app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
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");
}
}