master
Colby Knox Merge PR #322: kicad-lane: 1.0.73, framed renders, canvas check, engine 0.4.1 9f5826b 13d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784
//! The KiCad window model: what a KiCad window IS (title keywords, executable set, kind),
//! which one is the project manager, and the dialog pattern tables plus the pure decisions
//! (benign versus progress versus save prompt) ported from `handlers/close_windows.py`,
//! `handlers/kicad_ui.py`, `handlers/kicad_windows.py`, `handlers/win_focus.py` and the
//! title parsers of `open_symbol_editor.py` / `open_footprint_editor.py`.
//!
//! Everything here is pure: no OS call, no ab call. The verb group hands in titles, class
//! names, owners and body texts and gets a decision back, so every rule is unit-tested
//! without a desktop.

use serde_json::{json, Value};

// ── What is a KiCad window ────────────────────────────────────────────────────

/// Title keywords (kicad_ui.KICAD_TITLE_KEYWORDS). A title hit must ALSO belong to a
/// KiCad process (#23: a Chrome tab named "KiCad - ..." once matched).
pub const TITLE_KEYWORDS: &[&str] = &["KiCad", "Symbol Editor", "Footprint Editor", "3D Viewer", "eeschema", "pcbnew", "Schematic Editor", "PCB Editor"];

/// Executable base names, lower case, with and without the Windows suffix
/// (win_focus._KICAD_EXES plus gerbview, which the loose PID scan includes).
pub const KICAD_EXES: &[&str] = &[
    "kicad.exe", "eeschema.exe", "pcbnew.exe", "kicad-cli.exe", "pl_editor.exe", "bitmap2component.exe", "pcb_calculator.exe", "gerbview.exe",
    "kicad", "eeschema", "pcbnew", "kicad-cli", "pl_editor", "bitmap2component", "pcb_calculator", "gerbview",
];

/// Processes `kicad_close {force:true}` kills, in order (close_windows.WIN_KICAD_PROCESS_NAMES).
pub const KILL_PROCESS_NAMES: &[&str] = &["eeschema.exe", "pcbnew.exe", "bitmap2component.exe", "pcb_calculator.exe", "pl_editor.exe", "kicad.exe"];

/// The Win32 dialog window class.
pub const DIALOG_CLASS: &str = "#32770";

pub fn is_kicad_exe(exe: &str) -> bool {
    let e = exe.trim().to_ascii_lowercase();
    let base = e.rsplit(['/', '\\']).next().unwrap_or(&e);
    KICAD_EXES.contains(&base)
}

pub fn title_looks_kicad(title: &str) -> bool {
    let low = title.to_lowercase();
    TITLE_KEYWORDS.iter().any(|k| low.contains(&k.to_lowercase()))
}

/// What a KiCad top-level window is, by title (and ownership for dialogs).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WindowKind {
    ProjectManager,
    SchematicEditor,
    PcbEditor,
    SymbolEditor,
    FootprintEditor,
    Viewer3d,
    Dialog,
    Other,
}

impl WindowKind {
    /// The plugin inventory's kind names (bridge_client.handle_open_editors byKind).
    pub fn as_str(self) -> &'static str {
        match self {
            WindowKind::ProjectManager => "project_manager",
            WindowKind::SchematicEditor => "schematic_editor",
            WindowKind::PcbEditor => "pcb_editor",
            WindowKind::SymbolEditor => "symbol_editor",
            WindowKind::FootprintEditor => "footprint_editor",
            WindowKind::Viewer3d => "3d_viewer",
            WindowKind::Dialog => "dialog",
            WindowKind::Other => "other",
        }
    }
    /// The title fragment that names this kind (for window labelling and finders).
    pub fn title_word(self) -> &'static str {
        match self {
            WindowKind::ProjectManager => "KiCad",
            WindowKind::SchematicEditor => "Schematic Editor",
            WindowKind::PcbEditor => "PCB Editor",
            WindowKind::SymbolEditor => "Symbol Editor",
            WindowKind::FootprintEditor => "Footprint Editor",
            WindowKind::Viewer3d => "3D Viewer",
            WindowKind::Dialog => "",
            WindowKind::Other => "",
        }
    }
    pub fn is_editor(self) -> bool {
        matches!(self, WindowKind::SchematicEditor | WindowKind::PcbEditor | WindowKind::SymbolEditor | WindowKind::FootprintEditor | WindowKind::Viewer3d)
    }
}

/// A dialog is the Win32 dialog class or any owned top-level (close_windows: `cls ==
/// "#32770" or owner != 0`).
pub fn is_dialog(class_name: &str, owner: u64) -> bool {
    class_name == DIALOG_CLASS || owner != 0
}

/// close_windows._is_manager_window: the title starts with "KiCad" and names no editor or viewer.
pub fn is_manager_title(title: &str) -> bool {
    let low = title.trim().to_lowercase();
    low.starts_with("kicad") && !low.contains("editor") && !low.contains("viewer")
}

/// kicad_windows.find_project_manager: says KiCad, names no sub-tool, and is unowned.
pub fn is_project_manager(title: &str, owner: u64) -> bool {
    let low = title.to_lowercase();
    if !low.contains("kicad") {
        return false;
    }
    if ["editor", "viewer", "calculator"].iter().any(|k| low.contains(k)) {
        return false;
    }
    owner == 0
}

/// Classify a KiCad-owned top-level window.
pub fn classify(title: &str, class_name: &str, owner: u64) -> WindowKind {
    let low = title.to_lowercase();
    if low.contains("symbol editor") {
        return WindowKind::SymbolEditor;
    }
    if low.contains("footprint editor") {
        return WindowKind::FootprintEditor;
    }
    if low.contains("3d viewer") {
        return WindowKind::Viewer3d;
    }
    if low.contains("schematic editor") || low.contains("eeschema") {
        return WindowKind::SchematicEditor;
    }
    if low.contains("pcb editor") || low.contains("pcbnew") {
        return WindowKind::PcbEditor;
    }
    if is_dialog(class_name, owner) {
        return WindowKind::Dialog;
    }
    if is_manager_title(title) {
        return WindowKind::ProjectManager;
    }
    WindowKind::Other
}

// ── Dialog pattern tables (close_windows.py) ──────────────────────────────────

/// Titles that read like a KiCad error/notice box even when the owning process is not
/// in the PID set (close_windows._DIALOG_TITLE_HINTS).
pub const DIALOG_TITLE_HINTS: &[&str] = &[
    "kicad", "error", "warning", "confirmation", "information", "cannot", "denied", "failed", "not found", "opengl", "setup", "save", "unsaved",
    "annotate", "rescue", "migrate",
];

/// The "Save changes?" box KiCad raises on WM_CLOSE of a dirty editor.
pub const SAVE_PROMPT_PATTERNS: &[&str] = &["save changes", "save the changes", "unsaved", "changes to", "before closing"];

/// Info dialogs that are HARMLESS and safe to auto-expire (KiCad keeps working).
pub const BENIGN_DIALOG_PATTERNS: &[&str] = &[
    // GPU-less OpenGL notice (VMs / RDP / servers): stalls every editor + 3D open.
    "could not use opengl",
    "falling back to software rendering",
    "opengl 2.1 or higher is required",
    "using fallback",
    // KiCad first-run "Setup" / welcome wizard: dismissing it accepts the defaults.
    "welcome to kicad",
    "starting for the first time",
    "kicad setup",
    "configure some basic settings",
    "default settings",
    // "This file was created by an older version of KiCad" conversion info bar.
    "created by an older version of kicad",
    "converted to the new format",
];

/// v0.9.80: progress dialogs (wxProgressDialog) show ONE button and it is CANCEL.
/// "Dismissing" one aborts the operation the verb just started. Never touched.
pub const PROGRESS_BODY_PATTERNS: &[&str] = &["elapsed time", "remaining time", "estimated time"];
pub const PROGRESS_TITLE_PATTERNS: &[&str] = &["load pcb", "loading", "progress", "plotting", "rendering", "importing", "exporting", "generating"];

/// Button captions that make a benign dialog go away, in PRIORITY order: affirmative
/// first (info boxes only have OK), then wizard/skip buttons (the first-run wizard has
/// no OK, only Back/Next/Cancel). "yes" also clears the wizard's "cancel setup?" box.
pub const DISMISS_BUTTON_TIERS: &[&[&str]] = &[
    &["ok", "close", "continue", "got it", "dismiss", "yes"],
    &["finish", "cancel", "skip", "no thanks", "later", "quit"],
];

/// Labels of the Discard button on KiCad's save prompt, tried in order
/// (close_windows.handle_close_kicad).
pub const DISCARD_LABELS: &[&str] = &["discard changes", "discard", "don't save", "do not save", "no"];

/// Labels of the Discard button for the UIA fallback (case as KiCad renders them).
pub const DISCARD_LABELS_UIA: &[&str] = &["Discard Changes", "Discard", "Don't Save"];

pub fn is_benign_dialog(title: &str, body: &str) -> bool {
    let t = format!("{title} {body}").to_lowercase();
    BENIGN_DIALOG_PATTERNS.iter().any(|p| t.contains(p))
}

pub fn is_progress_dialog(title: &str, body: &str) -> bool {
    let t = title.trim().to_lowercase();
    let b = body.to_lowercase();
    if PROGRESS_BODY_PATTERNS.iter().any(|p| b.contains(p)) {
        return true;
    }
    PROGRESS_TITLE_PATTERNS.iter().any(|p| t.starts_with(p) || t == *p)
}

/// Tool windows KiCad opens on purpose from a menu (PCM, Preferences, the library
/// tables, Board/Schematic Setup). They are dialogs by window class but never an error
/// box: the sweep leaves them for the AI or the user to close.
pub const TOOL_DIALOG_TITLES: &[&str] = &[
    "plugin and content manager",
    "applying package changes",
    "preferences",
    "manage symbol libraries",
    "manage footprint libraries",
    "symbol libraries",
    "footprint libraries",
    "configure paths",
    "board setup",
    "schematic setup",
    "symbol fields table",
    "design rules",
];

pub fn is_tool_dialog(title: &str) -> bool {
    let t = title.trim().to_lowercase();
    TOOL_DIALOG_TITLES.iter().any(|p| t == *p || t.starts_with(&format!("{p} ")))
}

pub fn is_save_prompt(title: &str, body: &str) -> bool {
    let t = format!("{title} {body}").to_lowercase();
    SAVE_PROMPT_PATTERNS.iter().any(|p| t.contains(p))
}

/// close_windows._win_find_global_dialogs predicate: a #32770 with a KiCad-ish title or
/// any title at all (the plain editor frames are wxWindowNR and never match).
pub fn looks_like_global_dialog(title: &str, class_name: &str) -> bool {
    if class_name != DIALOG_CLASS {
        return false;
    }
    let low = title.to_lowercase();
    let looks_kicad = DIALOG_TITLE_HINTS.iter().any(|h| low.contains(h));
    looks_kicad || !title.is_empty()
}

/// What the sweep does with one dialog.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DialogAction {
    /// The verb is WORKING (load/plot/render). Report it, leave it, not an error.
    LeaveProgress,
    /// Click OK/Cancel/Escape/WM_CLOSE. `benign` says whether it counts as an error.
    Dismiss { benign: bool },
    /// Not benign and the sweep is benign-only: leave it up and report it.
    Leave,
}

/// close_windows._win_sweep_dialogs decision for one dialog.
pub fn sweep_decision(title: &str, body: &str, dismiss_all: bool) -> DialogAction {
    if is_progress_dialog(title, body) {
        return DialogAction::LeaveProgress;
    }
    if is_tool_dialog(title) && !dismiss_all {
        return DialogAction::Leave;
    }
    let benign = is_benign_dialog(title, body);
    if benign || dismiss_all {
        DialogAction::Dismiss { benign }
    } else {
        DialogAction::Leave
    }
}

/// auto_sweep_dialogs decision: every non-progress dialog is expired; a non-benign one
/// flips hadError.
pub fn auto_sweep_decision(title: &str, body: &str) -> DialogAction {
    if is_progress_dialog(title, body) {
        DialogAction::LeaveProgress
    } else if is_tool_dialog(title) {
        DialogAction::Leave
    } else {
        DialogAction::Dismiss { benign: is_benign_dialog(title, body) }
    }
}

/// A button label as the sweep compares it: mnemonic ampersands removed, trimmed, lower case.
pub fn normalize_button_label(label: &str) -> String {
    label.replace('&', "").trim().to_lowercase()
}

/// Which dismiss tier a button label belongs to (0 = affirmative, 1 = wizard/skip), if any.
pub fn dismiss_tier(label: &str) -> Option<usize> {
    let n = normalize_button_label(label);
    DISMISS_BUTTON_TIERS.iter().position(|tier| tier.contains(&n.as_str()))
}

pub fn is_discard_label(label: &str) -> bool {
    DISCARD_LABELS.contains(&normalize_button_label(label).as_str())
}

/// Body text of a dialog from its Static children (close_windows._win_get_dialog_body_text):
/// printable strings only, consecutive duplicates collapsed, joined by two spaces.
pub fn dialog_body(static_texts: &[String]) -> String {
    let mut out: Vec<&str> = Vec::new();
    for t in static_texts {
        let t = t.trim();
        if t.is_empty() || !t.chars().any(|c| c as u32 >= 32) {
            continue;
        }
        if out.last() != Some(&t) {
            out.push(t);
        }
    }
    out.join("  ").trim().to_string()
}

/// close_windows._win_force_software_canvas on one kicad_common.json document:
/// graphics.canvas_type 1 (OpenGL) becomes 2 (Cairo). Returns true when it changed.
pub fn force_software_canvas_doc(doc: &mut Value) -> bool {
    if !doc.is_object() {
        *doc = json!({});
    }
    let g = doc.as_object_mut().unwrap().entry("graphics").or_insert_with(|| json!({}));
    if !g.is_object() {
        *g = json!({});
    }
    if g.get("canvas_type") == Some(&json!(2)) {
        return false;
    }
    g["canvas_type"] = json!(2);
    true
}

// ── Editor title parsing (open_symbol_editor.py / open_footprint_editor.py) ───

/// Split "<library>:<name> \u{2014} Symbol Editor" (KiCad puts an em dash there; "- Footprint
/// Editor" is the hyphen form) into (library, name).
/// A leading '*' (unsaved) is dropped; the read-only marker KiCad appends to stock
/// libraries is stripped from the name (#49). ("", "") when no part is loaded.
pub fn parse_editor_title(title: &str, editor_word: &str) -> (String, String) {
    let mut t = title.trim().to_string();
    for suffix in [format!("\u{2014} {editor_word}"), format!("- {editor_word}"), editor_word.to_string()] {
        if t.ends_with(&suffix) {
            t = t[..t.len() - suffix.len()].trim().to_string();
            break;
        }
    }
    let t = t.trim_start_matches('*').trim().to_string();
    let Some((lib, name)) = t.split_once(':') else {
        return (String::new(), String::new());
    };
    let mut name = name.trim().to_string();
    for marker in ["[Read Only Library]", "[Read Only]", "(Read Only)"] {
        if name.ends_with(marker) {
            name = name[..name.len() - marker.len()].trim().to_string();
        }
    }
    (lib.trim().to_string(), name)
}

pub fn title_readonly(title: &str) -> bool {
    title.to_lowercase().contains("read only")
}

fn is_unit_suffix(sym: &str, requested: &str) -> bool {
    // NAME_<unit>_<style> (#54): the sub-node of the requested symbol is the requested part.
    let Some(rest) = sym.get(..requested.len()).filter(|p| p.eq_ignore_ascii_case(requested)).map(|_| &sym[requested.len()..]) else {
        return false;
    };
    let parts: Vec<&str> = rest.split('_').collect();
    parts.len() == 3 && parts[0].is_empty() && parts[1..].iter().all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
}

/// Does this Symbol Editor title PROVE the requested part (and library, when named) is
/// loaded? Exact library:symbol comparison, never a substring (0.9.194, #49, #54).
pub fn title_confirms_symbol(title: &str, symbol: &str, library: &str) -> bool {
    let (lib, sym) = parse_editor_title(title, "Symbol Editor");
    if sym.is_empty() || title.to_lowercase().contains("no symbol loaded") {
        return false;
    }
    if !sym.eq_ignore_ascii_case(symbol) && !is_unit_suffix(&sym, symbol) {
        return false;
    }
    if !library.is_empty() && !lib.eq_ignore_ascii_case(library) {
        return false;
    }
    true
}

/// Same for the Footprint Editor (0.9.207, wiki #42).
pub fn title_confirms_footprint(title: &str, footprint: &str, library: &str) -> bool {
    let (lib, fp) = parse_editor_title(title, "Footprint Editor");
    if fp.is_empty() || title.to_lowercase().contains("no footprint loaded") {
        return false;
    }
    if !fp.eq_ignore_ascii_case(footprint) {
        return false;
    }
    if !library.is_empty() && !lib.eq_ignore_ascii_case(library) {
        return false;
    }
    true
}

/// What the editor ACTUALLY landed on (wiki #49 ask 2): resolvedLibrary, resolvedSymbol,
/// resolvedFrom ("requested" | "fallback"), resolvedReadOnly.
pub fn resolved_fields(title: &str, requested_lib: &str, editor_word: &str) -> Value {
    let (lib, sym) = parse_editor_title(title, editor_word);
    if sym.is_empty() {
        return json!({});
    }
    let same = requested_lib.is_empty() || lib.eq_ignore_ascii_case(requested_lib);
    let mut out = json!({
        "resolvedLibrary": if lib.is_empty() { Value::Null } else { json!(lib) },
        "resolvedSymbol": sym,
        "resolvedFrom": if same { "requested" } else { "fallback" },
    });
    if title_readonly(title) {
        out["resolvedReadOnly"] = json!(true);
    }
    out
}

// ── Keys (kicad_ui.py) ────────────────────────────────────────────────────────

pub const VK_RETURN: u16 = 0x0D;
pub const VK_ESCAPE: u16 = 0x1B;
pub const VK_DOWN: u16 = 0x28;

/// kicad_ui.VK_MAP: named keys to virtual-key codes.
pub fn vk_for_key(name: &str) -> Option<u16> {
    Some(match name.trim().to_lowercase().as_str() {
        "enter" | "return" => 0x0D,
        "escape" | "esc" => 0x1B,
        "tab" => 0x09,
        "space" => 0x20,
        "up" => 0x26,
        "down" => 0x28,
        "left" => 0x25,
        "right" => 0x27,
        "backspace" => 0x08,
        "delete" => 0x2E,
        "home" => 0x24,
        "end" => 0x23,
        "pageup" => 0x21,
        "pagedown" => 0x22,
        "f1" => 0x70,
        "f2" => 0x71,
        "f3" => 0x72,
        "f4" => 0x73,
        "f5" => 0x74,
        "f6" => 0x75,
        "f7" => 0x76,
        "f8" => 0x77,
        "f9" => 0x78,
        "f10" => 0x79,
        "f11" => 0x7A,
        "f12" => 0x7B,
        _ => return None,
    })
}

/// A virtual key for one printable ASCII character (the letter/digit VKs equal the
/// upper-case ASCII code; anything else is delivered by WM_CHAR alone).
pub fn vk_for_char(ch: char) -> Option<u16> {
    if ch.is_ascii_alphanumeric() {
        return Some(ch.to_ascii_uppercase() as u16);
    }
    if ch == ' ' {
        return Some(0x20);
    }
    None
}

/// kicad_ui._MODIFIER_VK.
pub fn modifier_vk(name: &str) -> Option<u16> {
    Some(match name {
        "ctrl" | "control" => 0x11,
        "alt" | "menu" => 0x12,
        "shift" => 0x10,
        "win" | "super" | "lwin" => 0x5B,
        _ => return None,
    })
}

/// kicad_ui._parse_modifier_combo: "ctrl+shift+f10" -> ([0x11, 0x10], "f10"); None when
/// the key is not a combo or names an unknown modifier.
pub fn parse_modifier_combo(key: &str) -> Option<(Vec<u16>, String)> {
    if !key.contains('+') {
        return None;
    }
    let tokens: Vec<String> = key.split('+').map(|t| t.trim().to_lowercase()).filter(|t| !t.is_empty()).collect();
    if tokens.len() < 2 {
        return None;
    }
    let (base, mods) = tokens.split_last().unwrap();
    let mut vks = Vec::new();
    for m in mods {
        vks.push(modifier_vk(m)?);
    }
    Some((vks, base.clone()))
}

/// win_menu._norm_accel: "Ctrl+Shift+S" -> "ctrl+shift+s".
pub fn norm_accel(text: &str) -> String {
    text.to_lowercase().split_whitespace().collect::<String>()
}

// ── Canvas-painted probe (kicad_ui._canvas_uniformity) ────────────────────────

/// Is the editor's drawing CANVAS actually rendered, or an empty rectangle? Sample the
/// canvas region of a top-down RGB8 buffer and count distinct colours: a drawn canvas
/// carries grid dots, axes and the part; an unrendered one is one flat colour.
/// `rect` is (left, top, right, bottom) in window pixels (the wxGLCanvas), inset 4% so
/// border pixels never count; without it a coarse guess of the window is sampled and
/// `region` says "approx". A drawn canvas also has rows that vary: a blank one is flat rows, even
/// with a stray band across it (wiki #100: a white 3D viewer with one black strip had four colours,
/// all lit, and only 3% of its rows varied).
pub fn canvas_uniformity(rgb: &[u8], width: u32, height: u32, rect: Option<(i32, i32, i32, i32)>) -> Value {
    let (w, h) = (width as i64, height as i64);
    if w <= 0 || h <= 0 || rgb.len() < (w * h * 3) as usize {
        return json!({"checked": false});
    }
    let (x0, x1, y0, y1) = match rect {
        Some((l, t, r, b)) => {
            let (l, t, r, b) = (l as i64, t as i64, r as i64, b as i64);
            let dx = (r - l) * 4 / 100;
            let dy = (b - t) * 4 / 100;
            (l + dx, r - dx, t + dy, b - dy)
        }
        None => (w * 35 / 100, w * 95 / 100, h * 15 / 100, h * 85 / 100),
    };
    let (x0, y0) = (x0.max(0), y0.max(0));
    let (x1, y1) = (x1.min(w), y1.min(h));
    if x1 <= x0 || y1 <= y0 {
        return json!({"checked": false});
    }
    let region = if rect.is_some() { "glCanvas" } else { "approx" };
    let step_x = ((x1 - x0) / 60).max(1);
    let step_y = ((y1 - y0) / 60).max(1);
    let mut colors: std::collections::HashSet<[u8; 3]> = std::collections::HashSet::new();
    let mut samples = 0u64;
    let mut lit_samples = 0u64;
    let (mut rows, mut varied_rows) = (0u64, 0u64);
    let mut y = y0;
    while y < y1 {
        let mut x = x0;
        let first = { let i = ((y * w + x0) * 3) as usize; [rgb[i], rgb[i + 1], rgb[i + 2]] };
        let mut varied = false;
        while x < x1 {
            let i = ((y * w + x) * 3) as usize;
            let c = [rgb[i], rgb[i + 1], rgb[i + 2]];
            colors.insert(c);
            varied |= c != first;
            samples += 1;
            if rgb[i].max(rgb[i+1]).max(rgb[i+2]) > 24 { lit_samples += 1; }
            x += step_x;
        }
        rows += 1;
        varied_rows += varied as u64;
        y += step_y;
    }
    let varied_pct = if rows == 0 { 0.0 } else { (varied_rows * 1000 / rows) as f64 / 10.0 };
    json!({"checked": true, "distinctColors": colors.len(), "rendered": colors.len() > 3 && lit_samples * 100 >= samples * 3 && varied_rows * 10 >= rows,
        "litSamples": lit_samples, "variedRowsPct": varied_pct, "method": "canvas color heuristic, not geometry qualification", "samples": samples, "region": region})
}

/// A filename-safe label for a screenshot (kicad_ui.handle_screenshot_all).
pub fn safe_label(title: &str, prefix: &str) -> String {
    let safe: String = title.chars().filter(|c| c.is_alphanumeric() || "-_ ".contains(*c)).take(40).collect();
    let safe = safe.trim();
    if safe.is_empty() { prefix.to_string() } else { format!("{prefix}-{safe}") }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn exe_set_and_title_keywords() {
        assert!(is_kicad_exe("pcbnew.exe"));
        assert!(is_kicad_exe("C:/Program Files/KiCad/10.0/bin/KiCad.exe"));
        assert!(is_kicad_exe("eeschema"));
        assert!(!is_kicad_exe("chrome.exe"));
        assert!(title_looks_kicad("Adom:R \u{2014} Symbol Editor"));
        assert!(title_looks_kicad("KiCad 10.0"));
        assert!(!title_looks_kicad("Untitled - Notepad"));
    }

    #[test]
    fn classification_and_project_manager() {
        assert_eq!(classify("KiCad 10.0", "wxWindowNR", 0), WindowKind::ProjectManager);
        assert_eq!(classify("board.kicad_pcb \u{2014} PCB Editor", "wxWindowNR", 0), WindowKind::PcbEditor);
        assert_eq!(classify("Adom:R \u{2014} Symbol Editor", "wxWindowNR", 0), WindowKind::SymbolEditor);
        assert_eq!(classify("Adom:X - Footprint Editor", "wxWindowNR", 0), WindowKind::FootprintEditor);
        assert_eq!(classify("3D Viewer \u{2014} board.kicad_pcb", "wxWindowNR", 0), WindowKind::Viewer3d);
        assert_eq!(classify("sch.kicad_sch \u{2014} Schematic Editor", "wxWindowNR", 0), WindowKind::SchematicEditor);
        assert_eq!(classify("Error", "#32770", 0), WindowKind::Dialog);
        assert_eq!(classify("Confirmation", "wxWindowNR", 12345), WindowKind::Dialog);
        assert_eq!(classify("Something", "wxWindowNR", 0), WindowKind::Other);
        assert!(is_project_manager("KiCad 10.0.5", 0));
        assert!(!is_project_manager("KiCad 10.0.5", 77));
        assert!(!is_project_manager("KiCad PCB Editor", 0));
        assert!(!is_project_manager("PCB Calculator - KiCad", 0));
        assert!(is_manager_title("KiCad"));
        assert!(!is_manager_title("KiCad 3D Viewer"));
        assert!(WindowKind::PcbEditor.is_editor());
        assert!(!WindowKind::ProjectManager.is_editor());
        assert_eq!(WindowKind::Viewer3d.as_str(), "3d_viewer");
    }

    #[test]
    fn benign_progress_and_save_prompts() {
        assert!(is_benign_dialog("KiCad", "Could not use OpenGL, falling back to software rendering"));
        assert!(is_benign_dialog("KiCad Setup", ""));
        assert!(is_benign_dialog("Information", "This file was created by an older version of KiCad"));
        assert!(!is_benign_dialog("Error", "Cannot enumerate C:/Templates (Access denied)"));
        assert!(is_progress_dialog("Load PCB", ""));
        assert!(is_progress_dialog("Loading Symbol Libraries", ""));
        assert!(is_progress_dialog("Anything", "Elapsed time: 00:00:03"));
        assert!(!is_progress_dialog("Reloading failed", ""));
        assert!(is_save_prompt("Save Changes?", "Save changes to board.kicad_pcb before closing?"));
        assert!(is_save_prompt("Confirmation", "You have unsaved changes"));
        assert!(!is_save_prompt("Error", "File not found"));
    }

    #[test]
    fn sweep_decisions_never_touch_progress() {
        assert_eq!(sweep_decision("Load PCB", "Elapsed time 0:01", true), DialogAction::LeaveProgress);
        assert_eq!(sweep_decision("KiCad Setup", "", false), DialogAction::Dismiss { benign: true });
        assert_eq!(sweep_decision("Error", "Cannot open", false), DialogAction::Leave);
        assert_eq!(sweep_decision("Error", "Cannot open", true), DialogAction::Dismiss { benign: false });
        assert_eq!(auto_sweep_decision("Error", "Cannot open"), DialogAction::Dismiss { benign: false });
        assert_eq!(auto_sweep_decision("Rendering", ""), DialogAction::LeaveProgress);
        assert_eq!(auto_sweep_decision("Information", "converted to the new format"), DialogAction::Dismiss { benign: true });
        // Tool windows opened on purpose (PCM, Preferences, library tables) are never swept.
        assert_eq!(auto_sweep_decision("Plugin And Content Manager", ""), DialogAction::Leave);
        assert_eq!(auto_sweep_decision("Applying Package Changes", ""), DialogAction::Leave);
        assert_eq!(sweep_decision("Preferences", "", false), DialogAction::Leave);
        assert_eq!(sweep_decision("Plugin And Content Manager", "", true), DialogAction::Dismiss { benign: false });
        assert!(!is_tool_dialog("Error"));
    }

    #[test]
    fn global_dialog_predicate_and_buttons() {
        assert!(looks_like_global_dialog("KiCad PCB Editor Error", "#32770"));
        assert!(looks_like_global_dialog("Anything", "#32770"));
        assert!(!looks_like_global_dialog("", "#32770"));
        assert!(!looks_like_global_dialog("Error", "wxWindowNR"));
        assert_eq!(dismiss_tier("&OK"), Some(0));
        assert_eq!(dismiss_tier(" Yes "), Some(0));
        assert_eq!(dismiss_tier("&Cancel"), Some(1));
        assert_eq!(dismiss_tier("Finish"), Some(1));
        assert_eq!(dismiss_tier("Retry"), None);
        assert!(is_discard_label("&Discard Changes"));
        assert!(is_discard_label("Don't Save"));
        assert!(!is_discard_label("Save"));
        assert_eq!(normalize_button_label("&Got it"), "got it");
    }

    #[test]
    fn dialog_body_collapses_duplicates() {
        let texts = vec!["".into(), "Insufficient permissions".into(), "Insufficient permissions".into(), "to save".into(), "\u{1}\u{2}".into()];
        assert_eq!(dialog_body(&texts), "Insufficient permissions  to save");
        assert_eq!(dialog_body(&[]), "");
    }

    #[test]
    fn software_canvas_doc_edit() {
        let mut d = json!({"graphics": {"canvas_type": 1}, "api": {"enable_server": true}});
        assert!(force_software_canvas_doc(&mut d));
        assert_eq!(d["graphics"]["canvas_type"], json!(2));
        assert_eq!(d["api"]["enable_server"], json!(true));
        assert!(!force_software_canvas_doc(&mut d));
        let mut empty = Value::Null;
        assert!(force_software_canvas_doc(&mut empty));
        assert_eq!(empty["graphics"]["canvas_type"], json!(2));
    }

    #[test]
    fn editor_title_parsing() {
        assert_eq!(parse_editor_title("Adom:RP2040 \u{2014} Symbol Editor", "Symbol Editor"), ("Adom".into(), "RP2040".into()));
        assert_eq!(parse_editor_title("*Adom:RP2040 - Symbol Editor", "Symbol Editor"), ("Adom".into(), "RP2040".into()));
        assert_eq!(parse_editor_title("Device:R [Read Only Library] \u{2014} Symbol Editor", "Symbol Editor"), ("Device".into(), "R".into()));
        assert_eq!(parse_editor_title("[no symbol loaded] \u{2014} Symbol Editor", "Symbol Editor"), ("".into(), "".into()));
        assert_eq!(parse_editor_title("Adom:SOT-23 - Footprint Editor", "Footprint Editor"), ("Adom".into(), "SOT-23".into()));
        assert!(title_readonly("Device:R [Read Only Library] \u{2014} Symbol Editor"));
    }

    #[test]
    fn title_confirms_exactly_not_substring() {
        // 0.9.194: "AdomRP2040:ADOM_MECHANICAL_PIN" contains "rp2040" but is not it.
        assert!(!title_confirms_symbol("AdomRP2040:ADOM_MECHANICAL_PIN \u{2014} Symbol Editor", "RP2040", "Adom"));
        assert!(title_confirms_symbol("Adom:RP2040 \u{2014} Symbol Editor", "rp2040", ""));
        assert!(title_confirms_symbol("Adom:RP2040 \u{2014} Symbol Editor", "RP2040", "adom"));
        // #49: a same-named part in a different library is not a match when a library was named.
        assert!(!title_confirms_symbol("LED:WS2812B-2020 [Read Only Library] \u{2014} Symbol Editor", "WS2812B-2020", "Adom"));
        assert!(title_confirms_symbol("LED:WS2812B-2020 [Read Only Library] \u{2014} Symbol Editor", "WS2812B-2020", ""));
        // #54: the NAME_<unit>_<style> sub-node IS the requested part.
        assert!(title_confirms_symbol("Adom:STM32H563RGTx_1_1 \u{2014} Symbol Editor", "STM32H563RGTx", "Adom"));
        assert!(!title_confirms_symbol("Adom:STM32H563RGTx_1_1_extra \u{2014} Symbol Editor", "STM32H563RGTx", "Adom"));
        assert!(!title_confirms_symbol("[no symbol loaded] \u{2014} Symbol Editor", "R", ""));
        assert!(title_confirms_footprint("Adom:s10b-ph-sm4-tb - Footprint Editor", "S10B-PH-SM4-TB", "Adom"));
        assert!(!title_confirms_footprint("Adom:s10b-ph-sm4-tb - Footprint Editor", "S10B", "Adom"));
        assert!(!title_confirms_footprint("[no footprint loaded] - Footprint Editor", "X", ""));
    }

    #[test]
    fn resolved_fields_report_fallback_and_readonly() {
        let r = resolved_fields("LED:WS2812B-2020 [Read Only Library] \u{2014} Symbol Editor", "Adom", "Symbol Editor");
        assert_eq!(r["resolvedLibrary"], json!("LED"));
        assert_eq!(r["resolvedSymbol"], json!("WS2812B-2020"));
        assert_eq!(r["resolvedFrom"], json!("fallback"));
        assert_eq!(r["resolvedReadOnly"], json!(true));
        let r = resolved_fields("Adom:R \u{2014} Symbol Editor", "", "Symbol Editor");
        assert_eq!(r["resolvedFrom"], json!("requested"));
        assert!(r.get("resolvedReadOnly").is_none());
        assert_eq!(resolved_fields("[no symbol loaded] \u{2014} Symbol Editor", "", "Symbol Editor"), json!({}));
    }

    #[test]
    fn keys_and_combos() {
        assert_eq!(vk_for_key("Enter"), Some(0x0D));
        assert_eq!(vk_for_key("esc"), Some(0x1B));
        assert_eq!(vk_for_key("F12"), Some(0x7B));
        assert_eq!(vk_for_key("bogus"), None);
        assert_eq!(vk_for_char('a'), Some(0x41));
        assert_eq!(vk_for_char('7'), Some(0x37));
        assert_eq!(vk_for_char('!'), None);
        assert_eq!(parse_modifier_combo("ctrl+shift+f10"), Some((vec![0x11, 0x10], "f10".into())));
        assert_eq!(parse_modifier_combo("Alt+3"), Some((vec![0x12], "3".into())));
        assert_eq!(parse_modifier_combo("enter"), None);
        assert_eq!(parse_modifier_combo("bogus+x"), None);
        assert_eq!(norm_accel("Ctrl+Shift+ S"), "ctrl+shift+s");
    }

    #[test]
    fn almost_black_canvas_is_not_accepted_for_sparse_color_noise() {
        let mut rgb=vec![0u8;200*100*3];
        for i in 0..rgb.len() { rgb[i]=(i%23) as u8; }
        assert_eq!(canvas_uniformity(&rgb,200,100,None)["rendered"],json!(false));
    }
    #[test]
    fn a_blank_canvas_with_a_stray_band_is_not_rendered() {
        // wiki #100: a white viewer with one black strip across a quarter of its width
        let (w, h) = (400u32, 240u32);
        let mut rgb = vec![255u8; (w * h * 3) as usize];
        for y in 40..46u32 {
            for x in 0..100u32 {
                let i = ((y * w + x) * 3) as usize;
                rgb[i..i + 3].copy_from_slice(&[20, 20, 20]);
            }
            for x in 100..116u32 {
                // an anti-aliased edge, wide enough for the sampling grid to see its shades
                let i = ((y * w + x) * 3) as usize;
                let g = 60 + (x - 100) as u8 * 12;
                rgb[i..i + 3].copy_from_slice(&[g, g, g]);
            }
        }
        let v = canvas_uniformity(&rgb, w, h, Some((0, 0, w as i32, h as i32)));
        assert!(v["distinctColors"].as_u64().unwrap() > 3, "{v}");
        assert_eq!(v["rendered"], json!(false), "{v}");
    }
    #[test]
    fn canvas_probe_flat_versus_drawn() {
        let (w, h) = (200u32, 100u32);
        let flat = vec![40u8; (w * h * 3) as usize];
        let v = canvas_uniformity(&flat, w, h, Some((10, 10, 190, 90)));
        assert_eq!(v["checked"], json!(true));
        assert_eq!(v["rendered"], json!(false));
        assert_eq!(v["region"], json!("glCanvas"));
        let mut drawn = flat.clone();
        for i in 0..drawn.len() {
            drawn[i] = (i % 251) as u8;
        }
        let v = canvas_uniformity(&drawn, w, h, None);
        assert_eq!(v["rendered"], json!(true));
        assert_eq!(v["region"], json!("approx"));
        assert_eq!(canvas_uniformity(&flat, w, h, Some((0, 0, 0, 0)))["checked"], json!(false));
        assert_eq!(canvas_uniformity(&[], w, h, None)["checked"], json!(false));
    }

    #[test]
    fn safe_labels() {
        assert_eq!(safe_label("board.kicad_pcb \u{2014} PCB Editor", "editor"), "editor-boardkicad_pcb  PCB Editor");
        assert_eq!(safe_label("", "dialog"), "dialog");
    }
}