← All Pull Requests

1.0.101: kicad_show_library shows the library instead of walking its parts #65

Merged opened by Colby Knox 2026-11-05

1.0.101: kicad_show_library with only a library name now just shows the library.

  • Fix, parts walked: with no part named, kicad_show_library set the filter and then kept trying to load one, clicking through the library's parts row by row and leaving the last one on the canvas. It now stops once the filter is set, for symbols and footprints. Calls that name a part are unchanged.
  • Fix, parts listed: the tree now lists every part of the library and nothing from other libraries. The filter was a text search on the name, which left out parts that didn't mention it and could pull in other libraries' parts that did.
  • The reply says what the canvas shows: a warm editor keeps the part it already had, so canvasPart names it and canvasFromOtherLibrary says if it came from another library. A proof image shows that part until one is loaded.
  • Behaviour change: if the filter can't be set, the reply is now success:false with library_filter_failed. It used to say success with the library shown.

Tested live on DESKTOP-BAR6S0F, KiCad 10.0.8: cold and warm library browses on both editors with no part loaded, proof images checked by eye, named-part calls unchanged. Battery on the touched verbs only: 3/0/1, no foreground grabs.

🤖 Generated with Claude Code

Diff Skip to comments

--- a/rust/Cargo.toml+++ b/rust/Cargo.toml@@ -1,27 +1,28 @@⋯ 2 unchanged lines ⋯ members = ["crates/*"]  [workspace.package]-version = "1.0.100"+version = "1.0.101" edition = "2021" license = "MIT" publish = false⋯ 15 unchanged lines ⋯ codegen-units = 1 strip = true panic = "abort"+--- a/rust/Cargo.lock+++ b/rust/Cargo.lock@@ -1,1307 +1,1308 @@⋯ 318 unchanged lines ⋯  [[package]] name = "kicad-bridge"-version = "1.0.100"+version = "1.0.101" dependencies = [  "adom-eda-checks",  "kicad-core",⋯ 5 unchanged lines ⋯  [[package]] name = "kicad-core"-version = "1.0.100"+version = "1.0.101" dependencies = [  "kicad-ipc-rs",  "kicad-platform",⋯ 20 unchanged lines ⋯  [[package]] name = "kicad-platform"-version = "1.0.100"+version = "1.0.101" dependencies = [  "serde",  "serde_json",⋯ 940 unchanged lines ⋯ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"+--- a/rust/crates/kicad-bridge/src/verbs_show.rs+++ b/rust/crates/kicad-bridge/src/verbs_show.rs@@ -1,1005 +1,1007 @@⋯ 122 unchanged lines ⋯         mechanism: Mechanism::Window, risk: "read", timeout_sec: 130,         input: "{\"libraryName\": \"Device\", \"kind\"?: \"symbol\"|\"footprint\", \"symbolName\"?: \"...\", \"footprintName\"?: \"...\", \"capture\"?: bool, \"foreground\"?: bool, \"foregroundReason\"?: \"...\"}",         example: "kicad_show_library {\"libraryName\":\"Device\"}",-        hint: "kicad_show_library {\"libraryName\":\"Device\"} (add kind:\"footprint\" for the fp side). Filters the tree to the library. A symbolName/footprintName passed alongside is honoured, not dropped (#49 follow-up).",+        hint: "kicad_show_library {\"libraryName\":\"Device\"} (add kind:\"footprint\" for the fp side). Lists the whole library in the tree and loads nothing, in the background. A warm editor's canvas keeps the part it had: canvasPart names it and canvasFromOtherLibrary says if a proof image shows another library's part. A symbolName/footprintName passed alongside is honoured, not dropped (#49 follow-up).",         related: &["kicad_show_symbol", "kicad_show_footprint", "kicad_list_symbols"],         pitfalls: &["the library must be registered in the sym-/fp-lib-table"],     },⋯ 873 unchanged lines ⋯         assert_eq!(surface_kind("show_project"), None);     } }++--- a/rust/crates/kicad-bridge/src/verbs_windows.rs+++ b/rust/crates/kicad-bridge/src/verbs_windows.rs@@ -1,4003 +1,4075 @@⋯ 1434 unchanged lines ⋯ /// LIB_TREE filter, Down through the filter box, Enter posted to the tree candidates, /// Down x1..3 then Enter, a posted double-click on the first row. Zero focus. /// Returns (confirmed, trace, mechanism).-fn navigate_tree(frame: u64, filter: &str, verify: Verify<'_>) -> (bool, Value, &'static str) {-    let mut steps: Vec<String> = Vec::new();-    let mut trace = json!({"filter": filter, "editorHwnd": frame, "titleBefore": title_of(frame)});-    let mut mechanism = "post";-    let Some(edit) = ensure_tree_search(frame, &mut steps) else {-        steps.push("search Edit under a 'searchCtrl' parent NOT FOUND (library tree pane hidden, or a different child layout)".into());-        trace["steps"] = json!(steps);-        return (false, trace, mechanism);-    };-    trace["editHwnd"] = json!(edit.hwnd);-    let mut set = set_filter_text(edit.hwnd, filter);-    if let Err(e) = &set {-        // A slow box (2 cores, software rendering) spends many seconds loading every library the-        // first time an editor opens, and its UI thread answers nothing meanwhile: the text timed out-        // at 1.5 s and the verb gave up on a symbol that exists (winvm). Wait for the editor to answer-        // again, then set the text once more.-        steps.push(format!("filter text not accepted yet ({e}); waiting for the editor to become responsive"));-        let mut waited = 0;-        while waited < 40 && !native().responsive(frame, 500).unwrap_or(true) {-            sleep_ms(500);-            waited += 1;-        }-        sleep_ms(800);-        set = set_filter_text(edit.hwnd, filter);-    }-    match set {-        Ok(m) => {-            mechanism = m;-            steps.push(format!("filter text set via {m}"));-        }-        Err(e) => {-            steps.push(format!("could not set the filter text: {e}"));-            trace["steps"] = json!(steps);-            return (false, trace, mechanism);-        }-    }-    sleep_ms(1400); // LIB_TREE search debounce on the UI thread + tree rebuild-    // Down THROUGH the filter box: KiCad forwards nav keys to the tree.-    post_vk(edit.hwnd, wm::VK_DOWN);-    sleep_ms(500);-    steps.push(format!("VK_DOWN posted to edit; title now {:?}", title_of(frame)));-    // Tree candidates: tall siblings under the Libraries pane (wx generic controls share-    // bland class names, so geometry is the discriminator) plus any wxDataView.-    let all = descendants(frame);-    let parent = all.iter().find(|c| c.hwnd == edit.hwnd).map(|c| c.parent).unwrap_or(frame);-    let pane = all.iter().find(|c| c.hwnd == parent).map(|c| c.parent).filter(|p| *p != 0).unwrap_or(parent);-    let cands: Vec<Child> = all.iter().filter(|c| c.hwnd != edit.hwnd && (c.parent == pane || all.iter().any(|p| p.hwnd == c.parent && p.parent == pane)) && (c.rect.3 > 200 || c.class_name.starts_with("wxDataView"))).cloned().collect();-    trace["treeCandidates"] = json!(cands.iter().map(|c| json!({"hwnd": c.hwnd, "class": c.class_name, "rect": [c.rect.0, c.rect.1, c.rect.0 + c.rect.2, c.rect.1 + c.rect.3]})).collect::<Vec<_>>());-    let title_ok = || verify_title(&title_of(frame), verify);-    // Round 1: Enter posted straight to each tree candidate.-    for c in &cands {-        post_vk(c.hwnd, wm::VK_RETURN);-    }-    for _ in 0..6 {-        sleep_ms(700);-        if title_ok() {-            steps.push("VK_RETURN to tree candidates -> title confirms".into());-            trace["steps"] = json!(steps);-            return (true, trace, mechanism);-        }-    }-    steps.push(format!("VK_RETURN to {} tree candidate(s); title still {:?}", cands.len(), title_of(frame)));-    // Round 1b/1c (wiki #71): on a FRESH editor nothing is selected, so select from inside-    // the tree: Down (first lands on the library node, second on the symbol row), then Enter.-    for downs in 1..=3 {-        for c in &cands {-            post_vk(c.hwnd, wm::VK_DOWN);-        }-        sleep_ms(400);-        for c in &cands {-            post_vk(c.hwnd, wm::VK_RETURN);-        }-        for _ in 0..5 {-            sleep_ms(600);-            if title_ok() {-                steps.push(format!("Down x{downs} then VK_RETURN to tree candidates -> title confirms"));-                trace["steps"] = json!(steps);-                return (true, trace, mechanism);-            }-        }-        steps.push(format!("Down x{downs} then VK_RETURN to tree; title still {:?}", title_of(frame)));-    }-    // Round 2: a posted double-click on the first visible row (just under the header).-    for c in &cands {-        let x = (c.rect.2 / 3).max(10);-        let y = 58;-        let _ = native().post_click(c.hwnd, x, y, false);-        let _ = native().post_click(c.hwnd, x, y, true);-    }-    for _ in 0..8 {-        sleep_ms(700);-        if title_ok() {-            steps.push("posted double-click on first row -> title confirms".into());-            trace["steps"] = json!(steps);-            return (true, trace, mechanism);-        }-    }-    steps.push(format!("posted double-click on first row; title still {:?}", title_of(frame)));-    trace["steps"] = json!(steps);-    (false, trace, mechanism)-}--// ── Opening an editor frame: menu walk, then UIA ──────────────────────────────--struct Budget {-    t0: Instant,-    limit: Duration,-}--impl Budget {-    fn new(args: &Value) -> Budget {-        let inline = args.get("waitInline").and_then(Value::as_bool).unwrap_or(false);-        Budget { t0: Instant::now(), limit: if inline { Duration::from_secs(1_000_000) } else { Duration::from_secs(110) } }-    }-    fn out(&self) -> bool {-        self.t0.elapsed() > self.limit-    }-    fn elapsed_sec(&self) -> u64 {-        self.t0.elapsed().as_secs()-    }-}--struct OpenSpec {-    kind: WindowKind,-    /// Menu label fragment ("symbol editor") and the UIA accessible name ("Symbol Editor").-    menu_needle: &'static str,-    uia_name: &'static str,-    /// Frames whose menu bar / UIA tree carries the launcher, in preference order-    /// (a running editor of the same family first, the project manager last).-    hosts: &'static [WindowKind],-    error_code: &'static str,-    starting_code: &'static str,-    label: &'static str,-}--const SYMBOL_SPEC: OpenSpec = OpenSpec { kind: WindowKind::SymbolEditor, menu_needle: "symbol editor", uia_name: "Symbol Editor", hosts: &[WindowKind::SchematicEditor, WindowKind::ProjectManager], error_code: "symbol_editor_did_not_open", starting_code: "symbol_editor_starting", label: "Symbol Editor" };-const FOOTPRINT_SPEC: OpenSpec = OpenSpec { kind: WindowKind::FootprintEditor, menu_needle: "footprint editor", uia_name: "Footprint Editor", hosts: &[WindowKind::PcbEditor, WindowKind::ProjectManager], error_code: "footprint_editor_did_not_open", starting_code: "footprint_editor_starting", label: "Footprint Editor" };--/// The early-return contract when a slow cold start would outlive the transport budget-/// (#54 ask 2, wiki #71): honest about whether anything is actually starting.-fn starting_contract(spec: &OpenSpec, budget: &Budget, phase: &str, pathways: &[String]) -> Value {-    invalidate();-    if first_of_kind(spec.kind).is_none() {-        return json!({-            "success": false, "errorCode": spec.error_code, "retryable": true, "retryAfterMs": 5000,-            "phase": phase, "elapsedSec": budget.elapsed_sec(), "pathwaysTried": pathways,-            "error": format!("No {} window exists. The bridge asked KiCad to open one and none appeared, so this is not a slow cold start - nothing is coming up.", spec.label),-            "_hint": format!("Call kicad_open_{} first and check it returns a window, then re-call this verb. If the editor never opens, KiCad is usually busy with a human (it is single-instance and will not service our menu command while someone is driving it) - kicad_window_info shows what is actually open.", spec.kind.as_str()),-        });-    }-    json!({-        "success": false, "errorCode": spec.starting_code, "starting": true, "retryable": true, "statusVerb": "kicad_state",-        "retryAfterMs": 20000, "phase": phase, "elapsedSec": budget.elapsed_sec(),-        "error": format!("The {} is still coming up (cold start indexes every library, which can outlive the transport budget). This call returned early instead of timing out; KiCad keeps working.", spec.label),-        "_hint": "Not a failure - poll kicad_state (or just wait retryAfterMs) and re-call with the SAME args; a warm editor resolves in seconds. Pass waitInline:true to force the old single-call blocking behaviour.",-    })-}--/// Make sure the project manager exists (spawn kicad.exe in the background if not).-fn ensure_project_manager(info: &KicadInfo, pathways: &mut Vec<String>) -> Result<u64, Value> {-    invalidate();-    if let Some(h) = find_project_manager() {-        return Ok(h);-    }-    let Some(exe) = kicad_exe(info, "kicad") else {-        return Err(fail("kicad_exe_not_found", "KiCad executable not found", "Report to the user and ask them to reinstall KiCad; the installation appears corrupted."));-    };-    spawn_background(&exe, &[], "launch", None).map_err(|mut e| { if e["errorCode"] != json!("not_supported_on_platform") { e["_hint"] = json!("Call kicad_close with {\"force\": true} to clear stuck processes, then retry."); } e })?;-    pathways.push("spawned-project-manager".into());-    let deadline = Instant::now() + Duration::from_secs(10);-    loop {-        sleep_ms(500);-        invalidate();-        if let Some(h) = find_project_manager() {-            sleep_ms(2000);-            return Ok(h);-        }-        if Instant::now() > deadline {-            return Err(fail("project_manager_did_not_open", "KiCad launched but project manager window not found after 10s", "Call screenshot_all to see KiCad's current state, or call close_kicad with {\"force\": true} and retry."));-        }-    }-}--/// Open an editor frame: an existing one wins; else the native menu walk on each host-/// frame (WM_COMMAND, repost once at 10 s); else UIA Invoke. Returns (hwnd, mechanism).-fn open_editor_frame(info: &KicadInfo, spec: &OpenSpec, budget: &Budget, pathways: &mut Vec<String>) -> Result<(u64, &'static str), Value> {-    let out = open_editor_frame_inner(info, spec, budget, pathways);-    // Wiki #48: a frame the bridge just opened is placed by Windows defaults, which means the-    // primary display. An existing window is the user's own and is never moved.-    if let Ok((hwnd, mechanism)) = &out {-        if *mechanism != "existing" {-            if let Some(row) = restore_placement_one(spec.kind, *hwnd) {-                if row["moved"] == json!(true) {-                    pathways.push(format!("placement-restored:{}", spec.kind.as_str()));-                }-            }-        }-    }-    out-}--fn open_editor_frame_inner(info: &KicadInfo, spec: &OpenSpec, budget: &Budget, pathways: &mut Vec<String>) -> Result<(u64, &'static str), Value> {-    invalidate();-    if let Some(h) = first_of_kind(spec.kind) {-        return Ok((h, "existing"));-    }-    // Hosts: a live editor frame of the same family first, then the project manager-    // (spawned in the background when nothing at all is running).-    let mut hosts: Vec<u64> = Vec::new();-    for k in spec.hosts {-        if *k == WindowKind::ProjectManager {-            continue;-        }-        hosts.extend(windows_of_kind(*k).iter().map(|w| w.hwnd));-    }-    if spec.hosts.contains(&WindowKind::ProjectManager) {-        let pm = match find_project_manager() {-            Some(h) => h,-            None if hosts.is_empty() => ensure_project_manager(info, pathways)?,-            None => 0,-        };-        if pm != 0 {-            hosts.push(pm);-        }-    }-    if hosts.is_empty() {-        return Err(fail(spec.error_code, format!("No window can host the {} launcher: neither a project manager nor a related editor is open.", spec.label), "Call kicad_launch first, then retry."));-    }-    // Rung: native menu walk + WM_COMMAND (background, version-proof ids).-    let mut menu_unsupported: Option<Value> = None;-    for &host in &hosts {-        if budget.out() {-            return Err(starting_contract(spec, budget, "waiting-for-editor-window", pathways));-        }-        match menu_invoke_by_text(host, spec.menu_needle) {-            Ok(Some((id, _label))) => {-                pathways.push(format!("menu-walk-id-{id}"));-                pathways.push("wm_command".into());-                let deadline = Instant::now() + Duration::from_secs(30);-                if let Some(h) = wait_for_kind(spec.kind, deadline, Some((host, id)), Duration::from_secs(10)) {-                    return Ok((h, "menu"));-                }-                pathways.push("wm_command-no-window".into());-            }-            Ok(None) => pathways.push(format!("menu-walk-miss:{host}")),-            Err(e) if is_not_impl(&e) => { menu_unsupported = Some(unsupported("native menu walk (menu_tree)")); }-            Err(e) => pathways.push(format!("menu-walk-error:{}", e.chars().take(60).collect::<String>())),-        }-    }-    // Rung: UI Automation Invoke by accessible name (no focus, no cursor).-    let mut uia_ran = false;-    for &host in &hosts {-        if budget.out() {-            return Err(starting_contract(spec, budget, "waiting-for-editor-window", pathways));-        }-        match native().uia_invoke(host, spec.uia_name) {-            Ok(_) => {-                uia_ran = true;-                pathways.push("uia".into());-                let deadline = Instant::now() + Duration::from_secs(30);-                if let Some(h) = wait_for_kind(spec.kind, deadline, None, Duration::from_secs(10)) {-                    return Ok((h, "uia"));-                }-                pathways.push("uia-no-window".into());-            }-            Err(e) if is_not_impl(&e) => {-                if let Some(u) = menu_unsupported.take() {-                    return Err(u);-                }-                pathways.push("uia-unsupported".into());-            }-            Err(e) => {-                uia_ran = true;-                pathways.push(format!("uia-{}", e.chars().take(40).collect::<String>()));-            }-        }-    }-    if let (Some(u), false) = (menu_unsupported, uia_ran) {-        return Err(u);-    }-    let idle = user_idle_seconds();-    let busy = idle.map(|i| i < 120.0).unwrap_or(false);-    Err(json!({-        "success": false, "errorCode": spec.error_code, "pathwaysTried": pathways, "userIdleSeconds": idle,-        "error": format!("KiCad accepted the {} command but no editor window appeared within 30s.{}", spec.label,-            if busy { format!(" A human was active on this machine {}s ago, which is the most likely reason: KiCad is single-instance, and while someone is driving it, it does not service our menu command promptly.", idle.unwrap_or(0.0) as i64) } else { String::new() }),-        "_hint": "Retry - this is usually a busy KiCad rather than a broken one, and it succeeds on a machine nobody is touching (measured: 63s cold, 57s with the project manager already running). If it repeats on an IDLE box, that is a real bug worth reporting. Otherwise check for a modal dialog with kicad_screenshot_all and dismiss it with send_key escape.",-    }))-}--/// Ask an open editor to close so the next open re-indexes the libraries: ab's-/// desktop_close_window first (its bookkeeping), local WM_CLOSE otherwise; the WAIT is-/// measured here. Returns (gone, how).-fn close_and_wait(hwnd: u64, reason: &str, timeout: Duration) -> (bool, &'static str) {-    let how = match ab::desktop_close_window(hwnd, reason) {-        Ok(_) => "ad:desktop_close_window",-        Err(_) => {-            if native().post_close(hwnd).is_err() {-                return (false, "none");-            }-            "local:WM_CLOSE"-        }-    };-    invalidate();-    let deadline = Instant::now() + timeout;-    while Instant::now() < deadline {-        sleep_ms(300);-        invalidate();-        if !window_alive(hwnd) {-            return (true, how);-        }-    }-    (false, how)-}--// ── kicad_launch ──────────────────────────────────────────────────────────────--pub(crate) fn launch(state: &mut State, args: &Value) -> Value {-    let existing = match find_windows(false, true) {-        Ok((w, _)) => w,-        Err(e) => return e,-    };-    if !existing.is_empty() {-        return json!({-            "success": true, "alreadyRunning": true,-            "windows": existing.iter().take(10).map(|w| w.title.clone()).collect::<Vec<_>>(),-            "output": format!("KiCad is already running ({} window(s)); nothing launched.", existing.len()),-            "_hint": "Idempotent no-op: KiCad was already up. Use kicad_open_editors to inventory editors, or the open_* verbs to open a specific one.",-        });-    }-    let project = arg_str(args, "project").unwrap_or("").trim().to_string();-    if !project.is_empty() && !Path::new(&project).exists() {-        return fail("project_not_found", format!("Project not found: {project}"), "Absolute Windows path required; %VAR% expansion is not performed.");-    }-    let info = state.kicad_info();-    let Some(exe) = kicad_exe(&info, "kicad") else {-        return fail("kicad_exe_not_found", "kicad.exe not found", "No KiCad installation detected. kicad_readiness reports install state; kicad_upgrade installs KiCad silently (per-user, no UAC).");-    };-    let argv: Vec<String> = if project.is_empty() { vec![] } else { vec![project.clone()] };-    let (pid, log) = match spawn_background(&exe, &argv, "launch", arg_str(args, "traceMasks")) {-        Ok(x) => x,-        Err(mut e) => {-            if e["errorCode"] != json!("not_supported_on_platform") {-                e["error"] = json!(format!("Failed to launch KiCad: {}", e["error"].as_str().unwrap_or("")));-            }-            return e;-        }-    };-    let wait_s = args.get("waitSeconds").and_then(Value::as_f64).unwrap_or(15.0).max(3.0);-    let deadline = Instant::now() + Duration::from_secs_f64(wait_s);-    let mut windows = Vec::new();-    while Instant::now() < deadline {-        windows = find_windows(false, true).map(|(w, _)| w).unwrap_or_default();-        if !windows.is_empty() {-            break;-        }-        sleep_ms(500);-    }-    let log_path = log.as_ref().map(|p| norm(p));-    if !windows.is_empty() {-        // Wiki #48 ask 2: a cold spawn on a box that has run KiCad before lands on the monitor-        // the user last used, not on whatever Windows defaults to.-        sleep_ms(1200);-        let restored = restore_placement();-        return json!({-            "success": true, "launched": true, "pid": pid, "mechanism": "spawn",-            "windows": windows.iter().take(10).map(|w| w.title.clone()).collect::<Vec<_>>(),-            "output": format!("KiCad launched; {} window(s) up.", windows.len()),-            "logPath": log_path, "placementRestored": restored,-            "_hint": "Launched in the BACKGROUND (no focus steal). First run on a fresh install may show a setup wizard; kicad_window_info self-heals benign dialogs; kicad_screenshot_all shows every window if something looks stuck.",-            "_next": ["kicad_window_info", "kicad_open_editors", "kicad_open_board"],-        });-    }-    json!({-        "success": false, "pid": pid, "mechanism": "spawn", "logPath": log_path,-        "error": format!("kicad.exe started but no KiCad window appeared within {}s", wait_s as i64),-        "_hint": "Cold start on a slow box (or software-OpenGL) can exceed the wait; retry kicad_window_info in a few seconds before treating this as a failure. A modal (crash-recovery / first-run) can also hold the window back: kicad_screenshot_all will show it.",-        "statusVerb": "kicad_window_info",-    })-}--// ── kicad_open_board / kicad_open_schematic ───────────────────────────────────--pub(crate) fn open_file(state: &mut State, args: &Value, kind: WindowKind) -> Value {-    let info = state.kicad_info();-    if !info.installed {-        return fail("not_installed", "KiCad not installed", "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.");-    }-    let Some(file_path) = arg_str(args, "filePath") else {-        return fail("missing_arg", "No filePath specified", "Pass filePath: the absolute path of the file on the desktop.");-    };-    let path = PathBuf::from(file_path);-    if !path.exists() {-        return fail("file_not_found", format!("File not found: {file_path}"), "The path may exist on Docker but not on the Windows host. Use push_file to copy from Docker first, or verify the host-side path.");-    }-    let (exe_name, label, lint_verb) = match kind {-        WindowKind::PcbEditor => ("pcbnew", "PCB board", "kicad_lint_board"),-        _ => ("eeschema", "schematic", "kicad_lint_schematic"),-    };-    let Some(exe) = kicad_exe(&info, exe_name) else {-        return fail("kicad_exe_not_found", format!("KiCad executable not found: {exe_name}_exe"), "Report to the user and ask them to reinstall KiCad; the installation appears corrupted.");-    };-    let stem = path.file_stem().map(|s| s.to_string_lossy().to_lowercase()).unwrap_or_default();-    let name = path.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();-    // Already open: the existing window, not a fresh reload.-    invalidate();-    if let Some(w) = windows_of_kind(kind).into_iter().find(|w| !stem.is_empty() && w.title.to_lowercase().contains(&stem)) {-        // Wiki #122: a title match is only a basename match. For a board, ask KiCad which-        // file it really has open before reusing the window.-        let mut path_verified = false;-        if kind == WindowKind::PcbEditor {-            if let Ok(open) = open_board_paths(Duration::from_secs(3)) {-                let wanted = norm(&path);-                if open.iter().any(|p| same_board_path(p, &wanted)) {-                    path_verified = true;-                } else if !open.is_empty() {-                    // the API serves one editor: a board of the same file name elsewhere, or another editor's-                    // board entirely (a second pcbnew holds the API, and this window cannot be verified)-                    let same_name = open.iter().any(|p| p.replace('\\', "/").rsplit('/').next().map(|f| f.eq_ignore_ascii_case(&name)).unwrap_or(false));-                    let (code, error) = if same_name {-                        ("different_board_same_name", format!("The PCB editor has a different board open with the same file name ({name}): {}", open.join(", ")))-                    } else {-                        ("api_serves_another_board", format!("A PCB editor titled {name} is open, but KiCad's API answers for another editor's board ({}), so this window's board cannot be verified", open.join(", ")))-                    };-                    return json!({-                        "success": false, "errorCode": code, "hwnd": w.hwnd, "title": w.title,-                        "error": error,-                        "openBoards": open, "requested": wanted,-                        "_hint": "KiCad titles show only the file name and its API serves one editor, so the bridge will not guess. Close the other board (save it first if it has changes), or open a copy of yours under a unique file name.",-                    });-                }-            }-        }-        return json!({-            "success": true, "alreadyOpen": true, "hwnd": w.hwnd, "title": w.title, "mechanism": "existing",-            "pathVerified": path_verified,-            "output": format!("{label} editor already has {name} open"),-            "_hint": if path_verified { "The existing window was reused, not reloaded (KiCad confirmed the exact path). kicad_close first if you need a fresh load from disk." } else { "The existing window was reused on a file-name match that could not be confirmed against the full path (no KiCad API answer). If a same-named file from another folder could be open, kicad_close it first." },-        });-    }-    let (pid, log) = match spawn_background(&exe, &[norm(&path)], exe_name, None) {-        Ok(x) => x,-        Err(mut e) => {-            if e["errorCode"] != json!("not_supported_on_platform") {-                e["error"] = json!(format!("Failed to launch: {}", e["error"].as_str().unwrap_or("")));-                e["_hint"] = json!("Call close_kicad with {\"force\": true} to clear any stuck processes, then retry.");-            }-            return e;-        }-    };-    // Verify by window title with one deadline (no nested sleeps).-    let wait_s = args.get("waitSeconds").and_then(Value::as_f64).unwrap_or(30.0).clamp(3.0, 110.0);-    let deadline = Instant::now() + Duration::from_secs_f64(wait_s);-    if !native().capabilities().window_control {-        return open_file_without_windows(kind, &path, pid, log, deadline, wait_s, exe_name, label, lint_verb);-    }-    let mut appeared: Option<Win> = None;-    while Instant::now() < deadline {-        invalidate();-        let wins = windows_of_kind(kind);-        appeared = wins.iter().find(|w| w.title.to_lowercase().contains(&stem)).or_else(|| wins.iter().find(|w| w.pid == pid)).cloned();-        if appeared.is_some() {-            break;-        }-        if native().process_alive(pid) == Some(false) {-            break;-        }-        sleep_ms(500);-    }-    let hint = format!(-        "GUI launched. For unfamiliar files (ones you did not generate / have not validated), prefer running `{lint_verb}` FIRST. It uses kicad-cli to give you structured DRC/ERC + file-format info in ~1-3s, much faster + more reliable than recovering from a confused GUI via screenshots. If the GUI shows a modal dialog (file-version upgrade prompt, missing-symbol warning, recovery prompt), use kicad_window_info -> kicad_screenshot_all -> kicad_send_key to handle it; or close + retry after lint."-    );-    let mut out = json!({-        "success": true, "pid": pid, "mechanism": "spawn", "logPath": log.as_ref().map(|p| norm(p)),-        "output": format!("Launched {label} editor with {name}"),-        "_hint": hint,-    });-    match appeared {-        Some(w) => {-            out["hwnd"] = json!(w.hwnd);-            out["title"] = json!(w.title);-            out["windowAppeared"] = json!(true);-            // Wiki #48: this window was spawned by us, so Windows placed it on the primary-            // display; put it back on the monitor the user keeps this editor on.-            if let Some(row) = restore_placement_one(kind, w.hwnd) {-                out["placementRestored"] = row;-            }-        }-        None => {-            let alive = native().process_alive(pid);-            out["windowAppeared"] = json!(false);-            if alive == Some(false) {-                out["success"] = json!(false);-                out["errorCode"] = json!("editor_exited");-                out["error"] = json!(format!("{exe_name} exited before a {label} window appeared"));-                out["_hint"] = json!(format!("The file probably failed to load: the launch log (logPath) and kicad_log_tail carry KiCad's own message; {lint_verb} says what is wrong with the file."));-            } else {-                out["statusVerb"] = json!("kicad_window_info");-                out["_hint"] = json!(format!("{exe_name} is running but no {label} window titled after the file appeared within {}s (a cold start or a large file can take longer). Poll kicad_window_info; a modal dialog (kicad_screenshot_all) can also be holding it. {hint}", wait_s as i64));-            }-        }-    }-    out-}--/// Wiki #127: no window list on this OS (Linux for now). A board is confirmed through KiCad's-/// own API (the editor reports the exact path it has open); a schematic by the process staying up.-#[allow(clippy::too_many_arguments)]-fn open_file_without_windows(kind: WindowKind, path: &Path, pid: u32, log: Option<PathBuf>, deadline: Instant, wait_s: f64, exe_name: &str, label: &str, lint_verb: &str) -> Value {-    let wanted = norm(path);-    let name = path.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();-    let mut out = json!({-        "success": true, "pid": pid, "mechanism": "spawn", "logPath": log.as_ref().map(|p| norm(p)),-        "output": format!("Launched {label} editor with {name}"),-        "windowControl": false,-    });-    let mut confirmed = false;-    let mut last_open: Vec<String> = Vec::new();-    while Instant::now() < deadline {-        if native().process_alive(pid) == Some(false) {-            out["success"] = json!(false);-            out["errorCode"] = json!("editor_exited");-            out["error"] = json!(format!("{exe_name} exited before the {label} loaded"));-            out["_hint"] = json!(format!("The launch log (logPath) carries KiCad's own message; {lint_verb} says what is wrong with the file."));-            return out;-        }-        if kind == WindowKind::PcbEditor {-            if let Ok(open) = open_board_paths(Duration::from_secs(2)) {-                if open.iter().any(|p| same_board_path(p, &wanted)) {-                    confirmed = true;-                    break;-                }-                last_open = open;-            }-        } else if Instant::now() + Duration::from_secs_f64(wait_s) > deadline + Duration::from_secs(5) {-            // A schematic: no API answer to wait for on KiCad 10; five seconds alive is the signal.-            confirmed = true;-            break;-        }-        sleep_ms(500);-    }-    out["loaded"] = json!(confirmed);-    out["_hint"] = json!(if confirmed {-        if kind == WindowKind::PcbEditor { "KiCad's API confirms the board is open, so the live verbs (routing, placement, zones) can use it now. Window verbs (screenshots, keys, menus) are not available on this OS yet: use kicad_render_board or kicad_export_svg for a picture." } else { "The schematic editor is running. Window verbs (screenshots, keys, menus) are not available on this OS yet: use kicad_export_svg or kicad_export_pdf for a picture." }-    } else if last_open.is_empty() {-        "The editor is running but KiCad's API did not report the board in time. A cold start can take longer: poll kicad_routing_state with filePath. If it answers api_server_off, run kicad_ipc_api {\"enable\":true} with KiCad closed."-    } else {-        "The editor is running, but KiCad's API reports a different board (openBoards). KiCad serves one API per machine: the first KiCad that started owns it."-    });-    if !last_open.is_empty() && !confirmed {-        out["openBoards"] = json!(last_open);-    }-    out-}--// ── kicad_open_symbol_editor ──────────────────────────────────────────────────--fn lib_ctx(info: &KicadInfo) -> LibCtx {-    let inst = info.primary();-    let version = inst.map(|i| i.version.clone()).unwrap_or_default();-    let mut ctx = LibCtx {-        version: version.clone(),-        base_dir: inst.map(|i| i.base_dir.clone()).filter(|b| !b.is_empty()),-        config_dir: if version.is_empty() { None } else { native().config_dir(&version) },-        user_dir: if version.is_empty() { None } else { native().user_dir(&version) },-        kicad_cli: inst.map(|i| PathBuf::from(&i.kicad_cli)).filter(|p| p.is_file()),-        env_overrides: Default::default(),-    };-    ctx.load_env_overrides();-    ctx-}--/// open_symbol_editor._resolve_symbol_alias: exact, case-insensitive, then a UNIQUE-/// prefix match in either direction against the Adom library's real names.-fn resolve_symbol_alias(info: &KicadInfo, requested: &str) -> (Option<String>, String) {-    let ctx = lib_ctx(info);-    let Some((_table, lib_path)) = libraries::adom_paths(&ctx) else { return (None, "library file not found".into()) };-    if !lib_path.is_file() {-        return (None, "library file not found".into());-    }-    let names = libraries::list_symbols(&lib_path);-    if names.iter().any(|n| n == requested) {-        return (Some(requested.to_string()), "exact".into());-    }-    if let Some(n) = names.iter().find(|n| n.eq_ignore_ascii_case(requested)) {-        return (Some(n.clone()), "case-insensitive".into());-    }-    let r = requested.to_lowercase();-    let pref: Vec<&String> = names.iter().filter(|n| { let l = n.to_lowercase(); l.starts_with(&r) || r.starts_with(&l) }).collect();-    match pref.len() {-        1 => (Some(pref[0].clone()), "unique prefix match".into()),-        0 => (None, "no match".into()),-        _ => (None, format!("ambiguous: {:?}", pref.iter().take(5).collect::<Vec<_>>())),-    }-}--fn symbol_on_disk(info: &KicadInfo, name: &str) -> bool {-    let ctx = lib_ctx(info);-    libraries::adom_paths(&ctx).map(|(_, p)| p.is_file() && libraries::list_symbols(&p).iter().any(|n| n.eq_ignore_ascii_case(name))).unwrap_or(false)-}--pub(crate) fn open_symbol_editor(state: &mut State, args: &Value) -> Value {-    let info = state.kicad_info();-    if !info.installed {-        return fail("not_installed", "KiCad not installed", "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.");-    }-    let mut symbol_name = arg_str(args, "symbolName").unwrap_or("").to_string();-    let library_name = arg_any(args, &["libraryName", "library"]).unwrap_or("").to_string();-    // Wiki #62: the file is named for the MPN but the symbol inside may not be. Resolve-    // the caller's name against the library's real names BEFORE navigating and say so.-    let mut note = Map::new();-    if !symbol_name.is_empty() && (library_name.is_empty() || library_name.eq_ignore_ascii_case("adom")) {-        let (resolved, how) = resolve_symbol_alias(&info, &symbol_name);-        match resolved {-            Some(r) if r != symbol_name => {-                note.insert("aliasedFrom".into(), json!(symbol_name));-                note.insert("aliasHow".into(), json!(how));-                symbol_name = r;-            }-            None if how.starts_with("ambiguous") => { note.insert("aliasNote".into(), json!(format!("'{symbol_name}' is not in the library and the prefix match is {how}"))); }-            None if how == "no match" => { note.insert("aliasNote".into(), json!(format!("'{symbol_name}' is not a symbol name in the Adom library (no exact, case-insensitive or prefix match)"))); }-            _ => {}-        }-    }-    let mut out = open_symbol_editor_inner(&info, args, &symbol_name, &library_name);-    if !note.is_empty() {-        if let Some(o) = out.as_object_mut() {-            for (k, v) in &note {-                o.insert(k.clone(), v.clone());-            }-            if let Some(from) = note.get("aliasedFrom").and_then(Value::as_str) {-                let prev = o.get("_hint").and_then(Value::as_str).unwrap_or("").to_string();-                o.insert("_hint".into(), json!(format!("Resolved '{from}' to symbol '{symbol_name}' ({}). {prev}", note["aliasHow"].as_str().unwrap_or(""))));-            }-        }-    }-    out-}--fn open_symbol_editor_inner(info: &KicadInfo, args: &Value, symbol_name: &str, library_name: &str) -> Value {-    let spec = &SYMBOL_SPEC;-    let budget = Budget::new(args);-    let rescan = args.get("rescan").and_then(Value::as_bool).unwrap_or(false);-    let mut pathways: Vec<String> = Vec::new();-    invalidate();-    // Already loaded? (#49: the library is a CONSTRAINT, exact library:symbol match only.)-    if !symbol_name.is_empty() && !rescan {-        for w in windows_of_kind(WindowKind::SymbolEditor) {-            if wm::title_confirms_symbol(&w.title, symbol_name, library_name) {-                let _ = native().show_no_activate(w.hwnd);-                let mut v = json!({"success": true, "hwnd": w.hwnd, "mechanism": "existing", "output": format!("Symbol '{symbol_name}' is already loaded in the Symbol Editor.")});-                merge(&mut v, wm::resolved_fields(&w.title, library_name, "Symbol Editor"));-                return v;-            }-        }-    }-    // rescan:true (0.9.192): the remedy for library_cache_stale. Close the editor so the-    // open below indexes a fresh one. Opt-in: it closes a window the user may be using.-    let mut rescanned = false;-    let mut close_via = "";-    if rescan {-        if let Some(h) = first_of_kind(WindowKind::SymbolEditor) {-            let (gone, how) = close_and_wait(h, "Close the Symbol Editor so its next open re-indexes the symbol libraries", Duration::from_secs(12));-            close_via = how;-            if gone {-                rescanned = true;-                pathways.push("rescan-closed-stale-editor".into());-            } else {-                pathways.push("rescan-close-failed".into());-            }-        }-    }-    let (frame, mechanism) = match open_editor_frame(info, spec, &budget, &mut pathways) {-        Ok(x) => x,-        Err(e) => return e,-    };-    if mechanism != "existing" {-        sleep_ms(1500); // let the frame settle-    }-    let _ = native().show_no_activate(frame);-    if symbol_name.is_empty() && library_name.is_empty() {-        return json!({"success": true, "hwnd": frame, "mechanism": mechanism, "pathwaysTried": pathways, "output": if mechanism == "existing" { "Symbol Editor is already open.".to_string() } else { format!("Symbol Editor opened successfully ({mechanism}).") }});-    }-    // Library-only browse mode (0.9.92): filter the tree to the library, no part load.-    if symbol_name.is_empty() {-        let (_, trace, nav_mech) = navigate_tree(frame, library_name, Verify::Never);-        return json!({-            "success": true, "hwnd": frame, "libraryShown": library_name, "mechanism": nav_mech, "openedVia": mechanism, "navigation": trace,-            "output": format!("Symbol Editor open with the '{library_name}' library filtered and selected. No symbol was requested, so none was loaded into the canvas."),-            "via": "win32-library-browse",-            "_hint": "Library browse mode: the tree is filtered to the library. To load a part into the canvas, call again with symbolName too.",-        });-    }-    let filter = if library_name.is_empty() { symbol_name.to_string() } else { format!("{library_name} {symbol_name}") };-    let verify = Verify::Symbol { name: symbol_name, library: library_name };-    let mut attempts = 0;-    let mut last_trace = Value::Null;-    let mut last_mech = "post";-    for attempt in 1..=2 {-        attempts = attempt;-        let (ok, trace, nav_mech) = navigate_tree(frame, &filter, verify);-        last_trace = trace;-        last_mech = nav_mech;-        if ok {-            let title = title_of(frame);-            let mut v = json!({-                "success": true, "hwnd": frame, "mechanism": nav_mech, "openedVia": mechanism, "via": "win32-background", "attempts": attempt,-                "output": format!("Symbol '{symbol_name}' loaded (background {nav_mech} path{}): {title}", if attempt == 2 { ", 2nd attempt" } else { "" }),-                "navigation": last_trace, "pathwaysTried": pathways, "rescanned": rescanned,-                "editorCloseVia": if rescanned { json!(close_via) } else { Value::Null },-            });-            merge(&mut v, wm::resolved_fields(&title, library_name, "Symbol Editor"));-            return v;-        }-        if budget.out() {-            // Wiki #71: a navigation that ran against an editor that is up is not "starting".-            let title = title_of(frame);-            let mut v = json!({-                "success": false, "errorCode": "navigation_unconfirmed_within_budget", "retryable": true, "retryAfterMs": 3000,-                "elapsedSec": budget.elapsed_sec(), "editorOpened": true, "hwnd": frame, "rescanned": rescanned, "mechanism": nav_mech,-                "navigation": last_trace,-                "error": format!("The Symbol Editor is open and one background navigation ran, but the title did not confirm '{symbol_name}' before this call's time budget ended (title: {})", if title.is_empty() { "unknown".into() } else { title.clone() }),-                "_hint": "Re-call with the SAME args: the editor is warm now, so the whole call is the navigation alone (seconds). Read navigation.steps and navigation.treeCandidates: zero candidates means Enter reached no window (report it with the trace).",-            });-            merge(&mut v, wm::resolved_fields(&title, library_name, "Symbol Editor"));-            return v;-        }-    }-    // Two background attempts did not confirm. Is the symbol even in the editor's world?-    invalidate();-    if first_of_kind(WindowKind::SymbolEditor).is_none() {-        return json!({-            "success": false, "editorOpened": false, "symbolLoaded": false, "errorCode": "symbol_editor_not_open",-            "error": format!("No Symbol Editor is open, so '{symbol_name}' could not be loaded (it was open earlier in this call if rescan was requested)."),-            "_hint": "The editor is gone rather than confused: nothing to navigate. Call again without rescan to open a fresh one. If this followed rescan:true, the close succeeded and the reopen did not - that is a bridge bug worth reporting, not a library problem.",-        });-    }-    let title = title_of(frame);-    // Wiki #54, #71 (the 0.9.287 staleVeto, ported): an editor THIS call opened indexed the-    // libraries after the library file was last written, so its cache cannot be stale. Saying-    // library_cache_stale there sends the caller to rescan:true, which only closes and reopens-    // an already fresh editor and burns the budget again. The verdict is navigation_failed.-    let on_disk = symbol_on_disk(info, symbol_name);-    let editor_is_ours = mechanism != "existing";-    let stale = on_disk && !rescan && !editor_is_ours;-    if stale {-        let ctx = lib_ctx(info);-        let paths = libraries::adom_paths(&ctx);-        return json!({-            "success": false, "editorOpened": true, "symbolLoaded": false, "attempts": attempts, "hwnd": frame,-            "errorCode": "library_cache_stale", "mechanism": last_mech, "navigation": last_trace,-            "symbolInLibraryFile": true,-            "symLibTable": paths.as_ref().map(|(t, _)| norm(t)),-            "libraryFile": paths.as_ref().map(|(_, l)| norm(l)),-            "error": format!("'{symbol_name}' is in the Adom library file on disk, but two background navigations did not load it (title: {})", if title.is_empty() { "unknown".into() } else { title.clone() }),-            "_hint": "The verdict is only 'navigation failed while the symbol is in the Adom file'. Check the sym-lib-table row points at libraryFile (a OneDrive-redirected Documents folder is the classic split), then re-call with rescan:true to close and reopen the editor so it re-indexes; kicad_rescan_libraries force:true restarts KiCad when a reopen is not enough.",-        });-    }-    let mut v = json!({-        "success": false, "editorOpened": true, "symbolLoaded": false, "attempts": attempts, "hwnd": frame,-        "errorCode": "navigation_failed", "mechanism": last_mech, "navigation": last_trace, "pathwaysTried": pathways,-        "error": format!("Symbol Editor open but '{symbol_name}' didn't load in the background after two attempts (title: {})", if title.is_empty() { "unknown".into() } else { title.clone() }),-        "_hint": "Two background attempts did not confirm, so a third is unlikely to help - check that the symbol really exists in this library. The keystroke fallback (allowFocusSteal) is not in the native build; pass foreground:true with a foregroundReason to bring the editor to the user and let them load it.",-    });-    if on_disk && editor_is_ours {-        v["symbolInLibraryFile"] = json!(true);-        v["staleVeto"] = json!(format!("this call opened the Symbol Editor itself ({mechanism}), so it indexed the libraries after the library file was last written: the index cannot be stale, and rescan:true would close and reopen an already fresh editor"));-        v["_hint"] = json!("The symbol IS in the Adom library file and this call opened the editor itself, so the index is fresh: this is a navigation failure, not a stale cache, and rescan:true cannot help. Read navigation.steps and navigation.treeCandidates (zero candidates means Enter reached no window, worth reporting with the trace), then pass foreground:true with a foregroundReason to hand the editor to the user.");-    }-    merge(&mut v, wm::resolved_fields(&title, library_name, "Symbol Editor"));-    v-}--fn merge(into: &mut Value, extra: Value) {-    if let (Some(o), Some(e)) = (into.as_object_mut(), extra.as_object()) {-        for (k, v) in e {-            o.insert(k.clone(), v.clone());-        }-    }-}--// ── kicad_open_footprint_editor ───────────────────────────────────────────────--pub(crate) fn open_footprint_editor(state: &mut State, args: &Value) -> Value {-    let info = state.kicad_info();-    if !info.installed {-        return fail("not_installed", "KiCad not installed", "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.");-    }-    let spec = &FOOTPRINT_SPEC;-    let budget = Budget::new(args);-    let footprint_name = arg_any(args, &["footprintName", "footprint"]).unwrap_or("").to_string();-    let library_name = arg_any(args, &["libraryName", "library"]).unwrap_or("").to_string();-    let mut pathways: Vec<String> = Vec::new();-    invalidate();-    if !footprint_name.is_empty() {-        for w in windows_of_kind(WindowKind::FootprintEditor) {-            if wm::title_confirms_footprint(&w.title, &footprint_name, &library_name) {-                let _ = native().show_no_activate(w.hwnd);-                let mut v = json!({"success": true, "hwnd": w.hwnd, "mechanism": "existing", "output": format!("Footprint '{footprint_name}' is already loaded in the Footprint Editor.")});-                merge(&mut v, wm::resolved_fields(&w.title, &library_name, "Footprint Editor"));-                return v;-            }-        }-    }-    if args.get("allowCursorClick").and_then(Value::as_bool).unwrap_or(false) {-        pathways.push("icon_click_not_in_native_build".into());-    } else {-        pathways.push("icon_click_skipped_background_mode".into());-    }-    let (frame, mechanism) = match open_editor_frame(&info, spec, &budget, &mut pathways) {-        Ok(x) => x,-        Err(mut e) => {-            if e["errorCode"] == json!(spec.error_code) {-                e["error"] = json!("Could not open the Footprint Editor via the background path.");-                e["_hint"] = json!("The background rungs (the host frame's native menu, then UI Automation) fired without a Footprint Editor appearing. Open a board first (kicad_open_board) so a PCB Editor hosts the launcher, check kicad_screenshot_all for a modal dialog, then retry. The mouse-moving launcher click (allowCursorClick) is not in the native build.");-            }-            return e;-        }-    };-    if mechanism != "existing" {-        sleep_ms(1500);-    }-    let _ = native().show_no_activate(frame);-    if footprint_name.is_empty() && library_name.is_empty() {-        return json!({"success": true, "hwnd": frame, "mechanism": mechanism, "pathwaysTried": pathways, "output": if mechanism == "existing" { "Footprint Editor is already open.".to_string() } else { format!("Footprint Editor opened successfully ({mechanism}).") }});-    }-    if footprint_name.is_empty() {-        let (_, trace, nav_mech) = navigate_tree(frame, &library_name, Verify::Never);-        return json!({-            "success": true, "hwnd": frame, "libraryShown": library_name, "mechanism": nav_mech, "openedVia": mechanism, "navigation": trace,-            "output": format!("Footprint Editor open with the '{library_name}' library filtered and selected. No footprint was requested, so none was loaded into the canvas."),-            "via": "win32-library-browse",-            "_hint": "Library browse mode: the tree is filtered to the library. To load a part, call again with footprintName too.",-        });-    }-    let filter = if library_name.is_empty() { footprint_name.clone() } else { format!("{library_name} {footprint_name}") };-    let verify = Verify::Footprint { name: &footprint_name, library: &library_name };-    let mut last_trace = Value::Null;-    let mut last_mech = "post";-    for attempt in 1..=2 {-        let (ok, trace, nav_mech) = navigate_tree(frame, &filter, verify);-        last_trace = trace;-        last_mech = nav_mech;-        if ok {-            let title = title_of(frame);-            let mut v = json!({-                "success": true, "hwnd": frame, "mechanism": nav_mech, "openedVia": mechanism, "via": "win32-background", "attempts": attempt,-                "output": format!("Footprint '{footprint_name}' loaded (background {nav_mech} path{}): {title}", if attempt == 2 { ", 2nd attempt" } else { "" }),-                "navigation": last_trace, "pathwaysTried": pathways,-            });-            merge(&mut v, wm::resolved_fields(&title, &library_name, "Footprint Editor"));-            return v;-        }-        if budget.out() {-            return json!({-                "success": false, "errorCode": "navigation_unconfirmed_within_budget", "retryable": true, "retryAfterMs": 3000,-                "elapsedSec": budget.elapsed_sec(), "editorOpened": true, "hwnd": frame, "mechanism": nav_mech, "navigation": last_trace,-                "error": format!("The Footprint Editor is open and one background navigation ran, but the title did not confirm '{footprint_name}' before this call's time budget ended (title: {})", title_of(frame)),-                "_hint": "Re-call with the SAME args: the editor is warm now, so the whole call is the navigation alone (seconds).",-            });-        }-    }-    let title = title_of(frame);-    let mut v = json!({-        "success": false, "editorOpened": true, "footprintLoaded": false, "attempts": 2, "hwnd": frame,-        "errorCode": "navigation_failed", "mechanism": last_mech, "navigation": last_trace, "pathwaysTried": pathways,-        "error": format!("Footprint Editor open but '{footprint_name}' didn't load in the background after two attempts (title: {})", if title.is_empty() { "unknown".into() } else { title.clone() }),-        "_hint": "Two background attempts did not confirm - check that the footprint really exists in this library (kicad_list_footprints). If it was installed after the editor opened, KiCad's library index predates it: kicad_close_footprint_editor and re-call, or kicad_rescan_libraries force:true. The keystroke fallback (allowFocusSteal) is not in the native build.",-    });-    // Wiki #111: tell the three causes apart from the DISK, which the bridge can read and the running-    // KiCad may not have: no such library row, no such footprint in the library, or both are there and-    // this KiCad simply started before the library was registered.-    if !library_name.is_empty() {-        let ctx = lib_ctx(&state.kicad_info());-        let (libs, _) = libraries::list_footprints(&ctx, Some(&library_name));-        match libs.first() {-            None => {-                v["errorCode"] = json!("library_not_registered");-                v["error"] = json!(format!("No footprint library named '{library_name}' in the fp-lib-table of KiCad {} (or its folder is missing), so the Footprint Editor cannot load '{footprint_name}'", ctx.version));-                v["_hint"] = json!("kicad_list_footprints lists the registered libraries (names are case-sensitive in KiCad). Register one with kicad_install_library {libraryPath, libraryType:\"footprint\"}.");-            }-            Some((_, info)) if !info.footprints.iter().any(|f| f.eq_ignore_ascii_case(&footprint_name)) => {-                v["errorCode"] = json!("footprint_not_in_library");-                v["error"] = json!(format!("Library '{library_name}' is registered ({}), but it holds no footprint named '{footprint_name}'", info.path));-                v["availableFootprints"] = json!(info.footprints.iter().take(40).collect::<Vec<_>>());-                v["_hint"] = json!("Pick a name from availableFootprints (kicad_list_footprints {library} has the full list).");-            }-            // Only a row in the user's OWN table can have been added after KiCad started. A stock-            // library (reached through KiCad's nested template table) is loaded by every KiCad at-            // start, so for those the failure is the navigation itself: keep navigation_failed-            // (1.0.37 blamed Resistor_SMD on "registered after launch", AdomLapper 2026-09-21).-            Some(_) if !user_table_row(&ctx, &library_name) => {}-            Some((_, info)) => {-                v["errorCode"] = json!("library_not_loaded_by_running_kicad");-                v["libraryOnDisk"] = json!(info.path);-                v["error"] = json!(format!("'{library_name}:{footprint_name}' exists on disk and the library is in fp-lib-table, but the running KiCad did not load it (title: {}). KiCad reads its library tables when it starts, so a library registered after launch is unknown to it", if title.is_empty() { "unknown" } else { title.as_str() }));-                v["_hint"] = json!(format!("kicad_rescan_libraries {{\"footprints\":[\"{library_name}:{footprint_name}\"],\"force\":true,\"confirmRestart\":true}} restarts KiCad so it re-reads the tables (windows go back where they were; ask the user first if they have unsaved work), then call this verb again. Retrying without that will fail the same way."));-            }-        }-    }-    merge(&mut v, wm::resolved_fields(&title, &library_name, "Footprint Editor"));-    v-}--// ── kicad_open_3d_viewer ──────────────────────────────────────────────────────--static VIEWER_SOURCE_BINDINGS: Mutex<BTreeMap<u64,(u32,u64)>> = Mutex::new(BTreeMap::new());-fn viewer_for_frame(frame:u64, source_pid:u32, windows:Vec<Win>, before:&[u64], editor_count:usize, known:Option<u64>)->Option<Win>{-    let eligible:Vec<_>=windows.into_iter().filter(|w|source_pid!=0 && w.pid==source_pid && (w.owner==0 || w.owner==frame)).collect();-    let exact:Vec<_>=eligible.iter().filter(|w|w.owner==frame).cloned().collect();-    if exact.len()==1{return exact.into_iter().next()}-    if exact.len()>1{return None}-    if let Some(id)=known {if let Some(w)=eligible.iter().find(|w|w.hwnd==id){return Some(w.clone())}}-    let created:Vec<_>=eligible.iter().filter(|w|!before.contains(&w.hwnd)).cloned().collect();-    if created.len()==1{return created.into_iter().next()}-    if editor_count==1 && eligible.len()==1{return eligible.into_iter().next()}-    None-}-fn poll_3d_window(frame:u64,source_pid:u32,before:&[u64],rounds:usize)->Option<Win>{-    for _ in 0..rounds.max(1){-        sleep_ms(750);invalidate();-        let editor_count=kicad_windows().iter().filter(|w|w.pid==source_pid && matches!(w.kind(),WindowKind::PcbEditor|WindowKind::FootprintEditor)).count();-        let known=VIEWER_SOURCE_BINDINGS.lock().ok().and_then(|m|m.get(&frame).filter(|(pid,_)|*pid==source_pid).map(|(_,h)|*h));-        if let Some(w)=viewer_for_frame(frame,source_pid,windows_of_kind(WindowKind::Viewer3d),before,editor_count,known){-            if let Ok(mut map)=VIEWER_SOURCE_BINDINGS.lock(){map.insert(frame,(source_pid,w.hwnd));}-            return Some(w)-        }-    }-    None-}--/// The rungs for one source frame: native menu walk, then UIA Invoke. Ok(Some) when a-/// 3D Viewer window appeared.-fn open_3d_from(frame: u64, label: &str, pathways: &mut Vec<String>) -> Result<Option<Value>, Value> {-    let source_pid = match kicad_windows().into_iter().find(|w| w.hwnd == frame) {-        Some(w) => w.pid,-        None => return Err(fail("source_editor_missing", "The requested source editor is no longer open.", "Call kicad_open_editors and pass the current pcbHwnd or fpHwnd.")),-    };-    let before:Vec<u64>=windows_of_kind(WindowKind::Viewer3d).iter().map(|w|w.hwnd).collect();-    let mut unsupported_menu: Option<Value> = None;-    match menu_invoke_by_text(frame, "3d viewer") {-        Ok(Some((id, _))) => {-            pathways.push(format!("win32menu(id={id})"));-            if let Some(w) = poll_3d_window(frame, source_pid, &before, 12) {-                return Ok(Some(json!({"success": true, "pathway": "win32menu", "mechanism": "menu", "hwnd": w.hwnd, "sourceHwnd": frame, "sourcePid": source_pid, "viewerPid": w.pid, "title": w.title, "output": format!("3D Viewer opened from {label} (Win32 menu): {}", w.title), "pathwaysTried": pathways})));-            }-        }-        Ok(None) => pathways.push("win32menu-not-found".into()),-        Err(e) if is_not_impl(&e) => unsupported_menu = Some(unsupported("native menu walk (menu_tree)")),-        Err(e) => pathways.push(format!("win32menu-{}", e.chars().take(40).collect::<String>())),-    }-    match native().uia_invoke(frame, "3D Viewer") {-        Ok(_) => {-            pathways.push("uia".into());-            if let Some(w) = poll_3d_window(frame, source_pid, &before, 12) {-                return Ok(Some(json!({"success": true, "pathway": "uia", "mechanism": "uia", "hwnd": w.hwnd, "sourceHwnd": frame, "sourcePid": source_pid, "viewerPid": w.pid, "title": w.title, "output": format!("3D Viewer opened from {label} via UIA: {}", w.title), "pathwaysTried": pathways})));-            }-        }-        Err(e) if is_not_impl(&e) => {-            // Neither rung exists on this OS: say so instead of "did not open".-            if let Some(u) = unsupported_menu {-                return Err(u);-            }-            pathways.push("uia-unsupported".into());-        }-        Err(e) => pathways.push(format!("uia-{}", e.chars().take(40).collect::<String>())),-    }-    Ok(None)-}---/// `kicad_3d_view`: the 3D Viewer's View-menu commands by name, repeated with pauses, or a tour.--/// `kicad_menu_items`: the native menu bar of a KiCad window, read-only.-fn is_view_refresh(path: &[String]) -> bool {-    let clean = |s: &str| {-        s.replace('&', "")-            .split('\t')-            .next()-            .unwrap_or("")-            .trim()-            .to_lowercase()-    };-    path.len() == 2 && clean(&path[0]) == "view" && clean(&path[1]) == "refresh"-}--pub(crate) fn refresh_board_views(args: &Value) -> Value {-    let Some(frame) = arg_any_u64(args, &["pcbHwnd"]) else {-        return fail(-            "source_editor_required",-            "Pass the exact pcbHwnd.",-            "Read kicad_open_editors first.",-        );-    };-    invalidate();-    let windows = kicad_windows();-    let Some(source) = windows-        .iter()-        .find(|w| w.hwnd == frame && w.kind() == WindowKind::PcbEditor)-    else {-        return fail(-            "source_editor_missing",-            "Requested PCB editor is not open.",-            "No substitute was used.",-        );-    };-    let viewers = windows_of_kind(WindowKind::Viewer3d);-    let before: Vec<_> = viewers.iter().map(|w| w.hwnd).collect();-    let count = windows-        .iter()-        .filter(|w| {-            w.pid == source.pid-                && matches!(-                    w.kind(),-                    WindowKind::PcbEditor | WindowKind::FootprintEditor-                )-        })-        .count();-    let known = VIEWER_SOURCE_BINDINGS.lock().ok().and_then(|m| {-        m.get(&frame)-            .filter(|(pid, _)| *pid == source.pid)-            .map(|(_, h)| *h)-    });-    let Some(viewer) = viewer_for_frame(frame, source.pid, viewers, &before, count, known) else {-        return fail(-            "viewer_binding_ambiguous",-            "No uniquely linked open 3D viewer.",-            "Open the viewer from this exact PCB editor first; nothing was refreshed.",-        );-    };-    let mut commands = Vec::new();-    for hwnd in [frame, viewer.hwnd] {-        let items = match native().menu_tree(hwnd) {-            Ok(items) => items,-            Err(e) => return fail("menu_walk_failed", &e, "No refresh was invoked."),-        };-        let matches: Vec<_> = items-            .iter()-            .filter(|m| m.id != 0 && is_view_refresh(&m.path))-            .collect();-        if matches.len() != 1 {-            return fail(-                "refresh_action_missing",-                "Exact View > Refresh menu not uniquely available.",-                "No fallback menu or keyboard action was invoked.",-            );-        }-        commands.push((hwnd, matches[0].id));-    }-    let mut invoked = Vec::new();-    for (hwnd, id) in commands {-        if let Err(e) = native().menu_invoke(hwnd, id) {-            return json!({"success":false,"errorCode":"refresh_invoke_failed","error":e,"refreshRequestedFor":invoked,"sourceHwnd":frame,"viewerHwnd":viewer.hwnd});-        }-        invoked.push(hwnd);-    }-    invalidate();-    json!({"success":true,"refreshRequested":true,"sourceHwnd":frame,"sourcePid":source.pid,"viewerHwnd":viewer.hwnd,"viewerPid":viewer.pid,"renderVerified":false,"_hint":"Both refresh commands were requested without foreground or cursor actions. Inspect kicad_state screenshots after rebuilding; command acknowledgement is not render completion."})-}--pub(crate) fn menu_items(args: &Value) -> Value {-    let hwnd = match arg_any_u64(args, &["hwnd"]).or_else(|| foreground_kicad_hwnd()) {-        Some(h) => h,-        None => return fail("no_window", "No KiCad window found.", "kicad_window_info lists the windows; pass hwnd."),-    };-    let needle = arg_str(args, "contains").map(|s| s.to_lowercase()).unwrap_or_default();-    match native().menu_tree(hwnd) {-        Ok(items) => {-            let list: Vec<Value> = items.iter().filter(|it| needle.is_empty() || it.path.iter().any(|p| p.replace('&', "").to_lowercase().contains(&needle))).map(|it| json!({"path": it.path.iter().map(|p| p.replace('&', "")).collect::<Vec<_>>(), "id": it.id, "accel": it.accel})).collect();-            json!({"success": true, "hwnd": hwnd, "title": title_of(hwnd), "count": list.len(), "items": list})-        }-        Err(e) if is_not_impl(&e) => unsupported("native menu walk (menu_tree)"),-        Err(e) => fail("menu_walk_failed", &e, "Retry once the window is idle."),-    }-}--pub(crate) fn view_3d(args: &Value) -> Value {-    const ACTIONS: &[(&str, &str)] = &[-        ("pan_left", "Move Board Left"), ("pan_right", "Move Board Right"),-        ("pan_up", "Move Board Up"), ("pan_down", "Move Board Down"),-        ("zoom_in", "Zoom In"), ("zoom_out", "Zoom Out"), ("fit", "Zoom to Fit"),-        ("rotate_x_cw", "Rotate X Clockwise"), ("rotate_x_ccw", "Rotate X Counterclockwise"),-        ("rotate_y_cw", "Rotate Y Clockwise"), ("rotate_y_ccw", "Rotate Y Counterclockwise"),-        ("rotate_z_cw", "Rotate Z Clockwise"), ("rotate_z_ccw", "Rotate Z Counterclockwise"),-        ("view_top", "View Top"), ("view_bottom", "View Bottom"), ("view_front", "View Front"), ("view_back", "View Back"),-        ("view_left", "View Left"), ("view_right", "View Right"), ("flip", "Flip Board"),-    ];-    let action = arg_str(args, "action").unwrap_or("").trim().to_lowercase();-    if action == "actions" || action.is_empty() {-        return json!({"success": true, "actions": ACTIONS.iter().map(|(a, l)| json!({"action": a, "menuItem": l})).collect::<Vec<_>>(), "tour": "fit, view_top, rotate_x_ccw x2, rotate_z_cw x8, zoom_in x3, rotate_z_ccw x4, zoom_out x3, view_front, rotate_z_cw x4, view_bottom, flip, view_top, fit", "_hint": "Pass action (and count / pauseMs), or action:\"tour\" with an optional steps list of [action, count] pairs."});-    }-    invalidate();-    let hwnd = match arg_any_u64(args, &["hwnd"]).or_else(|| first_of_kind(WindowKind::Viewer3d)) {-        Some(h) => h,-        None => return fail("viewer_not_open", "No 3D Viewer window is open.", "kicad_open_3d_viewer {editor:\"pcb\"} opens it from the PCB Editor's View menu; it takes a few seconds to render."),-    };-    let pause = args.get("pauseMs").and_then(Value::as_u64).unwrap_or(700).clamp(0, 10_000);-    let count = args.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 60) as usize;-    let steps: Vec<(String, usize)> = if action == "tour" {-        match args.get("steps").and_then(Value::as_array) {-            Some(a) => a.iter().filter_map(|e| { let a = e.as_array()?; Some((a.first()?.as_str()?.to_lowercase(), a.get(1).and_then(Value::as_u64).unwrap_or(1).clamp(1, 60) as usize)) }).collect(),-            None => vec![("fit", 1), ("view_top", 1), ("rotate_x_ccw", 2), ("rotate_z_cw", 8), ("zoom_in", 3), ("rotate_z_ccw", 4), ("zoom_out", 3), ("view_front", 1), ("rotate_z_cw", 4), ("view_bottom", 1), ("flip", 1), ("view_top", 1), ("fit", 1)].into_iter().map(|(a, n)| (a.to_string(), n)).collect(),-        }-    } else {-        vec![(action.clone(), count)]-    };-    let mut done: Vec<Value> = Vec::new();-    let started = Instant::now();-    for (act, n) in &steps {-        let Some((_, label)) = ACTIONS.iter().find(|(a, _)| *a == act.as_str()) else {-            return fail("bad_action", format!("Unknown action '{act}'."), "kicad_3d_view {action:\"actions\"} lists them.");-        };-        for _ in 0..*n {-            match menu_invoke_by_text(hwnd, label) {-                Ok(Some((id, _))) => { done.push(json!({"action": act, "cmdId": id})); }-                Ok(None) => return fail("menu_item_not_found", format!("The 3D Viewer has no menu item '{label}' (KiCad version?)."), "kicad_send_key {hwnd, menu:...} with a substring finds what this build calls it."),-                Err(e) => return fail("menu_invoke_failed", &e, "Retry once the viewer has finished rendering."),-            }-            std::thread::sleep(Duration::from_millis(pause));-        }-    }-    json!({"success": true, "hwnd": hwnd, "commands": done.len(), "steps": steps.iter().map(|(a, n)| json!([a, n])).collect::<Vec<_>>(), "seconds": (started.elapsed().as_secs_f64() * 10.0).round() / 10.0, "foreground": false,-        "_hint": "Every command was posted to the viewer's own menu in the background. kicad_screenshot_all shows the view; a window recording of the viewer has the motion."})-}--pub(crate) fn open_3d_viewer(args: &Value) -> Value {-    let choice = arg_str(args, "editor").unwrap_or("auto").to_lowercase();-    if !["auto", "fp", "footprint", "pcb", "board"].contains(&choice.as_str()) {-        return fail("bad_arg", format!("Unknown editor '{choice}'. Use 'auto', 'fp', or 'pcb'."), "editor selects which open editor's View menu opens the viewer.");-    }-    invalidate();-    let mut fp = first_of_kind(WindowKind::FootprintEditor);-    let mut pcb = first_of_kind(WindowKind::PcbEditor);-    // Explicit source editors (tour, 2026-09-03): a caller that knows its editor's hwnd-    // passes pcbHwnd / fpHwnd and we never guess among several PCB Editors.-    if let Some(h) = arg_any_u64(args, &["pcbHwnd", "pcb_hwnd"]) {-        pcb = Some(h);-    }-    if let Some(h) = arg_any_u64(args, &["fpHwnd", "fp_hwnd"]) {-        fp = Some(h);-    }-    match choice.as_str() {-        "fp" | "footprint" => {-            if fp.is_none() {-                return fail("footprint_editor_not_open", "Footprint Editor is not open.", "Call open_footprint_editor first or pass editor='pcb'.");-            }-            pcb = None;-        }-        "pcb" | "board" => {-            if pcb.is_none() {-                return fail("pcb_editor_not_open", "PCB Editor is not open.", "Call open_board first or pass editor='fp'.");-            }-            fp = None;-        }-        _ => {-            if pcb.is_some() && fp.is_some() {-                fp = None; // auto: prefer PCB when both are open (tour-like flow)-            }-        }-    }-    let mut pathways: Vec<String> = Vec::new();-    if let Some(f) = fp {-        match open_3d_from(f, "Footprint Editor", &mut pathways) {-            Ok(Some(v)) => return v,-            Err(e) => return e,-            Ok(None) => {}-        }-        if choice == "auto" && pcb.is_none() {-            pcb = first_of_kind(WindowKind::PcbEditor);-        }-        if pcb.is_none() {-            return json!({"success": false, "errorCode": "3d_viewer_did_not_open", "pathwaysTried": pathways, "error": "3D Viewer did not open from Footprint Editor.", "_hint": "Call open_footprint_editor with a footprintName first, OR pass editor='pcb' to target the board's 3D viewer instead."});-        }-    }-    if let Some(p) = pcb {-        match open_3d_from(p, "PCB Editor", &mut pathways) {-            Ok(Some(v)) => return v,-            Err(e) => return e,-            Ok(None) => {}-        }-        return json!({"success": false, "errorCode": "3d_viewer_did_not_open", "pathwaysTried": pathways, "error": "3D Viewer did not open via the background path.", "_hint": "Call open_board {\"filePath\":\"...\"} to load a PCB in pcbnew first, then retry; kicad_screenshot_all shows whether a dialog is holding the editor."});-    }-    fail("no_source_editor", "Neither Footprint Editor nor PCB Editor is open. Open one first with open_footprint_editor or open_board.", "Call open_footprint_editor or open_board {\"filePath\":\"...\"} first, then retry.")-}--fn arg_any_u64(args: &Value, keys: &[&str]) -> Option<u64> {-    keys.iter().find_map(|k| args.get(*k)).and_then(|v| v.as_u64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))).filter(|h| *h != 0)-}--// ── kicad_open_editors ────────────────────────────────────────────────────────--fn open_editors() -> Value {-    let (wins, source) = match find_windows(false, true) {-        Ok(x) => x,-        Err(e) => return e,-    };-    let mut editors = Vec::new();-    let mut by_kind: BTreeMap<String, Vec<Value>> = BTreeMap::new();-    for w in &wins {-        let kind = w.kind();-        if !kind.is_editor() && kind != WindowKind::ProjectManager {-            continue;-        }-        let row = json!({"kind": kind.as_str(), "exeName": w.exe, "pid": w.pid, "frameClass": w.class_name, "frameTitle": w.title, "hwnd": w.hwnd, "isShown": !w.minimized});-        by_kind.entry(kind.as_str().to_string()).or_default().push(row.clone());-        editors.push(row);-    }-    let running = !wins.is_empty();-    let hint = if editors.is_empty() && !running {-        "0 editors and no KiCad window: KiCad is almost certainly NOT RUNNING. This verb only INVENTORIES editors inside a running KiCad (despite its name); it never launches anything. To cold-start KiCad use kicad_launch; kicad_open_board / kicad_open_symbol_editor also cold-launch when opening a specific target."-    } else {-        "Inventory from window enumeration (the native build has no in-process plugin): each KiCad top-level frame is classified by its title. kind is one of project_manager, schematic_editor, pcb_editor, symbol_editor, footprint_editor, 3d_viewer."-    };-    json!({-        "success": true, "editors": editors, "byKind": by_kind, "kicadRunning": running, "windowSource": source,-        "summary": {"totalEditors": editors.len(), "probedPlugins": 0, "failedPlugins": 0, "byKindCounts": by_kind.iter().map(|(k, v)| (k.clone(), json!(v.len()))).collect::<Map<String, Value>>()},-        "_hint": hint,-    })-}--// ── kicad_close* ──────────────────────────────────────────────────────────────--fn close_kind(kind: WindowKind, label: &str) -> Value {-    invalidate();-    let wins = match find_windows(false, true) {-        Ok((w, _)) => w.into_iter().filter(|w| w.kind() == kind).collect::<Vec<_>>(),-        Err(e) => return e,-    };-    if wins.is_empty() {-        return json!({"success": true, "output": format!("{label} is not open.")});-    }-    let mut closed = Vec::new();-    let mut failed = Vec::new();-    let mut hows = Vec::new();-    for w in &wins {-        let (gone, how) = close_and_wait(w.hwnd, &format!("Close the KiCad {label} the calling AI asked to close"), Duration::from_secs(5));-        hows.push(how);-        if gone { closed.push(w.title.clone()) } else { failed.push(w.title.clone()) }-    }-    if !failed.is_empty() {-        let prompt = find_save_prompt();-        let mut v = fail("close_failed", format!("Failed to close: {}", failed.join(", ")), "Call close_kicad with {\"force\": true} to force-kill the window, then retry.");-        if let Some(p) = prompt {-            v["errorCode"] = json!("unsaved_changes");-            v["unsavedChanges"] = json!([{"window": failed[0], "dialog": p.title, "body": p.body}]);-            v["_hint"] = json!("KiCad is asking whether to save changes. Ask the user, then save in KiCad or call kicad_close {\"discardChanges\": true} to press Discard on their behalf.");-        }-        return v;-    }-    json!({-        "success": true, "closeVia": hows,-        "output": if closed.len() == 1 { format!("{label} closed: {}", closed[0]) } else { format!("Closed {} {label} window(s).", closed.len()) },-    })-}--/// kicad_close {force:true}: the ONLY path that kills a process.-fn force_kill_kicad() -> Result<Vec<String>, Value> {-    let procs = plat("process enumeration (processes)", native().processes(wm::KILL_PROCESS_NAMES))?;-    let mut killed = Vec::new();-    for name in wm::KILL_PROCESS_NAMES {-        let mut any = false;-        for p in procs.iter().filter(|p| p.exe.eq_ignore_ascii_case(name) || p.exe.eq_ignore_ascii_case(name.trim_end_matches(".exe"))) {-            if native().kill_process(p.pid).is_ok() {-                any = true;-            }-        }-        if any {-            killed.push(name.to_string());-        }-    }-    invalidate();-    Ok(killed)-}--#[derive(Default)]-struct CloseState {-    discard: bool,-    closed: Vec<String>,-    failed: Vec<String>,-    unsaved: Vec<Value>,-    discard_log: Vec<Value>,-}--/// One window: WM_CLOSE and wait; on a save prompt press Discard (discardChanges) or-/// report unsavedChanges. Some(true) closed, Some(false) failed, None already recorded.-fn close_one(st: &mut CloseState, w: &Win) -> Option<bool> {-    let (gone, _) = close_and_wait(w.hwnd, "Close KiCad as the calling AI asked", Duration::from_secs(8));-    if gone {-        return Some(true);-    }-    let Some(prompt) = find_save_prompt() else { return Some(false) };-    if st.discard {-        let mut attempts = Vec::new();-        let mut pressed = match native().click_button(prompt.hwnd, wm::DISCARD_LABELS) {-            Ok(m) => { attempts.push(json!({"win32Button": true, "method": m})); true }-            Err(e) => { attempts.push(json!({"win32Button": false, "error": e.chars().take(120).collect::<String>()})); false }-        };-        if !pressed {-            for label in wm::DISCARD_LABELS_UIA {-                match native().uia_invoke(prompt.hwnd, label) {-                    Ok(m) => { attempts.push(json!({"label": label, "uia": m})); pressed = true; break; }-                    Err(e) => attempts.push(json!({"label": label, "uia": e.chars().take(120).collect::<String>()})),-                }-            }-        }-        st.discard_log.push(json!({"window": w.title, "dialog": prompt.title, "attempts": attempts}));-        if pressed {-            let deadline = Instant::now() + Duration::from_secs(8);-            while Instant::now() < deadline {-                sleep_ms(300);-                invalidate();-                if !window_alive(w.hwnd) {-                    st.closed.push(format!("{} (changes discarded)", w.title));-                    return None;-                }-            }-        }-    }-    st.unsaved.push(json!({"window": w.title, "dialog": prompt.title, "body": prompt.body}));-    Some(false)-}--fn close_kicad(_state: &mut State, args: &Value) -> Value {-    let force = args.get("force").and_then(Value::as_bool).unwrap_or(false);-    let discard = args.get("discardChanges").and_then(Value::as_bool).unwrap_or(false);-    // Wiki #48: a close is the last moment these windows exist, so learn where they are-    // before the geometry is gone. A kill in particular denies KiCad its own save.-    remember_placement();-    if force {-        return match force_kill_kicad() {-            Ok(k) if !k.is_empty() => json!({"success": true, "output": format!("Force-killed: {}", k.join(", ")), "data": {"forceKilled": k}}),-            Ok(_) => json!({"success": true, "output": "KiCad is not running (nothing to kill)."}),-            Err(e) => e,-        };-    }-    invalidate();-    let wins = match find_windows(false, true) {-        Ok((w, _)) => w,-        Err(e) => return e,-    };-    let mut editors = Vec::new();-    let mut manager: Option<Win> = None;-    for w in wins.into_iter().filter(|w| w.owner == 0) {-        if wm::is_manager_title(&w.title) {-            manager = Some(w);-        } else {-            editors.push(w);-        }-    }-    if editors.is_empty() && manager.is_none() {-        return json!({"success": true, "output": "KiCad is not running."});-    }-    // Issue #84: a dirty board answers WM_CLOSE with "Save changes?". Read it; with-    // discardChanges press Discard and stay graceful; otherwise leave the window open and-    // report unsavedChanges. Nothing is ever killed here.-    let mut st = CloseState::default();-    st.discard = discard;-    for w in &editors {-        match close_one(&mut st, w) {-            Some(true) => st.closed.push(w.title.clone()),-            Some(false) => st.failed.push(w.title.clone()),-            None => {}-        }-    }-    if let (Some(m), true) = (&manager, st.unsaved.is_empty()) {-        match close_one(&mut st, m) {-            Some(true) => st.closed.push(m.title.clone()),-            Some(false) => st.failed.push(m.title.clone()),-            None => {}-        }-    }-    let CloseState { closed, failed, unsaved, discard_log, .. } = st;-    if !unsaved.is_empty() {-        return json!({-            "success": false, "errorCode": "unsaved_changes", "unsavedChanges": unsaved,-            "error": format!("KiCad is asking whether to save changes; nothing was killed: {}", unsaved.iter().map(|u| format!("{}: {}", u["window"].as_str().unwrap_or(""), u["body"].as_str().unwrap_or("").chars().take(80).collect::<String>())).collect::<Vec<_>>().join("; ")),-            "output": if closed.is_empty() { String::new() } else { format!("Closed: {}", closed.join(", ")) },-            "_hint": "The user's unsaved work is still open. Ask them, then either save in KiCad (Ctrl+S via desktop_press_key) and call kicad_close again, or call kicad_close {\"discardChanges\": true} to press Discard on their behalf. force:true kills the process, which discards silently.",-            "discard": discard_log,-        });-    }-    if !failed.is_empty() {-        // No save prompt and no close: the native build never kills here (force:true is-        // the only kill path); say what did not close and how to force it.-        // Wiki #109 ask 3: a dirty Symbol Editor refused to go and the reply named no cause.-        // Report every dialog that is up (the unrecognised save prompt is among them) and-        // the editor's own close verb.-        let seen: Vec<Value> = scan_dialogs().iter().map(|d| json!({"hwnd": d.hwnd, "title": d.title, "body": d.body.chars().take(240).collect::<String>()})).collect();-        let editor_verbs: Vec<&str> = [("symbol editor", "kicad_close_symbol_editor"), ("footprint editor", "kicad_close_footprint_editor"), ("3d viewer", "kicad_close_3d_viewer")]-            .iter().filter(|(word, _)| failed.iter().any(|t| t.to_lowercase().contains(word))).map(|(_, verb)| *verb).collect();-        return json!({-            "success": false, "errorCode": "close_failed", "dialogsSeen": seen, "editorCloseVerbs": editor_verbs,-            "error": format!("Failed to close: {}", failed.join(", ")),-            "output": if closed.is_empty() { String::new() } else { format!("Closed: {}", closed.join(", ")) },-            "_hint": "A title starting with * has unsaved work: its editor is waiting on a prompt. dialogsSeen lists every dialog that is up right now; answer it (kicad_dismiss_dialogs, or discardChanges:true to press Discard) or close that editor with the verb in editorCloseVerbs, then call kicad_close again. force:true kills the process and discards unsaved work silently. kicad_screenshot_all shows the windows.",-        });-    }-    let mut out = json!({"success": true, "output": format!("KiCad closed ({} window{}).", closed.len(), if closed.len() != 1 { "s" } else { "" }), "data": {}});-    if !discard_log.is_empty() {-        out["discard"] = json!(discard_log);-    }-    out-}--// ── kicad_dismiss_dialogs / kicad_window_info ─────────────────────────────────--fn dismiss_dialogs(state: &mut State, args: &Value) -> Value {-    let info = state.kicad_info();-    if args.get("forceSoftwareCanvas").and_then(Value::as_bool).unwrap_or(false) {-        let changed = force_software_canvas(&info.version);-        return json!({-            "success": true, "forcedSoftwareCanvas": changed,-            "output": if changed { "forced Cairo software canvas (canvas_type=2)" } else { "software canvas already set / no config" },-            "_hint": "Close and reopen KiCad editors; they will render via Cairo (no OpenGL) so no GPU is needed.",-        });-    }-    if let Err(e) = find_windows(true, true) {-        return e;-    }-    if args.get("debug").and_then(Value::as_bool).unwrap_or(false) {-        let wins = scan_dialogs();-        let pids: Vec<u32> = native().processes(wm::KICAD_EXES).map(|p| p.iter().map(|p| p.pid).collect()).unwrap_or_default();-        return json!({-            "success": true, "debug": true, "kicadPids": pids,-            "windows": wins.iter().map(|w| json!({"title": w.title, "className": w.class_name, "owner": w.owner, "isDialog": true, "body": w.body.chars().take(80).collect::<String>(), "viaGlobalSweep": w.via_global_sweep})).collect::<Vec<_>>(),-            "kicadInfoHasBinDir": info.primary().map(|i| !i.bin_dir.is_empty()).unwrap_or(false),-        });-    }-    let all = args.get("all").and_then(Value::as_bool).unwrap_or(false);-    let screenshot = args.get("screenshot").and_then(Value::as_bool).unwrap_or(true);-    let res = sweep_dialogs(&info, all, screenshot);-    let n = res["dismissed"].as_array().map(|a| a.len()).unwrap_or(0);-    let remaining = res["remaining"].as_array().map(|a| a.len()).unwrap_or(0);-    let forced = res["forcedSoftwareCanvas"].as_bool().unwrap_or(false);-    json!({-        "success": true,-        "output": format!("dismissed {n} dialog(s), {remaining} remaining{}", if forced { "; forced Cairo software canvas (no GPU); close+reopen editors" } else { "" }),-        "dismissed": res["dismissed"], "remaining": res["remaining"], "forcedSoftwareCanvas": forced,-        "_hint": format!("Expired {n} dialog(s). Now retry your open/screenshot verb. Benign-only by default; pass {{\"all\":true}} to clear every dialog. The OpenGL notice on a GPU-less host is auto-expired by window_info too."),-    })-}--fn window_info(state: &mut State, args: &Value) -> Value {-    let info = state.kicad_info();-    if let Err(e) = find_windows(true, true) {-        return e;-    }-    let mut auto_dismissed = json!([]);-    let mut forced_sw = false;-    if args.get("autoDismiss").and_then(Value::as_bool).unwrap_or(true) {-        let s = sweep_dialogs(&info, false, false);-        auto_dismissed = s["dismissed"].clone();-        forced_sw = s["forcedSoftwareCanvas"].as_bool().unwrap_or(false);-    }-    invalidate();-    let (wins, source) = match find_windows(false, true) {-        Ok(x) => x,-        Err(e) => return e,-    };-    let mut editors = Vec::new();-    let mut dialogs = Vec::new();-    let mut manager = Value::Null;-    for w in &wins {-        let base = json!({"hwnd": w.hwnd, "title": w.title, "className": w.class_name, "rect": w.rect_json(), "z": w.z, "minimized": w.minimized, "maximized": w.maximized, "hung": w.hung, "kind": w.kind().as_str()});-        if w.owner != 0 || w.class_name == wm::DIALOG_CLASS {-            let mut d = base;-            d["ownerHwnd"] = json!(w.owner);-            d["body"] = json!(dialog_body_text(w.hwnd));-            dialogs.push(d);-        } else if wm::is_manager_title(&w.title) {-            manager = base;-        } else {-            editors.push(base);-        }-    }-    let mut data = json!({-        "projectManager": manager, "editors": editors, "modalDialogs": dialogs, "hasModalDialogs": !dialogs.is_empty(),-        "autoDismissed": auto_dismissed, "windowSource": source,-    });-    if forced_sw {-        data["forcedSoftwareCanvas"] = json!(true);-    }-    let n_auto = data["autoDismissed"].as_array().map(|a| a.len()).unwrap_or(0);-    if n_auto > 0 {-        data["_autoDismissHint"] = json!(format!("Auto-expired {n_auto} benign blocking dialog(s) (e.g. the OpenGL/software-rendering notice) so editors are not stalled; no action needed.{}", if forced_sw { " This host lacks OpenGL, so KiCad was switched to the Cairo software canvas (kicad_common.json canvas_type=2): CLOSE and REOPEN the editor for it to render without a GPU." } else { "" }));-    }-    if !dialogs.is_empty() {-        data["_dialogHint"] = json!("hasModalDialogs is true: KiCad has a BLOCKING modal dialog. Read each modalDialogs[].body for the message text; if body is empty, screenshot modalDialogs[].hwnd via desktop_screenshot_window. Dismiss with kicad_send_key {hwnd, key:'return'} (OK/default) or 'escape' (cancel).");-    }-    json!({"success": true, "output": format!("{} editor(s), {} dialog(s)", editors.len(), dialogs.len()), "data": data})-}--// ── Screenshots (ab) and the canvas probe (local) ─────────────────────────────--/// Uniformity verdict for the editor canvas: one local capture, sampled, discarded.-fn canvas_probe(hwnd: u64) -> Value {-    let Ok(cap) = native().capture_window(hwnd) else { return Value::Null };-    let rect = native().gl_canvas_rect(hwnd).ok().flatten().map(|(x, y, w, h)| (x, y, x + w, y + h));-    wm::canvas_uniformity(&cap.rgb, cap.width, cap.height, rect)-}--/// Capture a window by hwnd: ab takes the picture (owned popups, coordMap, auto-pull);-/// the canvas verdict is always ours.-pub(crate) fn screenshot_hwnd(hwnd: u64, label: &str, resize_max: Option<u32>) -> Value {-    let mut v = match ab::desktop_screenshot_window(hwnd, &format!("Capture the KiCad window {label} as evidence for the calling AI"), resize_max) {-        Ok(shot) => shot.to_json(),-        Err(e) => json!({"success": false, "error": e.message(), "abUnavailable": e.is_unavailable(), "_hint": "The native build takes every screenshot through ab's desktop_screenshot_window; with ab unreachable no image is written. Only the canvas probe (canvas.rendered) answers locally."}),-    };-    v["canvas"] = canvas_probe(hwnd);-    v-}--fn screenshot_all(state: &mut State) -> Value {-    let info = window_info(state, &json!({}));-    if info["success"] != json!(true) {-        return info;-    }-    let data = &info["data"];-    let mut windows: Vec<(u64, String, &str)> = Vec::new();-    for ed in data["editors"].as_array().into_iter().flatten() {-        windows.push((ed["hwnd"].as_u64().unwrap_or(0), ed["title"].as_str().unwrap_or("").into(), "editor"));-    }-    if let Some(h) = data["projectManager"]["hwnd"].as_u64() {-        windows.push((h, data["projectManager"]["title"].as_str().unwrap_or("").into(), "main"));-    }-    for d in data["modalDialogs"].as_array().into_iter().flatten() {-        windows.push((d["hwnd"].as_u64().unwrap_or(0), d["title"].as_str().unwrap_or("").into(), "dialog"));-    }-    if windows.is_empty() {-        return fail("no_windows", "No KiCad windows found.", "kicad_launch or an open_* verb first; kicad_window_info lists what is up.");-    }-    let mut shots = Vec::new();-    let mut ab_unavailable = false;-    for (hwnd, title, kind) in &windows {-        let label = wm::safe_label(title, kind);-        let r = screenshot_hwnd(*hwnd, &label, None);-        let mut entry = json!({"type": kind, "title": title, "hwnd": hwnd, "canvas": r["canvas"]});-        if r["success"] == json!(true) {-            for k in ["savedTo", "fullPath", "safePath", "localFullPath", "sizeKB", "capturedBy", "shotId", "coordMap", "ownedPopupCount", "popups", "screenshots"] {-                if let Some(v) = r.get(k) {-                    entry[k] = v.clone();-                }-            }-        } else {-            entry["error"] = json!(r["error"].as_str().unwrap_or("screenshot failed"));-            if r["abUnavailable"] == json!(true) {-                ab_unavailable = true;-            }-        }-        shots.push(entry);-    }-    let captured = shots.iter().filter(|s| s.get("fullPath").is_some()).count();-    let mut out = json!({-        "success": captured > 0 || !ab_unavailable,-        "output": format!("Captured {captured} of {} KiCad window(s)", shots.len()),-        "data": {"screenshots": shots},-        "_hint": "Each entry is a background PrintWindow capture by ab with its owned popups (popups[]) and a coordMap: click what you see with desktop_click {space:\"image\", shotId, x, y}. canvas.rendered false means the GL canvas has not painted yet (or there is no usable GPU).",-    });-    if ab_unavailable {-        out["abUnavailable"] = json!(true);-        out["errorCode"] = json!("ab_unavailable");-        out["_hint"] = json!("ab's direct API is unreachable, so no image was written (the native build has no local PNG path). ADOM_DIRECT_API_URL missing from the spawn env is the usual cause; ab's own desktop_screenshot_window from the CLI still works.");-    }-    out-}--// ── kicad_state ───────────────────────────────────────────────────────────────--pub(crate) fn b64_encode(bytes: &[u8]) -> String {-    const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";-    let mut out = String::with_capacity((bytes.len() + 2) / 3 * 4);-    for chunk in bytes.chunks(3) {-        let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];-        let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;-        out.push(T[(n >> 18) as usize & 63] as char);-        out.push(T[(n >> 12) as usize & 63] as char);-        out.push(if chunk.len() > 1 { T[(n >> 6) as usize & 63] as char } else { '=' });-        out.push(if chunk.len() > 2 { T[n as usize & 63] as char } else { '=' });-    }-    out-}--fn kicad_state(state: &mut State, args: &Value) -> Value {-    let info = state.kicad_info();-    let (wins, source) = match find_windows(false, true) {-        Ok(x) => x,-        Err(e) => return e,-    };-    let include_images = args.get("includeImages").and_then(Value::as_bool).unwrap_or(true);-    let max_width = args.get("maxWidth").and_then(Value::as_u64).unwrap_or(1100) as u32;-    let mut shots = Vec::new();-    let mut ab_unavailable = false;-    if include_images {-        for w in &wins {-            let mut entry = json!({"title": w.title, "hwnd": w.hwnd, "className": w.class_name});-            let r = screenshot_hwnd(w.hwnd, "state", Some(max_width));-            entry["canvas"] = r["canvas"].clone();-            if r["success"] == json!(true) {-                let path = r["safePath"].as_str().filter(|p| !p.is_empty()).or_else(|| r["fullPath"].as_str()).unwrap_or("");-                match std::fs::read(path) {-                    Ok(bytes) if path.to_lowercase().ends_with(".png") => {-                        entry["image"] = json!(b64_encode(&bytes));-                        entry["path"] = json!(path);-                        if let Some(c) = r.get("coordMap") {-                            if let (Some(w), Some(h)) = (c["image"]["w"].as_u64(), c["image"]["h"].as_u64()) {-                                entry["width"] = json!(w);-                                entry["height"] = json!(h);-                            }-                        }-                        if let Some(s) = r.get("shotId") { entry["shotId"] = s.clone(); }-                        if let Some(p) = r.get("popups") { entry["popups"] = p.clone(); }-                    }-                    Ok(_) => { entry["error"] = json!("capture is not a png"); entry["path"] = json!(path); }-                    Err(e) => { entry["error"] = json!(format!("could not read {path}: {e}")); }-                }-            } else {-                entry["error"] = json!(r["error"].as_str().unwrap_or("capture failed"));-                if r["abUnavailable"] == json!(true) {-                    ab_unavailable = true;-                }-            }-            shots.push(entry);-        }-    }-    let editors = open_editors();-    let modal: Vec<Value> = scan_dialogs().into_iter().map(|d| json!({"hwnd": d.hwnd, "title": d.title, "body": d.body})).collect();-    let loop_events = native().etiquette_events();-    let mut events: Vec<Value> = FOCUS_EVENTS.lock().map(|g| g.iter().cloned().collect()).unwrap_or_default();-    events.extend(loop_events);-    let events: Vec<Value> = events.into_iter().rev().take(40).rev().collect();-    let focus_debug = match native().etiquette_debug() {-        Value::Null => json!({"guardian": "no etiquette loop on this OS; one-shot check per verb only", "oneShotCheck": true}),-        v => v,-    };-    let mut out = json!({-        "success": true, "running": !wins.is_empty(), "focusEvents": events,-        // PRESENCE GATE (John, 2026-08-17): automation that spawns windows checks this-        // first and defers while the user is active (< 600 = touched in the last 10 min).-        "userIdleSeconds": user_idle_seconds(),-        "focusDebug": focus_debug,-        "activeVersion": info.version,-        "windows": wins.iter().map(|w| json!({"hwnd": w.hwnd, "title": w.title, "className": w.class_name, "rect": w.rect_json(), "z": w.z, "kind": w.kind().as_str()})).collect::<Vec<_>>(),-        "windowSource": source,-        "shots": shots,-        "editors": {"kicadRunning": editors["kicadRunning"], "list": editors["editors"], "summary": editors["summary"]},-        "modalDialogs": modal,-        "_hint": "focusEvents is the focus log: a self-raise (pushed-to-background) entry is a KiCad window that took the foreground and was pushed back once; user-foregrounded means the caller asked with a reason. The full state of the union: every KiCad-owned window is in shots[] as inline base64 PNG (render them all; one window is NOT the app). modalDialogs non-empty means something is blocking; editors.list is the window inventory by kind. Poll before/after an action to confirm it landed. includeImages:false for a cheap logical poll.",-    });-    if ab_unavailable {-        out["abUnavailable"] = json!(true);-    }-    out-}--// ── kicad_send_key / kicad_click / kicad_fix_keyboard ─────────────────────────--fn foreground_kicad_hwnd() -> Option<u64> {-    if let Ok(fg) = native().foreground() {-        if fg != 0 && is_kicad_hwnd(fg) {-            return Some(fg);-        }-    }-    kicad_windows().first().map(|w| w.hwnd)-}--fn send_key(args: &Value) -> Value {-    // `menu`: a menu-item label (substring, case-insensitive) fired as WM_COMMAND on the-    // target window's menu bar, fully background. The way the PCM dialog is opened.-    if let Some(needle) = arg_str(args, "menu").map(str::trim).filter(|s| !s.is_empty()) {-        let target = match arg_any_u64(args, &["hwnd"]) {-            Some(h) => h,-            None => match foreground_kicad_hwnd() {-                Some(h) => h,-                None => return fail("no_window", "No KiCad window found to send the menu command to.", "kicad_window_info lists the windows; pass hwnd."),-            },-        };-        let title = title_of(target);-        return match menu_invoke_by_text(target, needle) {-            Ok(Some((id, label))) => json!({-                "success": true, "mechanism": "menu",-                "output": format!("Menu item '{label}' fired on '{title}' (background)"),-                "data": {"method": "menu-command", "cmdId": id, "menuItem": label, "hwnd": target, "title": title, "foreground": false},-                "_hint": "Resolved from the window's menu bar by label and fired with WM_COMMAND; no focus was touched. A dialog it opens is a child of this window: kicad_screenshot_all captures it.",-            }),-            Ok(None) => fail("menu_item_not_found", &format!("No menu item on '{title}' contains '{needle}'."), "kicad_window_info lists windows; the label match is a case-insensitive substring of the item text without '&'."),-            Err(e) if is_not_impl(&e) => unsupported("native menu walk (menu_tree)"),-            Err(e) => fail("menu_invoke_failed", &e, "Retry once the window is idle; pass hwnd explicitly."),-        };-    }-    let Some(key) = arg_str(args, "key").map(str::trim) else {-        return fail("missing_arg", "Missing required arg: key", "Pass key: a named key (enter/escape/tab/space/f1-f12), a single character, or a chord like 'ctrl+s'.");-    };-    let target = match arg_any_u64(args, &["hwnd"]) {-        Some(h) => h,-        None => match foreground_kicad_hwnd() {-            Some(h) => h,-            None => return fail("no_window", "No KiCad window found to send key to.", "kicad_window_info lists the windows; pass hwnd."),-        },-    };-    let title = title_of(target);-    // NEVER steal the user's screen: show without activating and post to THIS hwnd's queue.-    let _ = native().show_no_activate(target);-    sleep_ms(50);-    let allow_fg = args.get("allowForeground").and_then(Value::as_bool).unwrap_or(false);-    if let Some((_mods, _base)) = wm::parse_modifier_combo(key) {-        // A CHORD usually IS a menu accelerator (v0.9.106): resolve it from the window's-        // menu bar and fire WM_COMMAND, fully background.-        match menu_invoke_by_accel(target, key) {-            Ok(Some((id, label))) => {-                return json!({-                    "success": true, "mechanism": "menu",-                    "output": format!("Chord '{key}' delivered as menu command '{label}' (background)"),-                    "data": {"method": "menu-command", "cmdId": id, "menuItem": label, "hwnd": target, "title": title, "foreground": false},-                    "_hint": "Resolved via the window's menu-bar accelerator table and fired with WM_COMMAND; no focus was touched. This is the preferred path for every chord that maps to a menu item.",-                });-            }-            Err(e) if is_not_impl(&e) => return unsupported("native menu walk (menu_tree)"),-            _ => {}-        }-        if allow_fg {-            return unsupported("kicad_send_key allowForeground (real keystrokes via SendInput)");-        }-        return json!({-            "success": false, "errorCode": "foreground_required",-            "error": format!("send the key chord '{key}' to KiCad needs the foreground, which would take over the user's screen: no menu item carries this accelerator, so only real (focused) keystrokes can deliver it"),-            "_hint": "Use ab's desktop_press_key (it owns focus-first chord delivery and asks the user), or find the menu item and call it by name. The native bridge never sends global keystrokes.",-        });-    }-    if allow_fg {-        return unsupported("kicad_send_key allowForeground (real keystrokes via SendInput)");-    }-    let (vk, ch) = match wm::vk_for_key(key) {-        Some(vk) => (vk, None),-        None => {-            let mut chars = key.chars();-            match (chars.next(), chars.next()) {-                (Some(c), None) => (wm::vk_for_char(c).unwrap_or(0), Some(c)),-                _ => return fail("unknown_key", format!("Unknown key: '{key}'. Use a named key (enter/escape/tab/space/f1-f12), a single character, or a modifier combo like 'alt+3', 'ctrl+s', 'ctrl+shift+s' (modifiers: ctrl/control, alt/menu, shift, win/super)."), "Chords resolve through the menu bar; anything else needs desktop_press_key."),-            }-        }-    };-    match native().post_key(target, vk, ch) {-        Ok(()) => json!({-            "success": true, "mechanism": "post",-            "output": format!("Posted '{key}' to '{title}' (background)"),-            "data": {"hwnd": target, "title": title, "method": "postmessage", "foreground": false, "vk": vk},-            "_hint": "Delivered without touching the user's focus. If KiCad ignored it, the control may need real input: prefer a menu command by name; only as a last resort ask the user and use desktop_press_key.",-        }),-        Err(e) if is_not_impl(&e) => unsupported("posted keys (post_key)"),-        Err(e) => fail("post_failed", format!("Could not post '{key}' to the window: {e}"), "kicad_window_info shows whether the hwnd is still valid."),-    }-}--fn click(args: &Value) -> Value {-    let Some(hwnd) = arg_any_u64(args, &["hwnd"]) else {-        return fail("missing_arg", "Missing required arg: hwnd", "kicad_window_info lists the windows; pass hwnd.");-    };-    let (Some(x), Some(y)) = (args.get("x").and_then(Value::as_f64), args.get("y").and_then(Value::as_f64)) else {-        return fail("missing_arg", "Missing required args: x, y", "Pass x and y as fractions of the window (relative:true, default) or pixels (relative:false).");-    };-    let relative = args.get("relative").and_then(Value::as_bool).unwrap_or(true);-    if relative && !(0.0..=1.0).contains(&x) || relative && !(0.0..=1.0).contains(&y) {-        return json!({-            "success": false, "errorCode": "bad_arg",-            "error": format!("relative=True expects x/y as fractions 0.0-1.0 of the window, but got x={x}, y={y}. Pass fractions, or set relative=false to use pixels."),-            "_hint": "e.g. center = {\"x\":0.5,\"y\":0.5}; toolbar button ~ {\"x\":0.35,\"y\":0.09}. For exact pixel coords add \"relative\": false.",-        });-    }-    if args.get("allowForeground").and_then(Value::as_bool).unwrap_or(false) {-        return unsupported("kicad_click allowForeground (a real cursor click via SendInput)");-    }-    let info = match native().window_info(hwnd) {-        Ok(Some(i)) => i,-        Ok(None) => return fail("bad_hwnd", format!("HWND {hwnd} is not a valid window."), "kicad_window_info lists the current handles."),-        Err(e) => return plat::<()>("window lookup (window_info)", Err(e)).err().unwrap(),-    };-    let (w, h) = (info.rect.2, info.rect.3);-    let _ = native().show_no_activate(hwnd);-    sleep_ms(50);-    let (cx, cy) = if relative { ((x * w as f64) as i32, (y * h as f64) as i32) } else { (x as i32, y as i32) };-    match native().post_click(hwnd, cx, cy, false) {-        Ok(()) => json!({-            "success": true, "mechanism": "post",-            "output": format!("Posted click at ({cx},{cy}) in '{}' (background)", info.title),-            "data": {"hwnd": hwnd, "title": info.title, "client": [cx, cy], "screen": [info.rect.0 + cx, info.rect.1 + cy], "method": "postmessage", "foreground": false, "relative": relative},-            "_hint": "Clicked without moving the user's mouse or taking focus. If the canvas ignored it, that control may need real input: ab's desktop_click {space:\"image\", shotId, x, y} on a kicad_screenshot_all coordMap asks the user first.",-        }),-        Err(e) if is_not_impl(&e) => unsupported("posted clicks (post_click)"),-        Err(e) => fail("post_failed", format!("Could not post the click to the window: {e}"), "kicad_window_info shows whether the hwnd is still valid."),-    }-}--fn fix_keyboard() -> Value {-    json!({-        "success": true, "stuckKeys": [], "released": false,-        "output": "Nothing to fix: the native bridge never sends global keystrokes (no SendInput/keybd_event path), so it can never leave a modifier key held. Keys and clicks are posted to one window's queue, which touches no global keyboard state.",-        "_hint": "If keys ARE stuck on this desktop, another tool held them: ab's desktop_press_key path or a release of each modifier through it clears the OS state.",-    })-}--// ── kicad_place_footprint ─────────────────────────────────────────────────────--const BLANK_BOARD: &str = include_str!("../../../../templates/blank-board.kicad_pcb");--fn pseudo_uuid() -> String {-    // Not cryptographic: a KiCad item id only has to be unique within the board.-    let mut x: u64 = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos() as u64).unwrap_or(0x9E3779B97F4A7C15) ^ ((std::process::id() as u64) << 32);-    let mut bytes = [0u8; 16];-    for b in bytes.iter_mut() {-        x ^= x << 13;-        x ^= x >> 7;-        x ^= x << 17;-        *b = (x & 0xff) as u8;-    }-    bytes[6] = (bytes[6] & 0x0f) | 0x40;-    bytes[8] = (bytes[8] & 0x3f) | 0x80;-    let h: String = bytes.iter().map(|b| format!("{b:02x}")).collect();-    format!("{}-{}-{}-{}-{}", &h[0..8], &h[8..12], &h[12..16], &h[16..20], &h[20..32])-}--/// Quote an unquoted top-level `(layer X)` token: `(layer F.Cu)` -> `(layer "F.Cu")`.-fn quote_first_layer(text: &str) -> String {-    let Some(i) = text.find("(layer ") else { return text.to_string() };-    let rest = &text[i + 7..];-    let trimmed = rest.trim_start();-    if trimmed.starts_with('"') {-        return text.to_string();-    }-    let end = trimmed.find(')').unwrap_or(0);-    let name = trimmed[..end].trim();-    if name.is_empty() || !name.chars().next().map(|c| c.is_ascii_alphabetic()).unwrap_or(false) {-        return text.to_string();-    }-    format!("{}(layer \"{}\"){}", &text[..i], name, &trimmed[end + 1..])-}--/// Inject `(uuid ...)` and `(at x y)` right after the footprint's first quoted `(layer "...")`.-fn place_in_board(fp_text: &str, uid: &str, x: f64, y: f64) -> Option<String> {-    let i = fp_text.find("(layer \"")?;-    let close = fp_text[i..].find(')')? + i + 1;-    Some(format!("{}\n\t\t(uuid \"{uid}\")\n\t\t(at {x} {y}){}", &fp_text[..close], &fp_text[close..]))-}--fn place_footprint(state: &mut State, args: &Value) -> Value {-    let info = state.kicad_info();-    if !info.installed {-        return fail("not_installed", "KiCad not installed", "Install KiCad from https://www.kicad.org/download/ then retry.");-    }-    let file_name = arg_str(args, "fileName").unwrap_or("");-    let Some(content) = arg_str(args, "fileContent") else {-        return fail("missing_arg", "No fileContent (base64 .kicad_mod) specified", "Pass fileContent: the base64 of the .kicad_mod text.");-    };-    let mut fp_text = match kicad_core::install::b64_decode(content) {-        Ok(b) => String::from_utf8_lossy(&b).trim().to_string(),-        Err(e) => return fail("bad_content", format!("could not decode fileContent: {e}"), "fileContent must be standard base64."),-    };-    // Legacy `(module ...)` footprints (KiCad <= 5 / EAGLE imports): rename the wrapper-    // and quote the top-level layer token the splice relies on.-    if fp_text.starts_with("(module") {-        fp_text = format!("(footprint{}", &fp_text["(module".len()..]);-        fp_text = quote_first_layer(&fp_text);-    }-    if !fp_text.starts_with("(footprint") {-        return fail("bad_content", "fileContent is not a .kicad_mod (must start with '(footprint' or '(module')", "Send the raw .kicad_mod text, base64-encoded.");-    }-    let fp_name = arg_str(args, "footprintName").map(str::to_string)-        .or_else(|| kicad_core::install::footprint_name_in(&fp_text))-        .or_else(|| Path::new(file_name).file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.is_empty()))-        .unwrap_or_else(|| "footprint".into());-    let x = args.get("x").and_then(Value::as_f64).unwrap_or(148.0);-    let y = args.get("y").and_then(Value::as_f64).unwrap_or(105.0);-    let uid = pseudo_uuid();-    let Some(fp_board) = place_in_board(&fp_text, &uid, x, y) else {-        return fail("bad_content", "footprint has no (layer \"...\") token to anchor the placement", "A .kicad_mod carries (layer \"F.Cu\") near the top; this one does not parse.");-    };-    let skel = BLANK_BOARD.trim_end();-    let idx = skel.rfind(')').unwrap_or(skel.len());-    let combined = format!("{}\n{}\n)\n", skel[..idx].trim_end(), fp_board);-    let user_dir = info.primary().and_then(|i| native().user_dir(&i.version)).or_else(|| std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME")).map(PathBuf::from)).unwrap_or_else(|| PathBuf::from("."));-    let _ = std::fs::create_dir_all(&user_dir);-    let out_path = user_dir.join(format!("adom-place-{fp_name}.kicad_pcb"));-    if let Err(e) = std::fs::write(&out_path, combined) {-        return fail("write_failed", format!("could not write board: {e}"), "The preview board goes to Documents/KiCad/<version>; check it is writable.");-    }-    let board_path = norm(&out_path);-    let open_it = args.get("open").and_then(Value::as_bool).unwrap_or(true);-    let mut opened = false;-    let mut extra = Map::new();-    if open_it {-        match kicad_exe(&info, "pcbnew") {-            Some(exe) => match spawn_background(&exe, &[board_path.clone()], "pcbnew", None) {-                Ok((pid, _)) => {-                    opened = true;-                    extra.insert("pid".into(), json!(pid));-                    // Verify the frame by title with one deadline; the sweep runs after.-                    let stem = format!("adom-place-{fp_name}").to_lowercase();-                    let deadline = Instant::now() + Duration::from_secs(20);-                    while Instant::now() < deadline {-                        invalidate();-                        if let Some(w) = windows_of_kind(WindowKind::PcbEditor).into_iter().find(|w| w.title.to_lowercase().contains(&stem) || w.pid == pid) {-                            extra.insert("hwnd".into(), json!(w.hwnd));-                            extra.insert("title".into(), json!(w.title));-                            break;-                        }-                        sleep_ms(500);-                    }-                }-                Err(e) => {-                    if e["errorCode"] == json!("not_supported_on_platform") {-                        return e;-                    }-                    return json!({"success": true, "boardPath": board_path, "footprintName": fp_name, "placedAt": [x, y], "opened": false, "output": format!("Board written but pcbnew launch failed: {}", e["error"].as_str().unwrap_or("")), "_hint": format!("Open it manually: {board_path}")});-                }-            },-            None => {}-        }-    }-    let mut v = json!({-        "success": true, "boardPath": board_path, "footprintName": fp_name, "placedAt": [x, y], "opened": opened, "mechanism": if opened { "spawn" } else { "file" },-        "output": format!("Placed footprint '{fp_name}' at ({x}, {y}) mm on a preview board{}", if opened { " and opened it in pcbnew" } else { "" }),-    });-    merge(&mut v, Value::Object(extra));-    v-}--// ── kicad_enable_software_opengl ──────────────────────────────────────────────--const MESA_URL: &str = "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.60/mesa-llvmpipe-x64.zip";-const MESA_DLLS: &[&str] = &["opengl32.dll", "libgallium_wgl.dll", "dxil.dll"];-const MESA_EXES: &[&str] = &["kicad.exe", "pcbnew.exe", "eeschema.exe", "gerbview.exe", "pl_editor.exe", "bitmap2component.exe", "pcb_calculator.exe"];--fn enable_software_opengl(state: &mut State) -> Value {-    if native().capabilities().os != "windows" {-        return json!({"success": false, "error": "Windows-only", "errorCode": "unsupported_platform", "_hint": "Mesa llvmpipe DotLocal redirection only applies to KiCad's Windows build."});-    }-    let info = state.kicad_info();-    let mut bins: Vec<PathBuf> = Vec::new();-    for i in &info.installs {-        let mut b = PathBuf::from(if i.bin_dir.is_empty() { &i.base_dir } else { &i.bin_dir });-        if b.file_name().map(|n| n.to_string_lossy().to_lowercase() != "bin").unwrap_or(true) {-            b = b.join("bin");-        }-        if b.is_dir() && !bins.contains(&b) {-            bins.push(b);-        }-    }-    if bins.is_empty() {-        return json!({"success": false, "error": "No KiCad bin dir found; is KiCad installed?", "errorCode": "host_app_not_installed", "errorCodeLegacy": "kicad_not_installed", "hostApp": "KiCad", "_hint": "Run kicad_list_versions. Install KiCad first via kicad_upgrade."});-    }-    eprintln!("[kicad-bridge] enable_software_opengl: downloading Mesa llvmpipe...");-    let data = match pcm::http_fetch(MESA_URL, Duration::from_secs(180)) {-        Ok(d) => d,-        Err(e) => return json!({"success": false, "error": format!("Mesa download/unzip failed: {e}"), "errorCode": "download_failed"}),-    };-    let zip = match pcm::ZipFile::parse(&data) {-        Ok(z) => z,-        Err(e) => return json!({"success": false, "error": format!("Mesa download/unzip failed: {e}"), "errorCode": "download_failed"}),-    };-    let mut enabled = Vec::new();-    let mut errors = Vec::new();-    for bd in &bins {-        let mut ok = true;-        for dll in MESA_DLLS {-            let Some(entry) = zip.entries.iter().find(|e| e.name.rsplit('/').next() == Some(dll)) else {-                errors.push(format!("{}: {dll} missing from the Mesa zip", norm(bd)));-                ok = false;-                break;-            };-            let bytes = match zip.read(entry) {-                Ok(b) => b,-                Err(e) => { errors.push(format!("{}: {dll}: {e}", norm(bd))); ok = false; break; }-            };-            if let Err(e) = std::fs::write(bd.join(dll), bytes) {-                errors.push(if e.kind() == std::io::ErrorKind::PermissionDenied { format!("{}: permission denied (writing KiCad's bin needs elevation)", norm(bd)) } else { format!("{}: {e}", norm(bd)) });-                ok = false;-                break;-            }-        }-        if !ok {-            continue;-        }-        for exe in MESA_EXES {-            if bd.join(exe).exists() {-                let _ = std::fs::write(bd.join(format!("{exe}.local")), "");-            }-        }-        enabled.push(norm(bd));-        eprintln!("[kicad-bridge] enable_software_opengl: deployed to {}", norm(bd));-    }-    let ok = !enabled.is_empty();-    if !enabled.is_empty() {-        NO_OPENGL.store(false, std::sync::atomic::Ordering::Relaxed); // the next start renders through Mesa-    }-    json!({-        "success": ok, "enabledIn": enabled, "errors": errors, "renderer": "Mesa llvmpipe (CPU software OpenGL)",-        "_hint": if ok {-            "LAST-RESORT software-OpenGL fallback deployed: KiCad will render via Mesa llvmpipe on the CPU (SLOW). This is ONLY for a GPU-less host (Hyper-V/RDP/headless) where KiCad cannot render at all; do NOT enable it on a normal machine. CLOSE and REOPEN KiCad editors to load it. To undo, delete opengl32.dll/libgallium_wgl.dll/dxil.dll and the *.local files from KiCad's bin."-        } else {-            "Could not deploy; see errors[]. Writing KiCad's bin (Program Files) needs an elevated Bridge; a per-user KiCad install (kicad_upgrade scope=user) avoids that."-        },-    })-}--#[cfg(test)]-mod tests {-    use super::*;-    #[test]-    fn viewer_selection_is_bound_to_source_and_refuses_ambiguity(){-        let win=|hwnd,owner,pid|Win{hwnd,owner,pid,title:"3D Viewer".into(),class_name:"wxWindowNR".into(),rect:(0,0,100,100),z:None,exe:"pcbnew.exe".into(),minimized:false,maximized:false,hung:false};-        assert_eq!(viewer_for_frame(2,200,vec![win(10,1,200),win(20,2,200)],&[10,20],2,None).unwrap().hwnd,20);-        assert!(viewer_for_frame(2,200,vec![win(10,1,200)],&[10],2,None).is_none());-        assert!(viewer_for_frame(2,200,vec![win(10,0,200)],&[10],2,None).is_none());-        assert_eq!(viewer_for_frame(2,200,vec![win(10,0,200),win(20,0,200)],&[10],2,None).unwrap().hwnd,20);-        assert!(viewer_for_frame(2,200,vec![win(20,0,200),win(21,0,200)],&[],2,None).is_none());-        assert_eq!(viewer_for_frame(2,200,vec![win(10,0,200),win(20,0,200)],&[10,20],2,Some(20)).unwrap().hwnd,20);-    }---    #[test]-    fn footprint_splice_matches_python() {-        let fp = "(footprint \"X\"\n\t(version 20240108)\n\t(layer \"F.Cu\")\n\t(pad \"1\" smd rect)\n)";-        let placed = place_in_board(fp, "u-u-i-d", 148.0, 105.0).unwrap();-        assert!(placed.contains("(layer \"F.Cu\")\n\t\t(uuid \"u-u-i-d\")\n\t\t(at 148 105)"));-        let legacy = "(module X (layer F.Cu) (pad 1 smd rect))";-        let modern = format!("(footprint{}", &legacy["(module".len()..]);-        let q = quote_first_layer(&modern);-        assert!(q.starts_with("(footprint X (layer \"F.Cu\")"));-        assert_eq!(quote_first_layer("(footprint X (layer \"B.Cu\"))"), "(footprint X (layer \"B.Cu\"))");-        assert!(place_in_board("(footprint X)", "u", 1.0, 2.0).is_none());-        let u = pseudo_uuid();-        assert_eq!(u.len(), 36);-        assert_eq!(&u[14..15], "4");-    }--    #[test]-    fn base64_round_trip() {-        assert_eq!(b64_encode(b""), "");-        assert_eq!(b64_encode(b"f"), "Zg==");-        assert_eq!(b64_encode(b"fo"), "Zm8=");-        assert_eq!(b64_encode(b"foo"), "Zm9v");-        assert_eq!(b64_encode(b"foobar"), "Zm9vYmFy");-        let back = kicad_core::install::b64_decode(&b64_encode(b"\x89PNG\r\n\x1a\n")).unwrap();-        assert_eq!(back, b"\x89PNG\r\n\x1a\n");-    }--    #[test]-    fn stamp_and_foreground_args() {-        let s = utc_stamp();-        assert_eq!(s.len(), 15);-        assert_eq!(&s[8..9], "-");-        assert!(user_wants_foreground(&json!({"foreground": true})));-        assert!(user_wants_foreground(&json!({"showToUser": true})));-        assert!(user_wants_foreground(&json!({"bringToFront": true})));-        assert!(!user_wants_foreground(&json!({"foreground": false, "reason": "x"})));-        assert_eq!(arg_any_u64(&json!({"pcbHwnd": "123"}), &["pcbHwnd", "pcb_hwnd"]), Some(123));-        assert_eq!(arg_any_u64(&json!({"pcb_hwnd": 0}), &["pcbHwnd", "pcb_hwnd"]), None);-    }--    #[test]-    fn dispatch_claims_only_its_verbs_and_is_honest_off_windows() {-        let mut state = State::new();-        assert!(dispatch(&mut state, "kicad_errors", &json!({})).is_none());-        assert!(dispatch(&mut state, "kicad_run_drc", &json!({})).is_none());-        let fk = dispatch(&mut state, "kicad_fix_keyboard", &json!({})).unwrap();-        assert_eq!(fk["success"], json!(true));-        assert_eq!(fk["stuckKeys"], json!([]));-        // Without window control (this host) the window verbs answer not_supported_on_platform-        // or an ab-unavailable error, never a hang and never a fake success.-        if !native().capabilities().window_control {-            std::env::remove_var("ADOM_DIRECT_API_URL");-            let r = dispatch(&mut state, "kicad_window_info", &json!({})).unwrap();-            assert_eq!(r["success"], json!(false));-            let code = r["errorCode"].as_str().unwrap_or("");-            assert!(code == "not_supported_on_platform" || code == "ab_unavailable", "{r}");-            let r = dispatch(&mut state, "kicad_open_editors", &json!({})).unwrap();-            assert_eq!(r["success"], json!(false));-            let r = dispatch(&mut state, "kicad_send_key", &json!({"key": "escape", "hwnd": 5})).unwrap();-            assert_eq!(r["success"], json!(false));-        }-        let r = dispatch(&mut state, "kicad_click", &json!({"hwnd": 5, "x": 300, "y": 2})).unwrap();-        assert_eq!(r["errorCode"], json!("bad_arg"));-        let r = dispatch(&mut state, "kicad_send_key", &json!({})).unwrap();-        assert_eq!(r["errorCode"], json!("missing_arg"));-        let r = dispatch(&mut state, "kicad_open_3d_viewer", &json!({"editor": "sideways"})).unwrap();-        assert_eq!(r["errorCode"], json!("bad_arg"));-    }--    #[test]-    fn catalog_covers_the_python_window_group() {-        let names: Vec<&str> = VERBS.iter().map(|v| v.name).collect();-        for n in ["kicad_launch", "kicad_open_board", "kicad_open_schematic", "kicad_open_symbol_editor", "kicad_open_footprint_editor", "kicad_open_3d_viewer", "kicad_open_editors", "kicad_close", "kicad_close_symbol_editor", "kicad_close_footprint_editor", "kicad_close_3d_viewer", "kicad_dismiss_dialogs", "kicad_window_info", "kicad_state", "kicad_screenshot_all", "kicad_send_key", "kicad_click", "kicad_fix_keyboard", "kicad_place_footprint", "kicad_enable_software_opengl"] {-            assert!(names.contains(&n), "{n} missing from the catalog");-        }-        assert!(!names.contains(&"kicad_errors"));-        for v in VERBS {-            assert!(!v.summary.contains('\u{2014}') && !v.hint.contains('\u{2014}'), "{} carries an em dash", v.name);-        }-    }-}---/// kicad_rescan_libraries: tree refresh first (#51), restart only on force plus confirmation.-fn rescan_libraries(state: &mut State, args: &Value) -> Value {-    let force = args.get("force").and_then(Value::as_bool).unwrap_or(false);-    if !force {-        let mut refreshed: Vec<u64> = Vec::new();-        for kind in [WindowKind::FootprintEditor, WindowKind::SymbolEditor] {-            if let Some(h) = first_of_kind(kind) {-                if let Ok(Some(_)) = menu_invoke_by_text(h, "refresh") {-                    refreshed.push(h);-                }-            }-        }-        if !refreshed.is_empty() {-            // The tree reloads asynchronously; the Python waited 3 s before probing.-            std::thread::sleep(std::time::Duration::from_secs(3));-            invalidate();-            let mut wanted: Vec<(String, String)> = Vec::new();-            for spec in args.get("footprints").and_then(Value::as_array).cloned().unwrap_or_default() {-                match spec {-                    Value::String(s) if s.contains(':') => {-                        let (lib, name) = s.split_once(':').unwrap();-                        wanted.push((lib.to_string(), name.to_string()));-                    }-                    Value::Object(o) => {-                        if let Some(name) = o.get("name").and_then(Value::as_str) {-                            wanted.push((o.get("library").and_then(Value::as_str).unwrap_or("Adom").to_string(), name.to_string()));-                        }-                    }-                    _ => {}-                }-            }-            let mut probed: Vec<Value> = Vec::new();-            if !wanted.is_empty() {-                let fp = first_of_kind(WindowKind::FootprintEditor);-                for (lib, name) in &wanted {-                    let (ok, trace, _mech) = match fp {-                        Some(h) => navigate_tree(h, &format!("{lib} {name}"), Verify::Footprint { name, library: lib }),-                        None => (false, json!({}), "none"),-                    };-                    probed.push(json!({-                        "library": lib, "name": name, "visible": ok,-                        "error": if ok { Value::Null } else if fp.is_none() { json!("no Footprint Editor open") } else { json!("editor tree did not load it") },-                        "navigation": trace,-                    }));-                }-            }-            let verified = !wanted.is_empty() && probed.iter().all(|p| p["visible"].as_bool().unwrap_or(false));-            let mut out = json!({"success": true, "method": "tree-refresh", "refreshedEditors": refreshed, "verified": verified, "probed": probed});-            out["_hint"] = json!(if !wanted.is_empty() && !verified {-                format!("Tree refreshed but KiCad still cannot load: {}. Check the name with kicad_list_footprints; if it is on disk, call again with force:true and confirmRestart:true for the process restart (about a minute).",-                    probed.iter().filter(|p| !p["visible"].as_bool().unwrap_or(false)).map(|p| format!("{}:{}", p["library"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or(""))).collect::<Vec<_>>().join(", "))-            } else if !wanted.is_empty() {-                "Verified in the editor: every listed footprint loaded after the refresh, no restart. The last one is now shown in the Footprint Editor.".to_string()-            } else {-                "Refresh invoked on the open editor tree(s). Pass footprints:[\"Lib:Name\"] to have each loaded in the editor as proof; without that list nothing is verified. force:true is the process restart, the last resort.".to_string()-            });-            post_verb(state, "kicad_rescan_libraries", args, &mut out, None);-            return out;-        }-    }-    // Restart path: the native build has no ownership ledger yet (phase 4), so a running-    // KiCad is treated as the user's unless the caller confirms.-    let running = native().processes(&["kicad.exe", "pcbnew.exe", "eeschema.exe"]).unwrap_or_default();-    let confirm = args.get("confirmRestart").and_then(Value::as_bool).unwrap_or(false);-    if !running.is_empty() && !confirm {-        return json!({"success": false, "errorCode": "rescan_refused",-            "reason": "KiCad is running and the native build cannot yet tell a user-launched KiCad from its own",-            "runningPids": running.iter().map(|p| p.pid).collect::<Vec<_>>(),-            "_hint": "Ask the user whether KiCad may be restarted, then call again with force:true and confirmRestart:true. Without force the verb refreshes the open editors' trees in place, which is enough for footprints installed after the editor started."});-    }-    // Wiki #48: a fresh process places every window on the primary display, so the user's-    // monitor choice is captured here, while the windows still exist, and restored below.-    let remembered = remember_placement();-    let mut close_how = "nothing was running";-    let mut graceful_failure = Value::Null;-    if !running.is_empty() {-        // Wiki #48 ask 3: graceful first, so KiCad writes its own geometry and its own files;-        // the kill is the fallback for a window that will not go.-        let mut closed = dispatch(state, "kicad_close", &json!({})).unwrap_or_else(|| json!({}));-        close_how = "graceful";-        if !closed["success"].as_bool().unwrap_or(false) {-            if closed["errorCode"] == json!("unsaved_changes") {-                return json!({"success": false, "errorCode": "restart_close_failed", "close": closed,-                    "_hint": "KiCad is asking whether to save the user's work, so nothing was closed or killed. Ask them, then save (or call kicad_close {\"discardChanges\": true}) before restarting."});-            }-            // Keep WHY the graceful close failed: otherwise the kill overwrites the only-            // evidence and the next person sees "force-kill" with no cause.-            graceful_failure = json!({"errorCode": closed.get("errorCode").cloned().unwrap_or(Value::Null), "error": closed.get("error").cloned().unwrap_or(Value::Null), "output": closed.get("output").cloned().unwrap_or(Value::Null)});-            closed = dispatch(state, "kicad_close", &json!({"force": true})).unwrap_or_else(|| json!({}));-            close_how = "force-kill after the graceful close failed";-            if !closed["success"].as_bool().unwrap_or(false) {-                return json!({"success": false, "errorCode": "restart_close_failed", "close": closed, "closeHow": close_how, "gracefulClose": graceful_failure});-            }-        }-        std::thread::sleep(std::time::Duration::from_secs(4));-    }-    let launched = launch(state, &json!({}));-    if !launched["success"].as_bool().unwrap_or(false) {-        return json!({"success": false, "errorCode": "restart_launch_failed", "launch": launched});-    }-    for _ in 0..80 {-        std::thread::sleep(std::time::Duration::from_millis(750));-        invalidate();-        if !kicad_windows().is_empty() {-            sleep_ms(1200); // let the fresh windows settle before moving them-            let restored = restore_placement();-            let moved = restored.iter().filter(|r| r["moved"] == json!(true)).count();-            return json!({"success": true, "restarted": true, "ready": true, "method": "process-restart",-                "placementRemembered": remembered, "placementRestored": restored, "closeHow": close_how, "gracefulClose": graceful_failure,-                "_hint": format!("Library caches rebuilt. Every part installed before this call is now visible; show verbs will not trigger another restart until the next install-while-open. Placement: {moved} window(s) put back where the user had them (wiki #48), without activating anything.")});-        }-    }-    json!({"success": false, "restarted": true, "ready": false, "errorCode": "restart_no_window",-        "_hint": "KiCad was restarted but no window appeared within 60 s; kicad_window_info shows what is up."})-}---#[cfg(test)]-mod linked_refresh_tests {-    #[test]-    fn exact_view_refresh_excludes_plugins_and_partial_labels() {-        let path = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();-        assert!(super::is_view_refresh(&path(&["&View", "&Refresh\tF5"])));-        for a in [-            vec!["Tools", "Refresh Plugins"],-            vec!["View", "Refresh All"],-            vec!["Refresh"],-        ] {-            assert!(!super::is_view_refresh(&path(&a)));-        }-    }-}--/// The boards KiCad's API says are open; without the ipc feature (the macOS type-check build) there is-/// no API to ask, and the callers fall back to the window title.-#[cfg(feature = "ipc")]-fn open_board_paths(timeout: Duration) -> Result<Vec<String>, String> {-    kicad_core::ipc::open_board_paths(timeout)-}--#[cfg(not(feature = "ipc"))]-fn open_board_paths(_timeout: Duration) -> Result<Vec<String>, String> {-    Err("this build has the ipc feature off".into())-}--#[cfg(feature = "ipc")]-fn same_board_path(a: &str, b: &str) -> bool {-    kicad_core::ipc::same_board_path(a, b)-}--#[cfg(not(feature = "ipc"))]-fn same_board_path(a: &str, b: &str) -> bool {-    a.replace('\\', "/").eq_ignore_ascii_case(&b.replace('\\', "/"))-}--// ----------------------------------------------------------------------------// The standard's document verbs: check_dialogs, save, close_document, close_all_documents-// -----------------------------------------------------------------------------/// What a dialog is, in the standard's words: (category, safeAction, meaning).-fn classify_dialog(title: &str, body: &str) -> (&'static str, &'static str, &'static str) {-    if wm::is_save_prompt(title, body) {-        return ("save_prompt", "ask the person; never answer it for them", "KiCad is asking whether to keep unsaved changes, which may be someone's work.");-    }-    match wm::sweep_decision(title, body, false) {-        DialogAction::LeaveProgress => ("progress", "wait", "KiCad is working; it closes itself."),-        DialogAction::Dismiss { benign: true } => ("benign", "kicad_dismiss_dialogs", "A notice that blocks nothing of value; the sweep clears it."),-        _ if wm::is_tool_dialog(title) => ("tool", "leave it, or close it when finished", "A tool window someone opened on purpose."),-        _ => ("other", "read it, then answer it", "KiCad is waiting on this; read the body or the shot before answering."),-    }-}--fn check_dialogs(args: &Value) -> Value {-    if let Err(e) = find_windows(true, true) {-        return e;-    }-    let shots = args.get("screenshots").and_then(Value::as_bool).unwrap_or(true);-    let rows: Vec<Value> = scan_dialogs().iter().map(|d| {-        let info = native().window_info(d.hwnd).ok().flatten();-        let buttons: Vec<String> = native().child_windows(d.hwnd).map(|c| c.into_iter().filter(|w| w.class_name == "Button").map(|w| w.title.replace('&', "")).collect()).unwrap_or_default();-        let (category, safe, meaning) = classify_dialog(&d.title, &d.body);-        json!({"hwnd": d.hwnd, "pid": info.as_ref().map(|i| i.pid), "title": d.title, "className": d.class_name, "body": d.body, "buttons": buttons,-            "category": category, "safeAction": safe, "meaning": meaning, "ownedByMainInstance": d.owner != 0 && !d.via_global_sweep,-            "rect": info.as_ref().map(|i| json!(i.rect)), "shot": if shots { json!(screenshot_dialog(d.hwnd, &d.title)) } else { Value::Null }})-    }).collect();-    json!({"success": true, "dialogsDetected": rows.len(), "dialogs": rows})-}--/// The editor holding a board or schematic path, matched on the whole file name, not a prefix of it.-fn editor_of(path: &str) -> Option<Win> {-    let p = Path::new(path);-    let kind = match p.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() {-        Some("kicad_pcb") => WindowKind::PcbEditor,-        Some("kicad_sch") => WindowKind::SchematicEditor,-        _ => return None,-    };-    let stem = p.file_stem()?.to_string_lossy().to_string();-    windows_of_kind(kind).into_iter().find(|w| crate::verbs_std_more::parse_title(&w.title).is_some_and(|t| t.0.eq_ignore_ascii_case(&stem)))-}--/// Ask an editor to save itself; true once the unsaved mark has gone from its title.-fn save_editor(state: &mut State, w: &Win, path: Option<&str>) -> Result<&'static str, String> {-    if w.kind() == WindowKind::PcbEditor {-        let args = path.map_or(json!({}), |p| json!({"filePath": p}));-        if ipc_save(state, &args).is_ok() {-            return Ok("IPC SaveDocument");-        }-    }-    match menu_invoke_by_text(w.hwnd, "Save") {-        Ok(Some(_)) => {}-        Ok(None) => return Err("the editor has no File > Save".into()),-        Err(e) => return Err(e),-    }-    let started = Instant::now();-    while started.elapsed() < Duration::from_secs(60) {-        sleep_ms(250);-        invalidate();-        let title = native().window_info(w.hwnd).ok().flatten().map(|i| i.title).unwrap_or_default();-        if !title.starts_with('*') {-            return Ok("File > Save");-        }-        if !scan_dialogs().is_empty() {-            break;-        }-    }-    Err("KiCad did not finish saving".into())-}--fn save_document(state: &mut State, args: &Value) -> Value {-    let started = Instant::now();-    let given = arg_any(args, &["filePath", "path"]);-    let win = match given {-        Some(p) => editor_of(p),-        None => windows_of_kind(WindowKind::PcbEditor).into_iter().chain(windows_of_kind(WindowKind::SchematicEditor)).min_by_key(|w| w.z),-    };-    let Some(win) = win else {-        return match given {-            Some(p) => json!({"success": true, "saved": false, "path": p, "note": format!("not open: {p}. The file already holds what it holds; nothing to save."), "data": {"process": Value::Null, "params": {}}}),-            None => fail("no_document", "no board or schematic is open", "kicad_open_document opens one; kicad_state lists KiCad's windows."),-        };-    };-    let was_dirty = win.title.starts_with('*');-    let mut out = match save_editor(state, &win, given) {-        Ok(how) => json!({"success": true, "saved": true, "wasUnsaved": was_dirty, "title": win.title, "data": {"process": how, "params": {}}}),-        Err(e) => {-            let mut v = fail("save_failed", e, "dialogs lists anything KiCad is asking. A never-saved document asks for a name.");-            v["saved"] = json!(false);-            v-        }-    };-    out["dialogs"] = json!(scan_dialogs().iter().map(|d| json!({"title": d.title, "body": d.body})).collect::<Vec<_>>());-    out["roundtripMs"] = json!(started.elapsed().as_millis() as u64);-    out-}--fn close_document(args: &Value) -> Value {-    let started = Instant::now();-    let Some(path) = arg_any(args, &["path", "filePath"]) else {-        return fail("missing_arg", "path is required", "The absolute path of the open .kicad_pcb or .kicad_sch.");-    };-    let Some(win) = editor_of(path) else {-        return json!({"success": true, "data": {"closed": false, "path": path, "note": format!("not open: {path}")}, "roundtripMs": started.elapsed().as_millis() as u64});-    };-    let mut st = CloseState { discard: true, ..Default::default() };-    let gone = close_one(&mut st, &win);-    let discarded = gone.is_none();-    if gone == Some(false) {-        let mut v = fail("close_failed", format!("{} did not close", win.title), "dialogsSeen lists what is up; kicad_check_dialogs reads them.");-        v["dialogsSeen"] = json!(scan_dialogs().iter().map(|d| json!({"title": d.title, "body": d.body})).collect::<Vec<_>>());-        v["unsavedChanges"] = json!(st.unsaved);-        return v;-    }-    json!({"success": true, "data": {"closed": true, "path": path, "discarded": discarded}, "roundtripMs": started.elapsed().as_millis() as u64})-}--fn close_all_documents(state: &mut State, args: &Value) -> Value {-    let flag = |k: &str| args.get(k).and_then(Value::as_bool).unwrap_or(false);-    let (save, discard, scratch) = (flag("save"), flag("discardUnsaved"), flag("includeScratch"));-    invalidate();-    let wins = match find_windows(false, true) {-        Ok((w, _)) => w,-        Err(e) => return e,-    };-    let (mut closed, mut saved, mut kept) = (Vec::new(), Vec::new(), Vec::new());-    for w in wins.iter().filter(|w| w.owner == 0 && matches!(w.kind(), WindowKind::PcbEditor | WindowKind::SchematicEditor | WindowKind::SymbolEditor | WindowKind::FootprintEditor)) {-        let name = crate::verbs_std_more::parse_title(&w.title).map(|t| t.0).unwrap_or_else(|| w.title.clone());-        let dirty = w.title.starts_with('*');-        let never_saved = w.title.contains("[Unsaved]") || name.is_empty() || name.eq_ignore_ascii_case("untitled") || name.eq_ignore_ascii_case("noname");-        if never_saved && !scratch {-            kept.push(json!({"name": name, "why": "scratch"}));-            continue;-        }-        if dirty && !never_saved && save {-            match save_editor(state, w, None) {-                Ok(_) => saved.push(name.clone()),-                Err(e) => {-                    kept.push(json!({"name": name, "why": format!("unsaved changes: the save failed ({e})")}));-                    continue;-                }-            }-        } else if dirty && !discard {-            kept.push(json!({"name": name, "why": "unsaved changes"}));-            continue;-        }-        let mut st = CloseState { discard, ..Default::default() };-        match close_one(&mut st, w) {-            Some(false) => kept.push(json!({"name": name, "why": if st.unsaved.is_empty() { "did not close" } else { "unsaved changes" }})),-            _ => closed.push(name),-        }-    }-    json!({"success": true, "closed": closed, "saved": saved, "keptOpen": kept})-}--// ----------------------------------------------------------------------------// kicad_teardrops: KiCad's own Edit > Edit Teardrops-// -----------------------------------------------------------------------------/// Teardrop areas on the board the editor serves (zones carrying `(teardrop (type ..))`), or-/// None without the IPC API. Read straight from KiCad, not through another verb: this runs-/// under GUI_LOCK, which verbs::call would try to take again.-/// (teardrop areas, the board's revision) for the board the editor serves; None without IPC.-#[cfg(feature = "ipc")]-fn teardrop_count(state: &mut State, args: &Value) -> Option<(usize, String)> {-    use kicad_core::{detect, ipc};-    let info = state.kicad_info();-    let ctx = ipc::Ctx {-        kicad_cli: info.primary().map(|p| PathBuf::from(&p.kicad_cli)).filter(|p| p.is_file()),-        config_dir: info.primary().and_then(|p| detect::config_dir(&p.version)),-    };-    let (text, _, _) = ipc::board_text(&ctx, args).ok()?;-    Some((text.match_indices("(teardrop").filter(|(i, _)| !text[i + 9..].starts_with('s')).count(), ipc::revision_of(&text)))-}--#[cfg(not(feature = "ipc"))]-fn teardrop_count(_state: &mut State, _args: &Value) -> Option<(usize, String)> {-    None-}--#[cfg(feature = "ipc")]-fn ipc_save(state: &mut State, args: &Value) -> Result<(), String> {-    use kicad_core::{detect, ipc};-    let info = state.kicad_info();-    let ctx = ipc::Ctx {-        kicad_cli: info.primary().map(|p| PathBuf::from(&p.kicad_cli)).filter(|p| p.is_file()),-        config_dir: info.primary().and_then(|p| detect::config_dir(&p.version)),-    };-    ipc::save_board(&ctx, args).map(|_| ()).map_err(|e| e.to_string())-}--#[cfg(not(feature = "ipc"))]-fn ipc_save(_state: &mut State, _args: &Value) -> Result<(), String> {-    Err("saving needs the IPC API".into())-}--/// KiCad's one "Track to track" box answers both of the standard's tracks and tJunctions: an explicit-/// answer on either decides it (either true turns it on), and neither given leaves it on.-fn track_to_track(args: &Value) -> bool {-    let (t, j) = (args.get("tracks").and_then(Value::as_bool), args.get("tJunctions").and_then(Value::as_bool));-    match (t, j) {-        (None, None) => true,-        _ => t == Some(true) || j == Some(true),-    }-}--fn teardrops(state: &mut State, args: &Value) -> Value {-    if args.get("force") == Some(&json!(true)) {-        return fail("not_supported", "KiCad's Set Teardrops has no way to force a teardrop that breaks a rule", "Leave force off, then check with kicad_run_drc: KiCad reports a teardrop that breaks clearance there.");-    }-    let editors = windows_of_kind(WindowKind::PcbEditor);-    let editor = match arg_str(args, "filePath") {-        Some(path) => {-            let stem = Path::new(path).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();-            match editors.into_iter().find(|w| w.title.trim_start_matches('*').starts_with(&stem)) {-                Some(w) => w,-                None => return fail("board_not_open", format!("{stem} is not open in a PCB editor"), "Open it with kicad_open_board: teardrops run in KiCad's PCB editor."),-            }-        }-        None => match editors.into_iter().next() {-            Some(w) => w,-            None => return fail("board_not_open", "no PCB editor is open", "Pass filePath after kicad_open_board: teardrops run in KiCad's PCB editor."),-        },-    };-    let flag = |k: &str, d: bool| args.get(k).and_then(Value::as_bool).unwrap_or(d);-    let remove = flag("remove", false);-    let save = flag("save", true);-    let before = teardrop_count(state, args).map(|(n, _)| n);-    match menu_invoke_by_text(editor.hwnd, "Edit Teardrops...") {-        Ok(Some(_)) => {}-        Ok(None) => return fail("menu_not_found", "the PCB editor has no Edit Teardrops... command", "KiCad 8 or newer has it under Edit."),-        Err(e) => return fail("menu_failed", e, "Check the editor with kicad_window_info."),-    }-    let mut dialog = None;-    for _ in 0..60 {-        if let Some(d) = scan_dialogs().into_iter().find(|d| d.title.trim() == "Set Teardrops") {-            dialog = Some(d.hwnd);-            break;-        }-        sleep_ms(250);-    }-    let Some(dlg) = dialog else {-        return fail("dialog_not_shown", "KiCad did not show the Set Teardrops dialog within 15 s", "kicad_window_info lists what KiCad shows; another dialog may be in the way.");-    };-    let mut steps = Vec::new();-    let mut set = |label: &str, on: bool| -> Result<(), String> { native().uia_set_checked(dlg, label, on).map(|r| steps.push(r)) };-    let vias = flag("vias", true);-    let options = [("PTH pads", flag("pthPads", vias)), ("SMD pads", flag("smdPads", true)), ("Vias", vias), ("Track to track", track_to_track(args)), ("Selected items only", flag("selectedOnly", false))];-    let mut failed = None;-    for (label, on) in options {-        if let Err(e) = set(label, on) {-            failed = Some(e);-            break;-        }-    }-    if failed.is_none() {-        let action = if remove { "Remove all teardrops" } else { "Add teardrops with default values for shape" };-        match native().uia_invoke(dlg, action) {-            Ok(r) => steps.push(r),-            Err(e) => failed = Some(e),-        }-    }-    if let Some(e) = failed {-        let _ = native().click_button(dlg, &["close"]);-        return fail("dialog_control_failed", format!("Set Teardrops: {e}"), "Nothing was applied; the dialog was closed. kicad_screenshot_all shows KiCad's windows.");-    }-    if let Err(e) = native().click_button(dlg, &["apply and close"]) {-        let _ = native().click_button(dlg, &["close"]);-        return fail("dialog_control_failed", format!("Set Teardrops: {e}"), "Nothing was applied; the dialog was closed.");-    }-    steps.push("apply and close".into());-    let started = Instant::now();-    while window_alive(dlg) && started.elapsed() < Duration::from_secs(150) {-        sleep_ms(250);-    }-    if window_alive(dlg) {-        return fail("dialog_still_open", "Set Teardrops is still open after 150 s", "KiCad may still be working; check with kicad_window_info before calling again.");-    }-    let save_error = if save { ipc_save(state, args).err() } else { None };-    if save && save_error.is_none() {-        steps.push("save".into());-    }-    let dialogs_left: Vec<Value> = scan_dialogs().into_iter().map(|d| json!({"title": d.title, "body": d.body})).collect();-    let after_read = teardrop_count(state, args);-    let after = after_read.as_ref().map(|(n, _)| *n);-    let revision = after_read.map(|(_, r)| r);-    let delta = match (before, after) {-        (Some(b), Some(a)) => Some(a as i64 - b as i64),-        _ => None,-    };-    json!({-        "success": true,-        "output": match delta {-            Some(d) => format!("Teardrops {}: {} before, {} after", if remove { "removed" } else { "applied" }, before.unwrap_or(0), after.unwrap_or(0)) + if d == 0 && !remove { " (nothing new: every item in scope already had one, or none qualified)" } else { "" },-            None => format!("Teardrops {} through KiCad's dialog (counts need the IPC API)", if remove { "removed" } else { "applied" }),-        },-        "before": before, "after": after, "added": delta.filter(|d| *d > 0), "removed": delta.filter(|d| *d < 0).map(|d| -d),-        "teardropsBefore": before, "teardropsAfter": after,-        "mutated": delta.map(|d| d != 0),-        "dialogSteps": steps, "steps": steps,-        "dialogsLeft": dialogs_left,-        "revision": revision,-        "saved": save && save_error.is_none(),-        "saveError": save_error,-        "_hint": "One KiCad Undo step; revision is the board's new revision (KiCad touches the board even when it adds none), so pass it to the next edit. saved says whether it reached the file (save:false leaves that to kicad_save_board). Then kicad_run_drc: teardrops are copper areas and take part in the fill and the checks.",-    })-}+/// The tree filter that lists one library whole. KiCad reads "Lib:" as "every part of Lib" and+/// nothing else; the bare name is a text search, which drops parts that don't say it and pulls in+/// other libraries' parts that do. KiCad splits the filter on spaces, so a name with one stays bare.+fn library_filter(library: &str) -> String {+    let name = library.trim().trim_end_matches(':');+    if name.chars().any(char::is_whitespace) { name.to_string() } else { format!("{name}:") }+}++/// Whether a navigation goes on to load a part, or only filters the tree.+fn loads_a_part(verify: Verify<'_>) -> bool {+    !matches!(verify, Verify::Never)+}++/// The reply of a library browse. The canvas is left alone, so on a warm editor it still shows+/// whatever was there: say so, because a proof image of it shows that part and not the library's.+fn library_browse_reply(ok: bool, title: &str, editor_word: &str, library: &str, nothing: &str) -> Value {+    if !ok {+        return json!({"success": false, "errorCode": "library_filter_failed", "error": format!("The {editor_word} is open but its library filter could not be set to '{library}'"),+            "_hint": "Read navigation.steps. A hidden library tree pane is the usual cause: show it from the editor's View menu."});+    }+    let (lib, part) = wm::parse_editor_title(title, editor_word);+    let mut out = json!({"success": true, "libraryShown": library});+    if part.is_empty() {+        out["output"] = json!(format!("{editor_word} open with the '{library}' library filtered. {nothing}"));+    } else {+        let id = if lib.is_empty() { part.clone() } else { format!("{lib}:{part}") };+        out["canvasPart"] = json!(id);+        out["canvasFromOtherLibrary"] = json!(!lib.eq_ignore_ascii_case(library));+        out["output"] = json!(format!("{editor_word} open with the '{library}' library filtered. This call loaded nothing; the canvas still shows {id} from before."));+    }+    out+}++fn navigate_tree(frame: u64, filter: &str, verify: Verify<'_>) -> (bool, Value, &'static str) {+    let mut steps: Vec<String> = Vec::new();+    let mut trace = json!({"filter": filter, "editorHwnd": frame, "titleBefore": title_of(frame)});+    let mut mechanism = "post";+    let Some(edit) = ensure_tree_search(frame, &mut steps) else {+        steps.push("search Edit under a 'searchCtrl' parent NOT FOUND (library tree pane hidden, or a different child layout)".into());+        trace["steps"] = json!(steps);+        return (false, trace, mechanism);+    };+    trace["editHwnd"] = json!(edit.hwnd);+    let mut set = set_filter_text(edit.hwnd, filter);+    if let Err(e) = &set {+        // A slow box (2 cores, software rendering) spends many seconds loading every library the+        // first time an editor opens, and its UI thread answers nothing meanwhile: the text timed out+        // at 1.5 s and the verb gave up on a symbol that exists (winvm). Wait for the editor to answer+        // again, then set the text once more.+        steps.push(format!("filter text not accepted yet ({e}); waiting for the editor to become responsive"));+        let mut waited = 0;+        while waited < 40 && !native().responsive(frame, 500).unwrap_or(true) {+            sleep_ms(500);+            waited += 1;+        }+        sleep_ms(800);+        set = set_filter_text(edit.hwnd, filter);+    }+    match set {+        Ok(m) => {+            mechanism = m;+            steps.push(format!("filter text set via {m}"));+        }+        Err(e) => {+            steps.push(format!("could not set the filter text: {e}"));+            trace["steps"] = json!(steps);+            return (false, trace, mechanism);+        }+    }+    sleep_ms(1400); // LIB_TREE search debounce on the UI thread + tree rebuild+    // A library browse asks for no part: everything below this exists to load one, and with no+    // part to confirm it would walk the library row by row and leave the last one on the canvas.+    if !loads_a_part(verify) {+        steps.push("library browse: filter set, no part loaded".into());+        trace["steps"] = json!(steps);+        return (true, trace, mechanism);+    }+    // Down THROUGH the filter box: KiCad forwards nav keys to the tree.+    post_vk(edit.hwnd, wm::VK_DOWN);+    sleep_ms(500);+    steps.push(format!("VK_DOWN posted to edit; title now {:?}", title_of(frame)));+    // Tree candidates: tall siblings under the Libraries pane (wx generic controls share+    // bland class names, so geometry is the discriminator) plus any wxDataView.+    let all = descendants(frame);+    let parent = all.iter().find(|c| c.hwnd == edit.hwnd).map(|c| c.parent).unwrap_or(frame);+    let pane = all.iter().find(|c| c.hwnd == parent).map(|c| c.parent).filter(|p| *p != 0).unwrap_or(parent);+    let cands: Vec<Child> = all.iter().filter(|c| c.hwnd != edit.hwnd && (c.parent == pane || all.iter().any(|p| p.hwnd == c.parent && p.parent == pane)) && (c.rect.3 > 200 || c.class_name.starts_with("wxDataView"))).cloned().collect();+    trace["treeCandidates"] = json!(cands.iter().map(|c| json!({"hwnd": c.hwnd, "class": c.class_name, "rect": [c.rect.0, c.rect.1, c.rect.0 + c.rect.2, c.rect.1 + c.rect.3]})).collect::<Vec<_>>());+    let title_ok = || verify_title(&title_of(frame), verify);+    // Round 1: Enter posted straight to each tree candidate.+    for c in &cands {+        post_vk(c.hwnd, wm::VK_RETURN);+    }+    for _ in 0..6 {+        sleep_ms(700);+        if title_ok() {+            steps.push("VK_RETURN to tree candidates -> title confirms".into());+            trace["steps"] = json!(steps);+            return (true, trace, mechanism);+        }+    }+    steps.push(format!("VK_RETURN to {} tree candidate(s); title still {:?}", cands.len(), title_of(frame)));+    // Round 1b/1c (wiki #71): on a FRESH editor nothing is selected, so select from inside+    // the tree: Down (first lands on the library node, second on the symbol row), then Enter.+    for downs in 1..=3 {+        for c in &cands {+            post_vk(c.hwnd, wm::VK_DOWN);+        }+        sleep_ms(400);+        for c in &cands {+            post_vk(c.hwnd, wm::VK_RETURN);+        }+        for _ in 0..5 {+            sleep_ms(600);+            if title_ok() {+                steps.push(format!("Down x{downs} then VK_RETURN to tree candidates -> title confirms"));+                trace["steps"] = json!(steps);+                return (true, trace, mechanism);+            }+        }+        steps.push(format!("Down x{downs} then VK_RETURN to tree; title still {:?}", title_of(frame)));+    }+    // Round 2: a posted double-click on the first visible row (just under the header).+    for c in &cands {+        let x = (c.rect.2 / 3).max(10);+        let y = 58;+        let _ = native().post_click(c.hwnd, x, y, false);+        let _ = native().post_click(c.hwnd, x, y, true);+    }+    for _ in 0..8 {+        sleep_ms(700);+        if title_ok() {+            steps.push("posted double-click on first row -> title confirms".into());+            trace["steps"] = json!(steps);+            return (true, trace, mechanism);+        }+    }+    steps.push(format!("posted double-click on first row; title still {:?}", title_of(frame)));+    trace["steps"] = json!(steps);+    (false, trace, mechanism)+}++// ── Opening an editor frame: menu walk, then UIA ──────────────────────────────++struct Budget {+    t0: Instant,+    limit: Duration,+}++impl Budget {+    fn new(args: &Value) -> Budget {+        let inline = args.get("waitInline").and_then(Value::as_bool).unwrap_or(false);+        Budget { t0: Instant::now(), limit: if inline { Duration::from_secs(1_000_000) } else { Duration::from_secs(110) } }+    }+    fn out(&self) -> bool {+        self.t0.elapsed() > self.limit+    }+    fn elapsed_sec(&self) -> u64 {+        self.t0.elapsed().as_secs()+    }+}++struct OpenSpec {+    kind: WindowKind,+    /// Menu label fragment ("symbol editor") and the UIA accessible name ("Symbol Editor").+    menu_needle: &'static str,+    uia_name: &'static str,+    /// Frames whose menu bar / UIA tree carries the launcher, in preference order+    /// (a running editor of the same family first, the project manager last).+    hosts: &'static [WindowKind],+    error_code: &'static str,+    starting_code: &'static str,+    label: &'static str,+}++const SYMBOL_SPEC: OpenSpec = OpenSpec { kind: WindowKind::SymbolEditor, menu_needle: "symbol editor", uia_name: "Symbol Editor", hosts: &[WindowKind::SchematicEditor, WindowKind::ProjectManager], error_code: "symbol_editor_did_not_open", starting_code: "symbol_editor_starting", label: "Symbol Editor" };+const FOOTPRINT_SPEC: OpenSpec = OpenSpec { kind: WindowKind::FootprintEditor, menu_needle: "footprint editor", uia_name: "Footprint Editor", hosts: &[WindowKind::PcbEditor, WindowKind::ProjectManager], error_code: "footprint_editor_did_not_open", starting_code: "footprint_editor_starting", label: "Footprint Editor" };++/// The early-return contract when a slow cold start would outlive the transport budget+/// (#54 ask 2, wiki #71): honest about whether anything is actually starting.+fn starting_contract(spec: &OpenSpec, budget: &Budget, phase: &str, pathways: &[String]) -> Value {+    invalidate();+    if first_of_kind(spec.kind).is_none() {+        return json!({+            "success": false, "errorCode": spec.error_code, "retryable": true, "retryAfterMs": 5000,+            "phase": phase, "elapsedSec": budget.elapsed_sec(), "pathwaysTried": pathways,+            "error": format!("No {} window exists. The bridge asked KiCad to open one and none appeared, so this is not a slow cold start - nothing is coming up.", spec.label),+            "_hint": format!("Call kicad_open_{} first and check it returns a window, then re-call this verb. If the editor never opens, KiCad is usually busy with a human (it is single-instance and will not service our menu command while someone is driving it) - kicad_window_info shows what is actually open.", spec.kind.as_str()),+        });+    }+    json!({+        "success": false, "errorCode": spec.starting_code, "starting": true, "retryable": true, "statusVerb": "kicad_state",+        "retryAfterMs": 20000, "phase": phase, "elapsedSec": budget.elapsed_sec(),+        "error": format!("The {} is still coming up (cold start indexes every library, which can outlive the transport budget). This call returned early instead of timing out; KiCad keeps working.", spec.label),+        "_hint": "Not a failure - poll kicad_state (or just wait retryAfterMs) and re-call with the SAME args; a warm editor resolves in seconds. Pass waitInline:true to force the old single-call blocking behaviour.",+    })+}++/// Make sure the project manager exists (spawn kicad.exe in the background if not).+fn ensure_project_manager(info: &KicadInfo, pathways: &mut Vec<String>) -> Result<u64, Value> {+    invalidate();+    if let Some(h) = find_project_manager() {+        return Ok(h);+    }+    let Some(exe) = kicad_exe(info, "kicad") else {+        return Err(fail("kicad_exe_not_found", "KiCad executable not found", "Report to the user and ask them to reinstall KiCad; the installation appears corrupted."));+    };+    spawn_background(&exe, &[], "launch", None).map_err(|mut e| { if e["errorCode"] != json!("not_supported_on_platform") { e["_hint"] = json!("Call kicad_close with {\"force\": true} to clear stuck processes, then retry."); } e })?;+    pathways.push("spawned-project-manager".into());+    let deadline = Instant::now() + Duration::from_secs(10);+    loop {+        sleep_ms(500);+        invalidate();+        if let Some(h) = find_project_manager() {+            sleep_ms(2000);+            return Ok(h);+        }+        if Instant::now() > deadline {+            return Err(fail("project_manager_did_not_open", "KiCad launched but project manager window not found after 10s", "Call screenshot_all to see KiCad's current state, or call close_kicad with {\"force\": true} and retry."));+        }+    }+}++/// Open an editor frame: an existing one wins; else the native menu walk on each host+/// frame (WM_COMMAND, repost once at 10 s); else UIA Invoke. Returns (hwnd, mechanism).+fn open_editor_frame(info: &KicadInfo, spec: &OpenSpec, budget: &Budget, pathways: &mut Vec<String>) -> Result<(u64, &'static str), Value> {+    let out = open_editor_frame_inner(info, spec, budget, pathways);+    // Wiki #48: a frame the bridge just opened is placed by Windows defaults, which means the+    // primary display. An existing window is the user's own and is never moved.+    if let Ok((hwnd, mechanism)) = &out {+        if *mechanism != "existing" {+            if let Some(row) = restore_placement_one(spec.kind, *hwnd) {+                if row["moved"] == json!(true) {+                    pathways.push(format!("placement-restored:{}", spec.kind.as_str()));+                }+            }+        }+    }+    out+}++fn open_editor_frame_inner(info: &KicadInfo, spec: &OpenSpec, budget: &Budget, pathways: &mut Vec<String>) -> Result<(u64, &'static str), Value> {+    invalidate();+    if let Some(h) = first_of_kind(spec.kind) {+        return Ok((h, "existing"));+    }+    // Hosts: a live editor frame of the same family first, then the project manager+    // (spawned in the background when nothing at all is running).+    let mut hosts: Vec<u64> = Vec::new();+    for k in spec.hosts {+        if *k == WindowKind::ProjectManager {+            continue;+        }+        hosts.extend(windows_of_kind(*k).iter().map(|w| w.hwnd));+    }+    if spec.hosts.contains(&WindowKind::ProjectManager) {+        let pm = match find_project_manager() {+            Some(h) => h,+            None if hosts.is_empty() => ensure_project_manager(info, pathways)?,+            None => 0,+        };+        if pm != 0 {+            hosts.push(pm);+        }+    }+    if hosts.is_empty() {+        return Err(fail(spec.error_code, format!("No window can host the {} launcher: neither a project manager nor a related editor is open.", spec.label), "Call kicad_launch first, then retry."));+    }+    // Rung: native menu walk + WM_COMMAND (background, version-proof ids).+    let mut menu_unsupported: Option<Value> = None;+    for &host in &hosts {+        if budget.out() {+            return Err(starting_contract(spec, budget, "waiting-for-editor-window", pathways));+        }+        match menu_invoke_by_text(host, spec.menu_needle) {+            Ok(Some((id, _label))) => {+                pathways.push(format!("menu-walk-id-{id}"));+                pathways.push("wm_command".into());+                let deadline = Instant::now() + Duration::from_secs(30);+                if let Some(h) = wait_for_kind(spec.kind, deadline, Some((host, id)), Duration::from_secs(10)) {+                    return Ok((h, "menu"));+                }+                pathways.push("wm_command-no-window".into());+            }+            Ok(None) => pathways.push(format!("menu-walk-miss:{host}")),+            Err(e) if is_not_impl(&e) => { menu_unsupported = Some(unsupported("native menu walk (menu_tree)")); }+            Err(e) => pathways.push(format!("menu-walk-error:{}", e.chars().take(60).collect::<String>())),+        }+    }+    // Rung: UI Automation Invoke by accessible name (no focus, no cursor).+    let mut uia_ran = false;+    for &host in &hosts {+        if budget.out() {+            return Err(starting_contract(spec, budget, "waiting-for-editor-window", pathways));+        }+        match native().uia_invoke(host, spec.uia_name) {+            Ok(_) => {+                uia_ran = true;+                pathways.push("uia".into());+                let deadline = Instant::now() + Duration::from_secs(30);+                if let Some(h) = wait_for_kind(spec.kind, deadline, None, Duration::from_secs(10)) {+                    return Ok((h, "uia"));+                }+                pathways.push("uia-no-window".into());+            }+            Err(e) if is_not_impl(&e) => {+                if let Some(u) = menu_unsupported.take() {+                    return Err(u);+                }+                pathways.push("uia-unsupported".into());+            }+            Err(e) => {+                uia_ran = true;+                pathways.push(format!("uia-{}", e.chars().take(40).collect::<String>()));+            }+        }+    }+    if let (Some(u), false) = (menu_unsupported, uia_ran) {+        return Err(u);+    }+    let idle = user_idle_seconds();+    let busy = idle.map(|i| i < 120.0).unwrap_or(false);+    Err(json!({+        "success": false, "errorCode": spec.error_code, "pathwaysTried": pathways, "userIdleSeconds": idle,+        "error": format!("KiCad accepted the {} command but no editor window appeared within 30s.{}", spec.label,+            if busy { format!(" A human was active on this machine {}s ago, which is the most likely reason: KiCad is single-instance, and while someone is driving it, it does not service our menu command promptly.", idle.unwrap_or(0.0) as i64) } else { String::new() }),+        "_hint": "Retry - this is usually a busy KiCad rather than a broken one, and it succeeds on a machine nobody is touching (measured: 63s cold, 57s with the project manager already running). If it repeats on an IDLE box, that is a real bug worth reporting. Otherwise check for a modal dialog with kicad_screenshot_all and dismiss it with send_key escape.",+    }))+}++/// Ask an open editor to close so the next open re-indexes the libraries: ab's+/// desktop_close_window first (its bookkeeping), local WM_CLOSE otherwise; the WAIT is+/// measured here. Returns (gone, how).+fn close_and_wait(hwnd: u64, reason: &str, timeout: Duration) -> (bool, &'static str) {+    let how = match ab::desktop_close_window(hwnd, reason) {+        Ok(_) => "ad:desktop_close_window",+        Err(_) => {+            if native().post_close(hwnd).is_err() {+                return (false, "none");+            }+            "local:WM_CLOSE"+        }+    };+    invalidate();+    let deadline = Instant::now() + timeout;+    while Instant::now() < deadline {+        sleep_ms(300);+        invalidate();+        if !window_alive(hwnd) {+            return (true, how);+        }+    }+    (false, how)+}++// ── kicad_launch ──────────────────────────────────────────────────────────────++pub(crate) fn launch(state: &mut State, args: &Value) -> Value {+    let existing = match find_windows(false, true) {+        Ok((w, _)) => w,+        Err(e) => return e,+    };+    if !existing.is_empty() {+        return json!({+            "success": true, "alreadyRunning": true,+            "windows": existing.iter().take(10).map(|w| w.title.clone()).collect::<Vec<_>>(),+            "output": format!("KiCad is already running ({} window(s)); nothing launched.", existing.len()),+            "_hint": "Idempotent no-op: KiCad was already up. Use kicad_open_editors to inventory editors, or the open_* verbs to open a specific one.",+        });+    }+    let project = arg_str(args, "project").unwrap_or("").trim().to_string();+    if !project.is_empty() && !Path::new(&project).exists() {+        return fail("project_not_found", format!("Project not found: {project}"), "Absolute Windows path required; %VAR% expansion is not performed.");+    }+    let info = state.kicad_info();+    let Some(exe) = kicad_exe(&info, "kicad") else {+        return fail("kicad_exe_not_found", "kicad.exe not found", "No KiCad installation detected. kicad_readiness reports install state; kicad_upgrade installs KiCad silently (per-user, no UAC).");+    };+    let argv: Vec<String> = if project.is_empty() { vec![] } else { vec![project.clone()] };+    let (pid, log) = match spawn_background(&exe, &argv, "launch", arg_str(args, "traceMasks")) {+        Ok(x) => x,+        Err(mut e) => {+            if e["errorCode"] != json!("not_supported_on_platform") {+                e["error"] = json!(format!("Failed to launch KiCad: {}", e["error"].as_str().unwrap_or("")));+            }+            return e;+        }+    };+    let wait_s = args.get("waitSeconds").and_then(Value::as_f64).unwrap_or(15.0).max(3.0);+    let deadline = Instant::now() + Duration::from_secs_f64(wait_s);+    let mut windows = Vec::new();+    while Instant::now() < deadline {+        windows = find_windows(false, true).map(|(w, _)| w).unwrap_or_default();+        if !windows.is_empty() {+            break;+        }+        sleep_ms(500);+    }+    let log_path = log.as_ref().map(|p| norm(p));+    if !windows.is_empty() {+        // Wiki #48 ask 2: a cold spawn on a box that has run KiCad before lands on the monitor+        // the user last used, not on whatever Windows defaults to.+        sleep_ms(1200);+        let restored = restore_placement();+        return json!({+            "success": true, "launched": true, "pid": pid, "mechanism": "spawn",+            "windows": windows.iter().take(10).map(|w| w.title.clone()).collect::<Vec<_>>(),+            "output": format!("KiCad launched; {} window(s) up.", windows.len()),+            "logPath": log_path, "placementRestored": restored,+            "_hint": "Launched in the BACKGROUND (no focus steal). First run on a fresh install may show a setup wizard; kicad_window_info self-heals benign dialogs; kicad_screenshot_all shows every window if something looks stuck.",+            "_next": ["kicad_window_info", "kicad_open_editors", "kicad_open_board"],+        });+    }+    json!({+        "success": false, "pid": pid, "mechanism": "spawn", "logPath": log_path,+        "error": format!("kicad.exe started but no KiCad window appeared within {}s", wait_s as i64),+        "_hint": "Cold start on a slow box (or software-OpenGL) can exceed the wait; retry kicad_window_info in a few seconds before treating this as a failure. A modal (crash-recovery / first-run) can also hold the window back: kicad_screenshot_all will show it.",+        "statusVerb": "kicad_window_info",+    })+}++// ── kicad_open_board / kicad_open_schematic ───────────────────────────────────++pub(crate) fn open_file(state: &mut State, args: &Value, kind: WindowKind) -> Value {+    let info = state.kicad_info();+    if !info.installed {+        return fail("not_installed", "KiCad not installed", "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.");+    }+    let Some(file_path) = arg_str(args, "filePath") else {+        return fail("missing_arg", "No filePath specified", "Pass filePath: the absolute path of the file on the desktop.");+    };+    let path = PathBuf::from(file_path);+    if !path.exists() {+        return fail("file_not_found", format!("File not found: {file_path}"), "The path may exist on Docker but not on the Windows host. Use push_file to copy from Docker first, or verify the host-side path.");+    }+    let (exe_name, label, lint_verb) = match kind {+        WindowKind::PcbEditor => ("pcbnew", "PCB board", "kicad_lint_board"),+        _ => ("eeschema", "schematic", "kicad_lint_schematic"),+    };+    let Some(exe) = kicad_exe(&info, exe_name) else {+        return fail("kicad_exe_not_found", format!("KiCad executable not found: {exe_name}_exe"), "Report to the user and ask them to reinstall KiCad; the installation appears corrupted.");+    };+    let stem = path.file_stem().map(|s| s.to_string_lossy().to_lowercase()).unwrap_or_default();+    let name = path.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();+    // Already open: the existing window, not a fresh reload.+    invalidate();+    if let Some(w) = windows_of_kind(kind).into_iter().find(|w| !stem.is_empty() && w.title.to_lowercase().contains(&stem)) {+        // Wiki #122: a title match is only a basename match. For a board, ask KiCad which+        // file it really has open before reusing the window.+        let mut path_verified = false;+        if kind == WindowKind::PcbEditor {+            if let Ok(open) = open_board_paths(Duration::from_secs(3)) {+                let wanted = norm(&path);+                if open.iter().any(|p| same_board_path(p, &wanted)) {+                    path_verified = true;+                } else if !open.is_empty() {+                    // the API serves one editor: a board of the same file name elsewhere, or another editor's+                    // board entirely (a second pcbnew holds the API, and this window cannot be verified)+                    let same_name = open.iter().any(|p| p.replace('\\', "/").rsplit('/').next().map(|f| f.eq_ignore_ascii_case(&name)).unwrap_or(false));+                    let (code, error) = if same_name {+                        ("different_board_same_name", format!("The PCB editor has a different board open with the same file name ({name}): {}", open.join(", ")))+                    } else {+                        ("api_serves_another_board", format!("A PCB editor titled {name} is open, but KiCad's API answers for another editor's board ({}), so this window's board cannot be verified", open.join(", ")))+                    };+                    return json!({+                        "success": false, "errorCode": code, "hwnd": w.hwnd, "title": w.title,+                        "error": error,+                        "openBoards": open, "requested": wanted,+                        "_hint": "KiCad titles show only the file name and its API serves one editor, so the bridge will not guess. Close the other board (save it first if it has changes), or open a copy of yours under a unique file name.",+                    });+                }+            }+        }+        return json!({+            "success": true, "alreadyOpen": true, "hwnd": w.hwnd, "title": w.title, "mechanism": "existing",+            "pathVerified": path_verified,+            "output": format!("{label} editor already has {name} open"),+            "_hint": if path_verified { "The existing window was reused, not reloaded (KiCad confirmed the exact path). kicad_close first if you need a fresh load from disk." } else { "The existing window was reused on a file-name match that could not be confirmed against the full path (no KiCad API answer). If a same-named file from another folder could be open, kicad_close it first." },+        });+    }+    let (pid, log) = match spawn_background(&exe, &[norm(&path)], exe_name, None) {+        Ok(x) => x,+        Err(mut e) => {+            if e["errorCode"] != json!("not_supported_on_platform") {+                e["error"] = json!(format!("Failed to launch: {}", e["error"].as_str().unwrap_or("")));+                e["_hint"] = json!("Call close_kicad with {\"force\": true} to clear any stuck processes, then retry.");+            }+            return e;+        }+    };+    // Verify by window title with one deadline (no nested sleeps).+    let wait_s = args.get("waitSeconds").and_then(Value::as_f64).unwrap_or(30.0).clamp(3.0, 110.0);+    let deadline = Instant::now() + Duration::from_secs_f64(wait_s);+    if !native().capabilities().window_control {+        return open_file_without_windows(kind, &path, pid, log, deadline, wait_s, exe_name, label, lint_verb);+    }+    let mut appeared: Option<Win> = None;+    while Instant::now() < deadline {+        invalidate();+        let wins = windows_of_kind(kind);+        appeared = wins.iter().find(|w| w.title.to_lowercase().contains(&stem)).or_else(|| wins.iter().find(|w| w.pid == pid)).cloned();+        if appeared.is_some() {+            break;+        }+        if native().process_alive(pid) == Some(false) {+            break;+        }+        sleep_ms(500);+    }+    let hint = format!(+        "GUI launched. For unfamiliar files (ones you did not generate / have not validated), prefer running `{lint_verb}` FIRST. It uses kicad-cli to give you structured DRC/ERC + file-format info in ~1-3s, much faster + more reliable than recovering from a confused GUI via screenshots. If the GUI shows a modal dialog (file-version upgrade prompt, missing-symbol warning, recovery prompt), use kicad_window_info -> kicad_screenshot_all -> kicad_send_key to handle it; or close + retry after lint."+    );+    let mut out = json!({+        "success": true, "pid": pid, "mechanism": "spawn", "logPath": log.as_ref().map(|p| norm(p)),+        "output": format!("Launched {label} editor with {name}"),+        "_hint": hint,+    });+    match appeared {+        Some(w) => {+            out["hwnd"] = json!(w.hwnd);+            out["title"] = json!(w.title);+            out["windowAppeared"] = json!(true);+            // Wiki #48: this window was spawned by us, so Windows placed it on the primary+            // display; put it back on the monitor the user keeps this editor on.+            if let Some(row) = restore_placement_one(kind, w.hwnd) {+                out["placementRestored"] = row;+            }+        }+        None => {+            let alive = native().process_alive(pid);+            out["windowAppeared"] = json!(false);+            if alive == Some(false) {+                out["success"] = json!(false);+                out["errorCode"] = json!("editor_exited");+                out["error"] = json!(format!("{exe_name} exited before a {label} window appeared"));+                out["_hint"] = json!(format!("The file probably failed to load: the launch log (logPath) and kicad_log_tail carry KiCad's own message; {lint_verb} says what is wrong with the file."));+            } else {+                out["statusVerb"] = json!("kicad_window_info");+                out["_hint"] = json!(format!("{exe_name} is running but no {label} window titled after the file appeared within {}s (a cold start or a large file can take longer). Poll kicad_window_info; a modal dialog (kicad_screenshot_all) can also be holding it. {hint}", wait_s as i64));+            }+        }+    }+    out+}++/// Wiki #127: no window list on this OS (Linux for now). A board is confirmed through KiCad's+/// own API (the editor reports the exact path it has open); a schematic by the process staying up.+#[allow(clippy::too_many_arguments)]+fn open_file_without_windows(kind: WindowKind, path: &Path, pid: u32, log: Option<PathBuf>, deadline: Instant, wait_s: f64, exe_name: &str, label: &str, lint_verb: &str) -> Value {+    let wanted = norm(path);+    let name = path.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();+    let mut out = json!({+        "success": true, "pid": pid, "mechanism": "spawn", "logPath": log.as_ref().map(|p| norm(p)),+        "output": format!("Launched {label} editor with {name}"),+        "windowControl": false,+    });+    let mut confirmed = false;+    let mut last_open: Vec<String> = Vec::new();+    while Instant::now() < deadline {+        if native().process_alive(pid) == Some(false) {+            out["success"] = json!(false);+            out["errorCode"] = json!("editor_exited");+            out["error"] = json!(format!("{exe_name} exited before the {label} loaded"));+            out["_hint"] = json!(format!("The launch log (logPath) carries KiCad's own message; {lint_verb} says what is wrong with the file."));+            return out;+        }+        if kind == WindowKind::PcbEditor {+            if let Ok(open) = open_board_paths(Duration::from_secs(2)) {+                if open.iter().any(|p| same_board_path(p, &wanted)) {+                    confirmed = true;+                    break;+                }+                last_open = open;+            }+        } else if Instant::now() + Duration::from_secs_f64(wait_s) > deadline + Duration::from_secs(5) {+            // A schematic: no API answer to wait for on KiCad 10; five seconds alive is the signal.+            confirmed = true;+            break;+        }+        sleep_ms(500);+    }+    out["loaded"] = json!(confirmed);+    out["_hint"] = json!(if confirmed {+        if kind == WindowKind::PcbEditor { "KiCad's API confirms the board is open, so the live verbs (routing, placement, zones) can use it now. Window verbs (screenshots, keys, menus) are not available on this OS yet: use kicad_render_board or kicad_export_svg for a picture." } else { "The schematic editor is running. Window verbs (screenshots, keys, menus) are not available on this OS yet: use kicad_export_svg or kicad_export_pdf for a picture." }+    } else if last_open.is_empty() {+        "The editor is running but KiCad's API did not report the board in time. A cold start can take longer: poll kicad_routing_state with filePath. If it answers api_server_off, run kicad_ipc_api {\"enable\":true} with KiCad closed."+    } else {+        "The editor is running, but KiCad's API reports a different board (openBoards). KiCad serves one API per machine: the first KiCad that started owns it."+    });+    if !last_open.is_empty() && !confirmed {+        out["openBoards"] = json!(last_open);+    }+    out+}++// ── kicad_open_symbol_editor ──────────────────────────────────────────────────++fn lib_ctx(info: &KicadInfo) -> LibCtx {+    let inst = info.primary();+    let version = inst.map(|i| i.version.clone()).unwrap_or_default();+    let mut ctx = LibCtx {+        version: version.clone(),+        base_dir: inst.map(|i| i.base_dir.clone()).filter(|b| !b.is_empty()),+        config_dir: if version.is_empty() { None } else { native().config_dir(&version) },+        user_dir: if version.is_empty() { None } else { native().user_dir(&version) },+        kicad_cli: inst.map(|i| PathBuf::from(&i.kicad_cli)).filter(|p| p.is_file()),+        env_overrides: Default::default(),+    };+    ctx.load_env_overrides();+    ctx+}++/// open_symbol_editor._resolve_symbol_alias: exact, case-insensitive, then a UNIQUE+/// prefix match in either direction against the Adom library's real names.+fn resolve_symbol_alias(info: &KicadInfo, requested: &str) -> (Option<String>, String) {+    let ctx = lib_ctx(info);+    let Some((_table, lib_path)) = libraries::adom_paths(&ctx) else { return (None, "library file not found".into()) };+    if !lib_path.is_file() {+        return (None, "library file not found".into());+    }+    let names = libraries::list_symbols(&lib_path);+    if names.iter().any(|n| n == requested) {+        return (Some(requested.to_string()), "exact".into());+    }+    if let Some(n) = names.iter().find(|n| n.eq_ignore_ascii_case(requested)) {+        return (Some(n.clone()), "case-insensitive".into());+    }+    let r = requested.to_lowercase();+    let pref: Vec<&String> = names.iter().filter(|n| { let l = n.to_lowercase(); l.starts_with(&r) || r.starts_with(&l) }).collect();+    match pref.len() {+        1 => (Some(pref[0].clone()), "unique prefix match".into()),+        0 => (None, "no match".into()),+        _ => (None, format!("ambiguous: {:?}", pref.iter().take(5).collect::<Vec<_>>())),+    }+}++fn symbol_on_disk(info: &KicadInfo, name: &str) -> bool {+    let ctx = lib_ctx(info);+    libraries::adom_paths(&ctx).map(|(_, p)| p.is_file() && libraries::list_symbols(&p).iter().any(|n| n.eq_ignore_ascii_case(name))).unwrap_or(false)+}++pub(crate) fn open_symbol_editor(state: &mut State, args: &Value) -> Value {+    let info = state.kicad_info();+    if !info.installed {+        return fail("not_installed", "KiCad not installed", "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.");+    }+    let mut symbol_name = arg_str(args, "symbolName").unwrap_or("").to_string();+    let library_name = arg_any(args, &["libraryName", "library"]).unwrap_or("").to_string();+    // Wiki #62: the file is named for the MPN but the symbol inside may not be. Resolve+    // the caller's name against the library's real names BEFORE navigating and say so.+    let mut note = Map::new();+    if !symbol_name.is_empty() && (library_name.is_empty() || library_name.eq_ignore_ascii_case("adom")) {+        let (resolved, how) = resolve_symbol_alias(&info, &symbol_name);+        match resolved {+            Some(r) if r != symbol_name => {+                note.insert("aliasedFrom".into(), json!(symbol_name));+                note.insert("aliasHow".into(), json!(how));+                symbol_name = r;+            }+            None if how.starts_with("ambiguous") => { note.insert("aliasNote".into(), json!(format!("'{symbol_name}' is not in the library and the prefix match is {how}"))); }+            None if how == "no match" => { note.insert("aliasNote".into(), json!(format!("'{symbol_name}' is not a symbol name in the Adom library (no exact, case-insensitive or prefix match)"))); }+            _ => {}+        }+    }+    let mut out = open_symbol_editor_inner(&info, args, &symbol_name, &library_name);+    if !note.is_empty() {+        if let Some(o) = out.as_object_mut() {+            for (k, v) in &note {+                o.insert(k.clone(), v.clone());+            }+            if let Some(from) = note.get("aliasedFrom").and_then(Value::as_str) {+                let prev = o.get("_hint").and_then(Value::as_str).unwrap_or("").to_string();+                o.insert("_hint".into(), json!(format!("Resolved '{from}' to symbol '{symbol_name}' ({}). {prev}", note["aliasHow"].as_str().unwrap_or(""))));+            }+        }+    }+    out+}++fn open_symbol_editor_inner(info: &KicadInfo, args: &Value, symbol_name: &str, library_name: &str) -> Value {+    let spec = &SYMBOL_SPEC;+    let budget = Budget::new(args);+    let rescan = args.get("rescan").and_then(Value::as_bool).unwrap_or(false);+    let mut pathways: Vec<String> = Vec::new();+    invalidate();+    // Already loaded? (#49: the library is a CONSTRAINT, exact library:symbol match only.)+    if !symbol_name.is_empty() && !rescan {+        for w in windows_of_kind(WindowKind::SymbolEditor) {+            if wm::title_confirms_symbol(&w.title, symbol_name, library_name) {+                let _ = native().show_no_activate(w.hwnd);+                let mut v = json!({"success": true, "hwnd": w.hwnd, "mechanism": "existing", "output": format!("Symbol '{symbol_name}' is already loaded in the Symbol Editor.")});+                merge(&mut v, wm::resolved_fields(&w.title, library_name, "Symbol Editor"));+                return v;+            }+        }+    }+    // rescan:true (0.9.192): the remedy for library_cache_stale. Close the editor so the+    // open below indexes a fresh one. Opt-in: it closes a window the user may be using.+    let mut rescanned = false;+    let mut close_via = "";+    if rescan {+        if let Some(h) = first_of_kind(WindowKind::SymbolEditor) {+            let (gone, how) = close_and_wait(h, "Close the Symbol Editor so its next open re-indexes the symbol libraries", Duration::from_secs(12));+            close_via = how;+            if gone {+                rescanned = true;+                pathways.push("rescan-closed-stale-editor".into());+            } else {+                pathways.push("rescan-close-failed".into());+            }+        }+    }+    let (frame, mechanism) = match open_editor_frame(info, spec, &budget, &mut pathways) {+        Ok(x) => x,+        Err(e) => return e,+    };+    if mechanism != "existing" {+        sleep_ms(1500); // let the frame settle+    }+    let _ = native().show_no_activate(frame);+    if symbol_name.is_empty() && library_name.is_empty() {+        return json!({"success": true, "hwnd": frame, "mechanism": mechanism, "pathwaysTried": pathways, "output": if mechanism == "existing" { "Symbol Editor is already open.".to_string() } else { format!("Symbol Editor opened successfully ({mechanism}).") }});+    }+    // Library-only browse mode (0.9.92): filter the tree to the library, no part load.+    if symbol_name.is_empty() {+        let (ok, trace, nav_mech) = navigate_tree(frame, &library_filter(library_name), Verify::Never);+        let mut v = library_browse_reply(ok, &title_of(frame), "Symbol Editor", library_name, "No symbol was requested, so none was loaded into the canvas.");+        merge(&mut v, json!({"hwnd": frame, "mechanism": nav_mech, "openedVia": mechanism, "navigation": trace, "via": "win32-library-browse"}));+        if ok {+            v["_hint"] = json!("Library browse mode: the tree is filtered to the library. To load a part into the canvas, call again with symbolName too.");+        }+        return v;+    }+    let filter = if library_name.is_empty() { symbol_name.to_string() } else { format!("{library_name} {symbol_name}") };+    let verify = Verify::Symbol { name: symbol_name, library: library_name };+    let mut attempts = 0;+    let mut last_trace = Value::Null;+    let mut last_mech = "post";+    for attempt in 1..=2 {+        attempts = attempt;+        let (ok, trace, nav_mech) = navigate_tree(frame, &filter, verify);+        last_trace = trace;+        last_mech = nav_mech;+        if ok {+            let title = title_of(frame);+            let mut v = json!({+                "success": true, "hwnd": frame, "mechanism": nav_mech, "openedVia": mechanism, "via": "win32-background", "attempts": attempt,+                "output": format!("Symbol '{symbol_name}' loaded (background {nav_mech} path{}): {title}", if attempt == 2 { ", 2nd attempt" } else { "" }),+                "navigation": last_trace, "pathwaysTried": pathways, "rescanned": rescanned,+                "editorCloseVia": if rescanned { json!(close_via) } else { Value::Null },+            });+            merge(&mut v, wm::resolved_fields(&title, library_name, "Symbol Editor"));+            return v;+        }+        if budget.out() {+            // Wiki #71: a navigation that ran against an editor that is up is not "starting".+            let title = title_of(frame);+            let mut v = json!({+                "success": false, "errorCode": "navigation_unconfirmed_within_budget", "retryable": true, "retryAfterMs": 3000,+                "elapsedSec": budget.elapsed_sec(), "editorOpened": true, "hwnd": frame, "rescanned": rescanned, "mechanism": nav_mech,+                "navigation": last_trace,+                "error": format!("The Symbol Editor is open and one background navigation ran, but the title did not confirm '{symbol_name}' before this call's time budget ended (title: {})", if title.is_empty() { "unknown".into() } else { title.clone() }),+                "_hint": "Re-call with the SAME args: the editor is warm now, so the whole call is the navigation alone (seconds). Read navigation.steps and navigation.treeCandidates: zero candidates means Enter reached no window (report it with the trace).",+            });+            merge(&mut v, wm::resolved_fields(&title, library_name, "Symbol Editor"));+            return v;+        }+    }+    // Two background attempts did not confirm. Is the symbol even in the editor's world?+    invalidate();+    if first_of_kind(WindowKind::SymbolEditor).is_none() {+        return json!({+            "success": false, "editorOpened": false, "symbolLoaded": false, "errorCode": "symbol_editor_not_open",+            "error": format!("No Symbol Editor is open, so '{symbol_name}' could not be loaded (it was open earlier in this call if rescan was requested)."),+            "_hint": "The editor is gone rather than confused: nothing to navigate. Call again without rescan to open a fresh one. If this followed rescan:true, the close succeeded and the reopen did not - that is a bridge bug worth reporting, not a library problem.",+        });+    }+    let title = title_of(frame);+    // Wiki #54, #71 (the 0.9.287 staleVeto, ported): an editor THIS call opened indexed the+    // libraries after the library file was last written, so its cache cannot be stale. Saying+    // library_cache_stale there sends the caller to rescan:true, which only closes and reopens+    // an already fresh editor and burns the budget again. The verdict is navigation_failed.+    let on_disk = symbol_on_disk(info, symbol_name);+    let editor_is_ours = mechanism != "existing";+    let stale = on_disk && !rescan && !editor_is_ours;+    if stale {+        let ctx = lib_ctx(info);+        let paths = libraries::adom_paths(&ctx);+        return json!({+            "success": false, "editorOpened": true, "symbolLoaded": false, "attempts": attempts, "hwnd": frame,+            "errorCode": "library_cache_stale", "mechanism": last_mech, "navigation": last_trace,+            "symbolInLibraryFile": true,+            "symLibTable": paths.as_ref().map(|(t, _)| norm(t)),+            "libraryFile": paths.as_ref().map(|(_, l)| norm(l)),+            "error": format!("'{symbol_name}' is in the Adom library file on disk, but two background navigations did not load it (title: {})", if title.is_empty() { "unknown".into() } else { title.clone() }),+            "_hint": "The verdict is only 'navigation failed while the symbol is in the Adom file'. Check the sym-lib-table row points at libraryFile (a OneDrive-redirected Documents folder is the classic split), then re-call with rescan:true to close and reopen the editor so it re-indexes; kicad_rescan_libraries force:true restarts KiCad when a reopen is not enough.",+        });+    }+    let mut v = json!({+        "success": false, "editorOpened": true, "symbolLoaded": false, "attempts": attempts, "hwnd": frame,+        "errorCode": "navigation_failed", "mechanism": last_mech, "navigation": last_trace, "pathwaysTried": pathways,+        "error": format!("Symbol Editor open but '{symbol_name}' didn't load in the background after two attempts (title: {})", if title.is_empty() { "unknown".into() } else { title.clone() }),+        "_hint": "Two background attempts did not confirm, so a third is unlikely to help - check that the symbol really exists in this library. The keystroke fallback (allowFocusSteal) is not in the native build; pass foreground:true with a foregroundReason to bring the editor to the user and let them load it.",+    });+    if on_disk && editor_is_ours {+        v["symbolInLibraryFile"] = json!(true);+        v["staleVeto"] = json!(format!("this call opened the Symbol Editor itself ({mechanism}), so it indexed the libraries after the library file was last written: the index cannot be stale, and rescan:true would close and reopen an already fresh editor"));+        v["_hint"] = json!("The symbol IS in the Adom library file and this call opened the editor itself, so the index is fresh: this is a navigation failure, not a stale cache, and rescan:true cannot help. Read navigation.steps and navigation.treeCandidates (zero candidates means Enter reached no window, worth reporting with the trace), then pass foreground:true with a foregroundReason to hand the editor to the user.");+    }+    merge(&mut v, wm::resolved_fields(&title, library_name, "Symbol Editor"));+    v+}++fn merge(into: &mut Value, extra: Value) {+    if let (Some(o), Some(e)) = (into.as_object_mut(), extra.as_object()) {+        for (k, v) in e {+            o.insert(k.clone(), v.clone());+        }+    }+}++// ── kicad_open_footprint_editor ───────────────────────────────────────────────++pub(crate) fn open_footprint_editor(state: &mut State, args: &Value) -> Value {+    let info = state.kicad_info();+    if !info.installed {+        return fail("not_installed", "KiCad not installed", "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.");+    }+    let spec = &FOOTPRINT_SPEC;+    let budget = Budget::new(args);+    let footprint_name = arg_any(args, &["footprintName", "footprint"]).unwrap_or("").to_string();+    let library_name = arg_any(args, &["libraryName", "library"]).unwrap_or("").to_string();+    let mut pathways: Vec<String> = Vec::new();+    invalidate();+    if !footprint_name.is_empty() {+        for w in windows_of_kind(WindowKind::FootprintEditor) {+            if wm::title_confirms_footprint(&w.title, &footprint_name, &library_name) {+                let _ = native().show_no_activate(w.hwnd);+                let mut v = json!({"success": true, "hwnd": w.hwnd, "mechanism": "existing", "output": format!("Footprint '{footprint_name}' is already loaded in the Footprint Editor.")});+                merge(&mut v, wm::resolved_fields(&w.title, &library_name, "Footprint Editor"));+                return v;+            }+        }+    }+    if args.get("allowCursorClick").and_then(Value::as_bool).unwrap_or(false) {+        pathways.push("icon_click_not_in_native_build".into());+    } else {+        pathways.push("icon_click_skipped_background_mode".into());+    }+    let (frame, mechanism) = match open_editor_frame(&info, spec, &budget, &mut pathways) {+        Ok(x) => x,+        Err(mut e) => {+            if e["errorCode"] == json!(spec.error_code) {+                e["error"] = json!("Could not open the Footprint Editor via the background path.");+                e["_hint"] = json!("The background rungs (the host frame's native menu, then UI Automation) fired without a Footprint Editor appearing. Open a board first (kicad_open_board) so a PCB Editor hosts the launcher, check kicad_screenshot_all for a modal dialog, then retry. The mouse-moving launcher click (allowCursorClick) is not in the native build.");+            }+            return e;+        }+    };+    if mechanism != "existing" {+        sleep_ms(1500);+    }+    let _ = native().show_no_activate(frame);+    if footprint_name.is_empty() && library_name.is_empty() {+        return json!({"success": true, "hwnd": frame, "mechanism": mechanism, "pathwaysTried": pathways, "output": if mechanism == "existing" { "Footprint Editor is already open.".to_string() } else { format!("Footprint Editor opened successfully ({mechanism}).") }});+    }+    if footprint_name.is_empty() {+        let (ok, trace, nav_mech) = navigate_tree(frame, &library_filter(&library_name), Verify::Never);+        let mut v = library_browse_reply(ok, &title_of(frame), "Footprint Editor", &library_name, "No footprint was requested, so none was loaded into the canvas.");+        merge(&mut v, json!({"hwnd": frame, "mechanism": nav_mech, "openedVia": mechanism, "navigation": trace, "via": "win32-library-browse"}));+        if ok {+            v["_hint"] = json!("Library browse mode: the tree is filtered to the library. To load a part, call again with footprintName too.");+        }+        return v;+    }+    let filter = if library_name.is_empty() { footprint_name.clone() } else { format!("{library_name} {footprint_name}") };+    let verify = Verify::Footprint { name: &footprint_name, library: &library_name };+    let mut last_trace = Value::Null;+    let mut last_mech = "post";+    for attempt in 1..=2 {+        let (ok, trace, nav_mech) = navigate_tree(frame, &filter, verify);+        last_trace = trace;+        last_mech = nav_mech;+        if ok {+            let title = title_of(frame);+            let mut v = json!({+                "success": true, "hwnd": frame, "mechanism": nav_mech, "openedVia": mechanism, "via": "win32-background", "attempts": attempt,+                "output": format!("Footprint '{footprint_name}' loaded (background {nav_mech} path{}): {title}", if attempt == 2 { ", 2nd attempt" } else { "" }),+                "navigation": last_trace, "pathwaysTried": pathways,+            });+            merge(&mut v, wm::resolved_fields(&title, &library_name, "Footprint Editor"));+            return v;+        }+        if budget.out() {+            return json!({+                "success": false, "errorCode": "navigation_unconfirmed_within_budget", "retryable": true, "retryAfterMs": 3000,+                "elapsedSec": budget.elapsed_sec(), "editorOpened": true, "hwnd": frame, "mechanism": nav_mech, "navigation": last_trace,+                "error": format!("The Footprint Editor is open and one background navigation ran, but the title did not confirm '{footprint_name}' before this call's time budget ended (title: {})", title_of(frame)),+                "_hint": "Re-call with the SAME args: the editor is warm now, so the whole call is the navigation alone (seconds).",+            });+        }+    }+    let title = title_of(frame);+    let mut v = json!({+        "success": false, "editorOpened": true, "footprintLoaded": false, "attempts": 2, "hwnd": frame,+        "errorCode": "navigation_failed", "mechanism": last_mech, "navigation": last_trace, "pathwaysTried": pathways,+        "error": format!("Footprint Editor open but '{footprint_name}' didn't load in the background after two attempts (title: {})", if title.is_empty() { "unknown".into() } else { title.clone() }),+        "_hint": "Two background attempts did not confirm - check that the footprint really exists in this library (kicad_list_footprints). If it was installed after the editor opened, KiCad's library index predates it: kicad_close_footprint_editor and re-call, or kicad_rescan_libraries force:true. The keystroke fallback (allowFocusSteal) is not in the native build.",+    });+    // Wiki #111: tell the three causes apart from the DISK, which the bridge can read and the running+    // KiCad may not have: no such library row, no such footprint in the library, or both are there and+    // this KiCad simply started before the library was registered.+    if !library_name.is_empty() {+        let ctx = lib_ctx(&state.kicad_info());+        let (libs, _) = libraries::list_footprints(&ctx, Some(&library_name));+        match libs.first() {+            None => {+                v["errorCode"] = json!("library_not_registered");+                v["error"] = json!(format!("No footprint library named '{library_name}' in the fp-lib-table of KiCad {} (or its folder is missing), so the Footprint Editor cannot load '{footprint_name}'", ctx.version));+                v["_hint"] = json!("kicad_list_footprints lists the registered libraries (names are case-sensitive in KiCad). Register one with kicad_install_library {libraryPath, libraryType:\"footprint\"}.");+            }+            Some((_, info)) if !info.footprints.iter().any(|f| f.eq_ignore_ascii_case(&footprint_name)) => {+                v["errorCode"] = json!("footprint_not_in_library");+                v["error"] = json!(format!("Library '{library_name}' is registered ({}), but it holds no footprint named '{footprint_name}'", info.path));+                v["availableFootprints"] = json!(info.footprints.iter().take(40).collect::<Vec<_>>());+                v["_hint"] = json!("Pick a name from availableFootprints (kicad_list_footprints {library} has the full list).");+            }+            // Only a row in the user's OWN table can have been added after KiCad started. A stock+            // library (reached through KiCad's nested template table) is loaded by every KiCad at+            // start, so for those the failure is the navigation itself: keep navigation_failed+            // (1.0.37 blamed Resistor_SMD on "registered after launch", AdomLapper 2026-09-21).+            Some(_) if !user_table_row(&ctx, &library_name) => {}+            Some((_, info)) => {+                v["errorCode"] = json!("library_not_loaded_by_running_kicad");+                v["libraryOnDisk"] = json!(info.path);+                v["error"] = json!(format!("'{library_name}:{footprint_name}' exists on disk and the library is in fp-lib-table, but the running KiCad did not load it (title: {}). KiCad reads its library tables when it starts, so a library registered after launch is unknown to it", if title.is_empty() { "unknown" } else { title.as_str() }));+                v["_hint"] = json!(format!("kicad_rescan_libraries {{\"footprints\":[\"{library_name}:{footprint_name}\"],\"force\":true,\"confirmRestart\":true}} restarts KiCad so it re-reads the tables (windows go back where they were; ask the user first if they have unsaved work), then call this verb again. Retrying without that will fail the same way."));+            }+        }+    }+    merge(&mut v, wm::resolved_fields(&title, &library_name, "Footprint Editor"));+    v+}++// ── kicad_open_3d_viewer ──────────────────────────────────────────────────────++static VIEWER_SOURCE_BINDINGS: Mutex<BTreeMap<u64,(u32,u64)>> = Mutex::new(BTreeMap::new());+fn viewer_for_frame(frame:u64, source_pid:u32, windows:Vec<Win>, before:&[u64], editor_count:usize, known:Option<u64>)->Option<Win>{+    let eligible:Vec<_>=windows.into_iter().filter(|w|source_pid!=0 && w.pid==source_pid && (w.owner==0 || w.owner==frame)).collect();+    let exact:Vec<_>=eligible.iter().filter(|w|w.owner==frame).cloned().collect();+    if exact.len()==1{return exact.into_iter().next()}+    if exact.len()>1{return None}+    if let Some(id)=known {if let Some(w)=eligible.iter().find(|w|w.hwnd==id){return Some(w.clone())}}+    let created:Vec<_>=eligible.iter().filter(|w|!before.contains(&w.hwnd)).cloned().collect();+    if created.len()==1{return created.into_iter().next()}+    if editor_count==1 && eligible.len()==1{return eligible.into_iter().next()}+    None+}+fn poll_3d_window(frame:u64,source_pid:u32,before:&[u64],rounds:usize)->Option<Win>{+    for _ in 0..rounds.max(1){+        sleep_ms(750);invalidate();+        let editor_count=kicad_windows().iter().filter(|w|w.pid==source_pid && matches!(w.kind(),WindowKind::PcbEditor|WindowKind::FootprintEditor)).count();+        let known=VIEWER_SOURCE_BINDINGS.lock().ok().and_then(|m|m.get(&frame).filter(|(pid,_)|*pid==source_pid).map(|(_,h)|*h));+        if let Some(w)=viewer_for_frame(frame,source_pid,windows_of_kind(WindowKind::Viewer3d),before,editor_count,known){+            if let Ok(mut map)=VIEWER_SOURCE_BINDINGS.lock(){map.insert(frame,(source_pid,w.hwnd));}+            return Some(w)+        }+    }+    None+}++/// The rungs for one source frame: native menu walk, then UIA Invoke. Ok(Some) when a+/// 3D Viewer window appeared.+fn open_3d_from(frame: u64, label: &str, pathways: &mut Vec<String>) -> Result<Option<Value>, Value> {+    let source_pid = match kicad_windows().into_iter().find(|w| w.hwnd == frame) {+        Some(w) => w.pid,+        None => return Err(fail("source_editor_missing", "The requested source editor is no longer open.", "Call kicad_open_editors and pass the current pcbHwnd or fpHwnd.")),+    };+    let before:Vec<u64>=windows_of_kind(WindowKind::Viewer3d).iter().map(|w|w.hwnd).collect();+    let mut unsupported_menu: Option<Value> = None;+    match menu_invoke_by_text(frame, "3d viewer") {+        Ok(Some((id, _))) => {+            pathways.push(format!("win32menu(id={id})"));+            if let Some(w) = poll_3d_window(frame, source_pid, &before, 12) {+                return Ok(Some(json!({"success": true, "pathway": "win32menu", "mechanism": "menu", "hwnd": w.hwnd, "sourceHwnd": frame, "sourcePid": source_pid, "viewerPid": w.pid, "title": w.title, "output": format!("3D Viewer opened from {label} (Win32 menu): {}", w.title), "pathwaysTried": pathways})));+            }+        }+        Ok(None) => pathways.push("win32menu-not-found".into()),+        Err(e) if is_not_impl(&e) => unsupported_menu = Some(unsupported("native menu walk (menu_tree)")),+        Err(e) => pathways.push(format!("win32menu-{}", e.chars().take(40).collect::<String>())),+    }+    match native().uia_invoke(frame, "3D Viewer") {+        Ok(_) => {+            pathways.push("uia".into());+            if let Some(w) = poll_3d_window(frame, source_pid, &before, 12) {+                return Ok(Some(json!({"success": true, "pathway": "uia", "mechanism": "uia", "hwnd": w.hwnd, "sourceHwnd": frame, "sourcePid": source_pid, "viewerPid": w.pid, "title": w.title, "output": format!("3D Viewer opened from {label} via UIA: {}", w.title), "pathwaysTried": pathways})));+            }+        }+        Err(e) if is_not_impl(&e) => {+            // Neither rung exists on this OS: say so instead of "did not open".+            if let Some(u) = unsupported_menu {+                return Err(u);+            }+            pathways.push("uia-unsupported".into());+        }+        Err(e) => pathways.push(format!("uia-{}", e.chars().take(40).collect::<String>())),+    }+    Ok(None)+}+++/// `kicad_3d_view`: the 3D Viewer's View-menu commands by name, repeated with pauses, or a tour.++/// `kicad_menu_items`: the native menu bar of a KiCad window, read-only.+fn is_view_refresh(path: &[String]) -> bool {+    let clean = |s: &str| {+        s.replace('&', "")+            .split('\t')+            .next()+            .unwrap_or("")+            .trim()+            .to_lowercase()+    };+    path.len() == 2 && clean(&path[0]) == "view" && clean(&path[1]) == "refresh"+}++pub(crate) fn refresh_board_views(args: &Value) -> Value {+    let Some(frame) = arg_any_u64(args, &["pcbHwnd"]) else {+        return fail(+            "source_editor_required",+            "Pass the exact pcbHwnd.",+            "Read kicad_open_editors first.",+        );+    };+    invalidate();+    let windows = kicad_windows();+    let Some(source) = windows+        .iter()+        .find(|w| w.hwnd == frame && w.kind() == WindowKind::PcbEditor)+    else {+        return fail(+            "source_editor_missing",+            "Requested PCB editor is not open.",+            "No substitute was used.",+        );+    };+    let viewers = windows_of_kind(WindowKind::Viewer3d);+    let before: Vec<_> = viewers.iter().map(|w| w.hwnd).collect();+    let count = windows+        .iter()+        .filter(|w| {+            w.pid == source.pid+                && matches!(+                    w.kind(),+                    WindowKind::PcbEditor | WindowKind::FootprintEditor+                )+        })+        .count();+    let known = VIEWER_SOURCE_BINDINGS.lock().ok().and_then(|m| {+        m.get(&frame)+            .filter(|(pid, _)| *pid == source.pid)+            .map(|(_, h)| *h)+    });+    let Some(viewer) = viewer_for_frame(frame, source.pid, viewers, &before, count, known) else {+        return fail(+            "viewer_binding_ambiguous",+            "No uniquely linked open 3D viewer.",+            "Open the viewer from this exact PCB editor first; nothing was refreshed.",+        );+    };+    let mut commands = Vec::new();+    for hwnd in [frame, viewer.hwnd] {+        let items = match native().menu_tree(hwnd) {+            Ok(items) => items,+            Err(e) => return fail("menu_walk_failed", &e, "No refresh was invoked."),+        };+        let matches: Vec<_> = items+            .iter()+            .filter(|m| m.id != 0 && is_view_refresh(&m.path))+            .collect();+        if matches.len() != 1 {+            return fail(+                "refresh_action_missing",+                "Exact View > Refresh menu not uniquely available.",+                "No fallback menu or keyboard action was invoked.",+            );+        }+        commands.push((hwnd, matches[0].id));+    }+    let mut invoked = Vec::new();+    for (hwnd, id) in commands {+        if let Err(e) = native().menu_invoke(hwnd, id) {+            return json!({"success":false,"errorCode":"refresh_invoke_failed","error":e,"refreshRequestedFor":invoked,"sourceHwnd":frame,"viewerHwnd":viewer.hwnd});+        }+        invoked.push(hwnd);+    }+    invalidate();+    json!({"success":true,"refreshRequested":true,"sourceHwnd":frame,"sourcePid":source.pid,"viewerHwnd":viewer.hwnd,"viewerPid":viewer.pid,"renderVerified":false,"_hint":"Both refresh commands were requested without foreground or cursor actions. Inspect kicad_state screenshots after rebuilding; command acknowledgement is not render completion."})+}++pub(crate) fn menu_items(args: &Value) -> Value {+    let hwnd = match arg_any_u64(args, &["hwnd"]).or_else(|| foreground_kicad_hwnd()) {+        Some(h) => h,+        None => return fail("no_window", "No KiCad window found.", "kicad_window_info lists the windows; pass hwnd."),+    };+    let needle = arg_str(args, "contains").map(|s| s.to_lowercase()).unwrap_or_default();+    match native().menu_tree(hwnd) {+        Ok(items) => {+            let list: Vec<Value> = items.iter().filter(|it| needle.is_empty() || it.path.iter().any(|p| p.replace('&', "").to_lowercase().contains(&needle))).map(|it| json!({"path": it.path.iter().map(|p| p.replace('&', "")).collect::<Vec<_>>(), "id": it.id, "accel": it.accel})).collect();+            json!({"success": true, "hwnd": hwnd, "title": title_of(hwnd), "count": list.len(), "items": list})+        }+        Err(e) if is_not_impl(&e) => unsupported("native menu walk (menu_tree)"),+        Err(e) => fail("menu_walk_failed", &e, "Retry once the window is idle."),+    }+}++pub(crate) fn view_3d(args: &Value) -> Value {+    const ACTIONS: &[(&str, &str)] = &[+        ("pan_left", "Move Board Left"), ("pan_right", "Move Board Right"),+        ("pan_up", "Move Board Up"), ("pan_down", "Move Board Down"),+        ("zoom_in", "Zoom In"), ("zoom_out", "Zoom Out"), ("fit", "Zoom to Fit"),+        ("rotate_x_cw", "Rotate X Clockwise"), ("rotate_x_ccw", "Rotate X Counterclockwise"),+        ("rotate_y_cw", "Rotate Y Clockwise"), ("rotate_y_ccw", "Rotate Y Counterclockwise"),+        ("rotate_z_cw", "Rotate Z Clockwise"), ("rotate_z_ccw", "Rotate Z Counterclockwise"),+        ("view_top", "View Top"), ("view_bottom", "View Bottom"), ("view_front", "View Front"), ("view_back", "View Back"),+        ("view_left", "View Left"), ("view_right", "View Right"), ("flip", "Flip Board"),+    ];+    let action = arg_str(args, "action").unwrap_or("").trim().to_lowercase();+    if action == "actions" || action.is_empty() {+        return json!({"success": true, "actions": ACTIONS.iter().map(|(a, l)| json!({"action": a, "menuItem": l})).collect::<Vec<_>>(), "tour": "fit, view_top, rotate_x_ccw x2, rotate_z_cw x8, zoom_in x3, rotate_z_ccw x4, zoom_out x3, view_front, rotate_z_cw x4, view_bottom, flip, view_top, fit", "_hint": "Pass action (and count / pauseMs), or action:\"tour\" with an optional steps list of [action, count] pairs."});+    }+    invalidate();+    let hwnd = match arg_any_u64(args, &["hwnd"]).or_else(|| first_of_kind(WindowKind::Viewer3d)) {+        Some(h) => h,+        None => return fail("viewer_not_open", "No 3D Viewer window is open.", "kicad_open_3d_viewer {editor:\"pcb\"} opens it from the PCB Editor's View menu; it takes a few seconds to render."),+    };+    let pause = args.get("pauseMs").and_then(Value::as_u64).unwrap_or(700).clamp(0, 10_000);+    let count = args.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 60) as usize;+    let steps: Vec<(String, usize)> = if action == "tour" {+        match args.get("steps").and_then(Value::as_array) {+            Some(a) => a.iter().filter_map(|e| { let a = e.as_array()?; Some((a.first()?.as_str()?.to_lowercase(), a.get(1).and_then(Value::as_u64).unwrap_or(1).clamp(1, 60) as usize)) }).collect(),+            None => vec![("fit", 1), ("view_top", 1), ("rotate_x_ccw", 2), ("rotate_z_cw", 8), ("zoom_in", 3), ("rotate_z_ccw", 4), ("zoom_out", 3), ("view_front", 1), ("rotate_z_cw", 4), ("view_bottom", 1), ("flip", 1), ("view_top", 1), ("fit", 1)].into_iter().map(|(a, n)| (a.to_string(), n)).collect(),+        }+    } else {+        vec![(action.clone(), count)]+    };+    let mut done: Vec<Value> = Vec::new();+    let started = Instant::now();+    for (act, n) in &steps {+        let Some((_, label)) = ACTIONS.iter().find(|(a, _)| *a == act.as_str()) else {+            return fail("bad_action", format!("Unknown action '{act}'."), "kicad_3d_view {action:\"actions\"} lists them.");+        };+        for _ in 0..*n {+            match menu_invoke_by_text(hwnd, label) {+                Ok(Some((id, _))) => { done.push(json!({"action": act, "cmdId": id})); }+                Ok(None) => return fail("menu_item_not_found", format!("The 3D Viewer has no menu item '{label}' (KiCad version?)."), "kicad_send_key {hwnd, menu:...} with a substring finds what this build calls it."),+                Err(e) => return fail("menu_invoke_failed", &e, "Retry once the viewer has finished rendering."),+            }+            std::thread::sleep(Duration::from_millis(pause));+        }+    }+    json!({"success": true, "hwnd": hwnd, "commands": done.len(), "steps": steps.iter().map(|(a, n)| json!([a, n])).collect::<Vec<_>>(), "seconds": (started.elapsed().as_secs_f64() * 10.0).round() / 10.0, "foreground": false,+        "_hint": "Every command was posted to the viewer's own menu in the background. kicad_screenshot_all shows the view; a window recording of the viewer has the motion."})+}++pub(crate) fn open_3d_viewer(args: &Value) -> Value {+    let choice = arg_str(args, "editor").unwrap_or("auto").to_lowercase();+    if !["auto", "fp", "footprint", "pcb", "board"].contains(&choice.as_str()) {+        return fail("bad_arg", format!("Unknown editor '{choice}'. Use 'auto', 'fp', or 'pcb'."), "editor selects which open editor's View menu opens the viewer.");+    }+    invalidate();+    let mut fp = first_of_kind(WindowKind::FootprintEditor);+    let mut pcb = first_of_kind(WindowKind::PcbEditor);+    // Explicit source editors (tour, 2026-09-03): a caller that knows its editor's hwnd+    // passes pcbHwnd / fpHwnd and we never guess among several PCB Editors.+    if let Some(h) = arg_any_u64(args, &["pcbHwnd", "pcb_hwnd"]) {+        pcb = Some(h);+    }+    if let Some(h) = arg_any_u64(args, &["fpHwnd", "fp_hwnd"]) {+        fp = Some(h);+    }+    match choice.as_str() {+        "fp" | "footprint" => {+            if fp.is_none() {+                return fail("footprint_editor_not_open", "Footprint Editor is not open.", "Call open_footprint_editor first or pass editor='pcb'.");+            }+            pcb = None;+        }+        "pcb" | "board" => {+            if pcb.is_none() {+                return fail("pcb_editor_not_open", "PCB Editor is not open.", "Call open_board first or pass editor='fp'.");+            }+            fp = None;+        }+        _ => {+            if pcb.is_some() && fp.is_some() {+                fp = None; // auto: prefer PCB when both are open (tour-like flow)+            }+        }+    }+    let mut pathways: Vec<String> = Vec::new();+    if let Some(f) = fp {+        match open_3d_from(f, "Footprint Editor", &mut pathways) {+            Ok(Some(v)) => return v,+            Err(e) => return e,+            Ok(None) => {}+        }+        if choice == "auto" && pcb.is_none() {+            pcb = first_of_kind(WindowKind::PcbEditor);+        }+        if pcb.is_none() {+            return json!({"success": false, "errorCode": "3d_viewer_did_not_open", "pathwaysTried": pathways, "error": "3D Viewer did not open from Footprint Editor.", "_hint": "Call open_footprint_editor with a footprintName first, OR pass editor='pcb' to target the board's 3D viewer instead."});+        }+    }+    if let Some(p) = pcb {+        match open_3d_from(p, "PCB Editor", &mut pathways) {+            Ok(Some(v)) => return v,+            Err(e) => return e,+            Ok(None) => {}+        }+        return json!({"success": false, "errorCode": "3d_viewer_did_not_open", "pathwaysTried": pathways, "error": "3D Viewer did not open via the background path.", "_hint": "Call open_board {\"filePath\":\"...\"} to load a PCB in pcbnew first, then retry; kicad_screenshot_all shows whether a dialog is holding the editor."});+    }+    fail("no_source_editor", "Neither Footprint Editor nor PCB Editor is open. Open one first with open_footprint_editor or open_board.", "Call open_footprint_editor or open_board {\"filePath\":\"...\"} first, then retry.")+}++fn arg_any_u64(args: &Value, keys: &[&str]) -> Option<u64> {+    keys.iter().find_map(|k| args.get(*k)).and_then(|v| v.as_u64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))).filter(|h| *h != 0)+}++// ── kicad_open_editors ────────────────────────────────────────────────────────++fn open_editors() -> Value {+    let (wins, source) = match find_windows(false, true) {+        Ok(x) => x,+        Err(e) => return e,+    };+    let mut editors = Vec::new();+    let mut by_kind: BTreeMap<String, Vec<Value>> = BTreeMap::new();+    for w in &wins {+        let kind = w.kind();+        if !kind.is_editor() && kind != WindowKind::ProjectManager {+            continue;+        }+        let row = json!({"kind": kind.as_str(), "exeName": w.exe, "pid": w.pid, "frameClass": w.class_name, "frameTitle": w.title, "hwnd": w.hwnd, "isShown": !w.minimized});+        by_kind.entry(kind.as_str().to_string()).or_default().push(row.clone());+        editors.push(row);+    }+    let running = !wins.is_empty();+    let hint = if editors.is_empty() && !running {+        "0 editors and no KiCad window: KiCad is almost certainly NOT RUNNING. This verb only INVENTORIES editors inside a running KiCad (despite its name); it never launches anything. To cold-start KiCad use kicad_launch; kicad_open_board / kicad_open_symbol_editor also cold-launch when opening a specific target."+    } else {+        "Inventory from window enumeration (the native build has no in-process plugin): each KiCad top-level frame is classified by its title. kind is one of project_manager, schematic_editor, pcb_editor, symbol_editor, footprint_editor, 3d_viewer."+    };+    json!({+        "success": true, "editors": editors, "byKind": by_kind, "kicadRunning": running, "windowSource": source,+        "summary": {"totalEditors": editors.len(), "probedPlugins": 0, "failedPlugins": 0, "byKindCounts": by_kind.iter().map(|(k, v)| (k.clone(), json!(v.len()))).collect::<Map<String, Value>>()},+        "_hint": hint,+    })+}++// ── kicad_close* ──────────────────────────────────────────────────────────────++fn close_kind(kind: WindowKind, label: &str) -> Value {+    invalidate();+    let wins = match find_windows(false, true) {+        Ok((w, _)) => w.into_iter().filter(|w| w.kind() == kind).collect::<Vec<_>>(),+        Err(e) => return e,+    };+    if wins.is_empty() {+        return json!({"success": true, "output": format!("{label} is not open.")});+    }+    let mut closed = Vec::new();+    let mut failed = Vec::new();+    let mut hows = Vec::new();+    for w in &wins {+        let (gone, how) = close_and_wait(w.hwnd, &format!("Close the KiCad {label} the calling AI asked to close"), Duration::from_secs(5));+        hows.push(how);+        if gone { closed.push(w.title.clone()) } else { failed.push(w.title.clone()) }+    }+    if !failed.is_empty() {+        let prompt = find_save_prompt();+        let mut v = fail("close_failed", format!("Failed to close: {}", failed.join(", ")), "Call close_kicad with {\"force\": true} to force-kill the window, then retry.");+        if let Some(p) = prompt {+            v["errorCode"] = json!("unsaved_changes");+            v["unsavedChanges"] = json!([{"window": failed[0], "dialog": p.title, "body": p.body}]);+            v["_hint"] = json!("KiCad is asking whether to save changes. Ask the user, then save in KiCad or call kicad_close {\"discardChanges\": true} to press Discard on their behalf.");+        }+        return v;+    }+    json!({+        "success": true, "closeVia": hows,+        "output": if closed.len() == 1 { format!("{label} closed: {}", closed[0]) } else { format!("Closed {} {label} window(s).", closed.len()) },+    })+}++/// kicad_close {force:true}: the ONLY path that kills a process.+fn force_kill_kicad() -> Result<Vec<String>, Value> {+    let procs = plat("process enumeration (processes)", native().processes(wm::KILL_PROCESS_NAMES))?;+    let mut killed = Vec::new();+    for name in wm::KILL_PROCESS_NAMES {+        let mut any = false;+        for p in procs.iter().filter(|p| p.exe.eq_ignore_ascii_case(name) || p.exe.eq_ignore_ascii_case(name.trim_end_matches(".exe"))) {+            if native().kill_process(p.pid).is_ok() {+                any = true;+            }+        }+        if any {+            killed.push(name.to_string());+        }+    }+    invalidate();+    Ok(killed)+}++#[derive(Default)]+struct CloseState {+    discard: bool,+    closed: Vec<String>,+    failed: Vec<String>,+    unsaved: Vec<Value>,+    discard_log: Vec<Value>,+}++/// One window: WM_CLOSE and wait; on a save prompt press Discard (discardChanges) or+/// report unsavedChanges. Some(true) closed, Some(false) failed, None already recorded.+fn close_one(st: &mut CloseState, w: &Win) -> Option<bool> {+    let (gone, _) = close_and_wait(w.hwnd, "Close KiCad as the calling AI asked", Duration::from_secs(8));+    if gone {+        return Some(true);+    }+    let Some(prompt) = find_save_prompt() else { return Some(false) };+    if st.discard {+        let mut attempts = Vec::new();+        let mut pressed = match native().click_button(prompt.hwnd, wm::DISCARD_LABELS) {+            Ok(m) => { attempts.push(json!({"win32Button": true, "method": m})); true }+            Err(e) => { attempts.push(json!({"win32Button": false, "error": e.chars().take(120).collect::<String>()})); false }+        };+        if !pressed {+            for label in wm::DISCARD_LABELS_UIA {+                match native().uia_invoke(prompt.hwnd, label) {+                    Ok(m) => { attempts.push(json!({"label": label, "uia": m})); pressed = true; break; }+                    Err(e) => attempts.push(json!({"label": label, "uia": e.chars().take(120).collect::<String>()})),+                }+            }+        }+        st.discard_log.push(json!({"window": w.title, "dialog": prompt.title, "attempts": attempts}));+        if pressed {+            let deadline = Instant::now() + Duration::from_secs(8);+            while Instant::now() < deadline {+                sleep_ms(300);+                invalidate();+                if !window_alive(w.hwnd) {+                    st.closed.push(format!("{} (changes discarded)", w.title));+                    return None;+                }+            }+        }+    }+    st.unsaved.push(json!({"window": w.title, "dialog": prompt.title, "body": prompt.body}));+    Some(false)+}++fn close_kicad(_state: &mut State, args: &Value) -> Value {+    let force = args.get("force").and_then(Value::as_bool).unwrap_or(false);+    let discard = args.get("discardChanges").and_then(Value::as_bool).unwrap_or(false);+    // Wiki #48: a close is the last moment these windows exist, so learn where they are+    // before the geometry is gone. A kill in particular denies KiCad its own save.+    remember_placement();+    if force {+        return match force_kill_kicad() {+            Ok(k) if !k.is_empty() => json!({"success": true, "output": format!("Force-killed: {}", k.join(", ")), "data": {"forceKilled": k}}),+            Ok(_) => json!({"success": true, "output": "KiCad is not running (nothing to kill)."}),+            Err(e) => e,+        };+    }+    invalidate();+    let wins = match find_windows(false, true) {+        Ok((w, _)) => w,+        Err(e) => return e,+    };+    let mut editors = Vec::new();+    let mut manager: Option<Win> = None;+    for w in wins.into_iter().filter(|w| w.owner == 0) {+        if wm::is_manager_title(&w.title) {+            manager = Some(w);+        } else {+            editors.push(w);+        }+    }+    if editors.is_empty() && manager.is_none() {+        return json!({"success": true, "output": "KiCad is not running."});+    }+    // Issue #84: a dirty board answers WM_CLOSE with "Save changes?". Read it; with+    // discardChanges press Discard and stay graceful; otherwise leave the window open and+    // report unsavedChanges. Nothing is ever killed here.+    let mut st = CloseState::default();+    st.discard = discard;+    for w in &editors {+        match close_one(&mut st, w) {+            Some(true) => st.closed.push(w.title.clone()),+            Some(false) => st.failed.push(w.title.clone()),+            None => {}+        }+    }+    if let (Some(m), true) = (&manager, st.unsaved.is_empty()) {+        match close_one(&mut st, m) {+            Some(true) => st.closed.push(m.title.clone()),+            Some(false) => st.failed.push(m.title.clone()),+            None => {}+        }+    }+    let CloseState { closed, failed, unsaved, discard_log, .. } = st;+    if !unsaved.is_empty() {+        return json!({+            "success": false, "errorCode": "unsaved_changes", "unsavedChanges": unsaved,+            "error": format!("KiCad is asking whether to save changes; nothing was killed: {}", unsaved.iter().map(|u| format!("{}: {}", u["window"].as_str().unwrap_or(""), u["body"].as_str().unwrap_or("").chars().take(80).collect::<String>())).collect::<Vec<_>>().join("; ")),+            "output": if closed.is_empty() { String::new() } else { format!("Closed: {}", closed.join(", ")) },+            "_hint": "The user's unsaved work is still open. Ask them, then either save in KiCad (Ctrl+S via desktop_press_key) and call kicad_close again, or call kicad_close {\"discardChanges\": true} to press Discard on their behalf. force:true kills the process, which discards silently.",+            "discard": discard_log,+        });+    }+    if !failed.is_empty() {+        // No save prompt and no close: the native build never kills here (force:true is+        // the only kill path); say what did not close and how to force it.+        // Wiki #109 ask 3: a dirty Symbol Editor refused to go and the reply named no cause.+        // Report every dialog that is up (the unrecognised save prompt is among them) and+        // the editor's own close verb.+        let seen: Vec<Value> = scan_dialogs().iter().map(|d| json!({"hwnd": d.hwnd, "title": d.title, "body": d.body.chars().take(240).collect::<String>()})).collect();+        let editor_verbs: Vec<&str> = [("symbol editor", "kicad_close_symbol_editor"), ("footprint editor", "kicad_close_footprint_editor"), ("3d viewer", "kicad_close_3d_viewer")]+            .iter().filter(|(word, _)| failed.iter().any(|t| t.to_lowercase().contains(word))).map(|(_, verb)| *verb).collect();+        return json!({+            "success": false, "errorCode": "close_failed", "dialogsSeen": seen, "editorCloseVerbs": editor_verbs,+            "error": format!("Failed to close: {}", failed.join(", ")),+            "output": if closed.is_empty() { String::new() } else { format!("Closed: {}", closed.join(", ")) },+            "_hint": "A title starting with * has unsaved work: its editor is waiting on a prompt. dialogsSeen lists every dialog that is up right now; answer it (kicad_dismiss_dialogs, or discardChanges:true to press Discard) or close that editor with the verb in editorCloseVerbs, then call kicad_close again. force:true kills the process and discards unsaved work silently. kicad_screenshot_all shows the windows.",+        });+    }+    let mut out = json!({"success": true, "output": format!("KiCad closed ({} window{}).", closed.len(), if closed.len() != 1 { "s" } else { "" }), "data": {}});+    if !discard_log.is_empty() {+        out["discard"] = json!(discard_log);+    }+    out+}++// ── kicad_dismiss_dialogs / kicad_window_info ─────────────────────────────────++fn dismiss_dialogs(state: &mut State, args: &Value) -> Value {+    let info = state.kicad_info();+    if args.get("forceSoftwareCanvas").and_then(Value::as_bool).unwrap_or(false) {+        let changed = force_software_canvas(&info.version);+        return json!({+            "success": true, "forcedSoftwareCanvas": changed,+            "output": if changed { "forced Cairo software canvas (canvas_type=2)" } else { "software canvas already set / no config" },+            "_hint": "Close and reopen KiCad editors; they will render via Cairo (no OpenGL) so no GPU is needed.",+        });+    }+    if let Err(e) = find_windows(true, true) {+        return e;+    }+    if args.get("debug").and_then(Value::as_bool).unwrap_or(false) {+        let wins = scan_dialogs();+        let pids: Vec<u32> = native().processes(wm::KICAD_EXES).map(|p| p.iter().map(|p| p.pid).collect()).unwrap_or_default();+        return json!({+            "success": true, "debug": true, "kicadPids": pids,+            "windows": wins.iter().map(|w| json!({"title": w.title, "className": w.class_name, "owner": w.owner, "isDialog": true, "body": w.body.chars().take(80).collect::<String>(), "viaGlobalSweep": w.via_global_sweep})).collect::<Vec<_>>(),+            "kicadInfoHasBinDir": info.primary().map(|i| !i.bin_dir.is_empty()).unwrap_or(false),+        });+    }+    let all = args.get("all").and_then(Value::as_bool).unwrap_or(false);+    let screenshot = args.get("screenshot").and_then(Value::as_bool).unwrap_or(true);+    let res = sweep_dialogs(&info, all, screenshot);+    let n = res["dismissed"].as_array().map(|a| a.len()).unwrap_or(0);+    let remaining = res["remaining"].as_array().map(|a| a.len()).unwrap_or(0);+    let forced = res["forcedSoftwareCanvas"].as_bool().unwrap_or(false);+    json!({+        "success": true,+        "output": format!("dismissed {n} dialog(s), {remaining} remaining{}", if forced { "; forced Cairo software canvas (no GPU); close+reopen editors" } else { "" }),+        "dismissed": res["dismissed"], "remaining": res["remaining"], "forcedSoftwareCanvas": forced,+        "_hint": format!("Expired {n} dialog(s). Now retry your open/screenshot verb. Benign-only by default; pass {{\"all\":true}} to clear every dialog. The OpenGL notice on a GPU-less host is auto-expired by window_info too."),+    })+}++fn window_info(state: &mut State, args: &Value) -> Value {+    let info = state.kicad_info();+    if let Err(e) = find_windows(true, true) {+        return e;+    }+    let mut auto_dismissed = json!([]);+    let mut forced_sw = false;+    if args.get("autoDismiss").and_then(Value::as_bool).unwrap_or(true) {+        let s = sweep_dialogs(&info, false, false);+        auto_dismissed = s["dismissed"].clone();+        forced_sw = s["forcedSoftwareCanvas"].as_bool().unwrap_or(false);+    }+    invalidate();+    let (wins, source) = match find_windows(false, true) {+        Ok(x) => x,+        Err(e) => return e,+    };+    let mut editors = Vec::new();+    let mut dialogs = Vec::new();+    let mut manager = Value::Null;+    for w in &wins {+        let base = json!({"hwnd": w.hwnd, "title": w.title, "className": w.class_name, "rect": w.rect_json(), "z": w.z, "minimized": w.minimized, "maximized": w.maximized, "hung": w.hung, "kind": w.kind().as_str()});+        if w.owner != 0 || w.class_name == wm::DIALOG_CLASS {+            let mut d = base;+            d["ownerHwnd"] = json!(w.owner);+            d["body"] = json!(dialog_body_text(w.hwnd));+            dialogs.push(d);+        } else if wm::is_manager_title(&w.title) {+            manager = base;+        } else {+            editors.push(base);+        }+    }+    let mut data = json!({+        "projectManager": manager, "editors": editors, "modalDialogs": dialogs, "hasModalDialogs": !dialogs.is_empty(),+        "autoDismissed": auto_dismissed, "windowSource": source,+    });+    if forced_sw {+        data["forcedSoftwareCanvas"] = json!(true);+    }+    let n_auto = data["autoDismissed"].as_array().map(|a| a.len()).unwrap_or(0);+    if n_auto > 0 {+        data["_autoDismissHint"] = json!(format!("Auto-expired {n_auto} benign blocking dialog(s) (e.g. the OpenGL/software-rendering notice) so editors are not stalled; no action needed.{}", if forced_sw { " This host lacks OpenGL, so KiCad was switched to the Cairo software canvas (kicad_common.json canvas_type=2): CLOSE and REOPEN the editor for it to render without a GPU." } else { "" }));+    }+    if !dialogs.is_empty() {+        data["_dialogHint"] = json!("hasModalDialogs is true: KiCad has a BLOCKING modal dialog. Read each modalDialogs[].body for the message text; if body is empty, screenshot modalDialogs[].hwnd via desktop_screenshot_window. Dismiss with kicad_send_key {hwnd, key:'return'} (OK/default) or 'escape' (cancel).");+    }+    json!({"success": true, "output": format!("{} editor(s), {} dialog(s)", editors.len(), dialogs.len()), "data": data})+}++// ── Screenshots (ab) and the canvas probe (local) ─────────────────────────────++/// Uniformity verdict for the editor canvas: one local capture, sampled, discarded.+fn canvas_probe(hwnd: u64) -> Value {+    let Ok(cap) = native().capture_window(hwnd) else { return Value::Null };+    let rect = native().gl_canvas_rect(hwnd).ok().flatten().map(|(x, y, w, h)| (x, y, x + w, y + h));+    wm::canvas_uniformity(&cap.rgb, cap.width, cap.height, rect)+}++/// Capture a window by hwnd: ab takes the picture (owned popups, coordMap, auto-pull);+/// the canvas verdict is always ours.+pub(crate) fn screenshot_hwnd(hwnd: u64, label: &str, resize_max: Option<u32>) -> Value {+    let mut v = match ab::desktop_screenshot_window(hwnd, &format!("Capture the KiCad window {label} as evidence for the calling AI"), resize_max) {+        Ok(shot) => shot.to_json(),+        Err(e) => json!({"success": false, "error": e.message(), "abUnavailable": e.is_unavailable(), "_hint": "The native build takes every screenshot through ab's desktop_screenshot_window; with ab unreachable no image is written. Only the canvas probe (canvas.rendered) answers locally."}),+    };+    v["canvas"] = canvas_probe(hwnd);+    v+}++fn screenshot_all(state: &mut State) -> Value {+    let info = window_info(state, &json!({}));+    if info["success"] != json!(true) {+        return info;+    }+    let data = &info["data"];+    let mut windows: Vec<(u64, String, &str)> = Vec::new();+    for ed in data["editors"].as_array().into_iter().flatten() {+        windows.push((ed["hwnd"].as_u64().unwrap_or(0), ed["title"].as_str().unwrap_or("").into(), "editor"));+    }+    if let Some(h) = data["projectManager"]["hwnd"].as_u64() {+        windows.push((h, data["projectManager"]["title"].as_str().unwrap_or("").into(), "main"));+    }+    for d in data["modalDialogs"].as_array().into_iter().flatten() {+        windows.push((d["hwnd"].as_u64().unwrap_or(0), d["title"].as_str().unwrap_or("").into(), "dialog"));+    }+    if windows.is_empty() {+        return fail("no_windows", "No KiCad windows found.", "kicad_launch or an open_* verb first; kicad_window_info lists what is up.");+    }+    let mut shots = Vec::new();+    let mut ab_unavailable = false;+    for (hwnd, title, kind) in &windows {+        let label = wm::safe_label(title, kind);+        let r = screenshot_hwnd(*hwnd, &label, None);+        let mut entry = json!({"type": kind, "title": title, "hwnd": hwnd, "canvas": r["canvas"]});+        if r["success"] == json!(true) {+            for k in ["savedTo", "fullPath", "safePath", "localFullPath", "sizeKB", "capturedBy", "shotId", "coordMap", "ownedPopupCount", "popups", "screenshots"] {+                if let Some(v) = r.get(k) {+                    entry[k] = v.clone();+                }+            }+        } else {+            entry["error"] = json!(r["error"].as_str().unwrap_or("screenshot failed"));+            if r["abUnavailable"] == json!(true) {+                ab_unavailable = true;+            }+        }+        shots.push(entry);+    }+    let captured = shots.iter().filter(|s| s.get("fullPath").is_some()).count();+    let mut out = json!({+        "success": captured > 0 || !ab_unavailable,+        "output": format!("Captured {captured} of {} KiCad window(s)", shots.len()),+        "data": {"screenshots": shots},+        "_hint": "Each entry is a background PrintWindow capture by ab with its owned popups (popups[]) and a coordMap: click what you see with desktop_click {space:\"image\", shotId, x, y}. canvas.rendered false means the GL canvas has not painted yet (or there is no usable GPU).",+    });+    if ab_unavailable {+        out["abUnavailable"] = json!(true);+        out["errorCode"] = json!("ab_unavailable");+        out["_hint"] = json!("ab's direct API is unreachable, so no image was written (the native build has no local PNG path). ADOM_DIRECT_API_URL missing from the spawn env is the usual cause; ab's own desktop_screenshot_window from the CLI still works.");+    }+    out+}++// ── kicad_state ───────────────────────────────────────────────────────────────++pub(crate) fn b64_encode(bytes: &[u8]) -> String {+    const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";+    let mut out = String::with_capacity((bytes.len() + 2) / 3 * 4);+    for chunk in bytes.chunks(3) {+        let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];+        let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;+        out.push(T[(n >> 18) as usize & 63] as char);+        out.push(T[(n >> 12) as usize & 63] as char);+        out.push(if chunk.len() > 1 { T[(n >> 6) as usize & 63] as char } else { '=' });+        out.push(if chunk.len() > 2 { T[n as usize & 63] as char } else { '=' });+    }+    out+}++fn kicad_state(state: &mut State, args: &Value) -> Value {+    let info = state.kicad_info();+    let (wins, source) = match find_windows(false, true) {+        Ok(x) => x,+        Err(e) => return e,+    };+    let include_images = args.get("includeImages").and_then(Value::as_bool).unwrap_or(true);+    let max_width = args.get("maxWidth").and_then(Value::as_u64).unwrap_or(1100) as u32;+    let mut shots = Vec::new();+    let mut ab_unavailable = false;+    if include_images {+        for w in &wins {+            let mut entry = json!({"title": w.title, "hwnd": w.hwnd, "className": w.class_name});+            let r = screenshot_hwnd(w.hwnd, "state", Some(max_width));+            entry["canvas"] = r["canvas"].clone();+            if r["success"] == json!(true) {+                let path = r["safePath"].as_str().filter(|p| !p.is_empty()).or_else(|| r["fullPath"].as_str()).unwrap_or("");+                match std::fs::read(path) {+                    Ok(bytes) if path.to_lowercase().ends_with(".png") => {+                        entry["image"] = json!(b64_encode(&bytes));+                        entry["path"] = json!(path);+                        if let Some(c) = r.get("coordMap") {+                            if let (Some(w), Some(h)) = (c["image"]["w"].as_u64(), c["image"]["h"].as_u64()) {+                                entry["width"] = json!(w);+                                entry["height"] = json!(h);+                            }+                        }+                        if let Some(s) = r.get("shotId") { entry["shotId"] = s.clone(); }+                        if let Some(p) = r.get("popups") { entry["popups"] = p.clone(); }+                    }+                    Ok(_) => { entry["error"] = json!("capture is not a png"); entry["path"] = json!(path); }+                    Err(e) => { entry["error"] = json!(format!("could not read {path}: {e}")); }+                }+            } else {+                entry["error"] = json!(r["error"].as_str().unwrap_or("capture failed"));+                if r["abUnavailable"] == json!(true) {+                    ab_unavailable = true;+                }+            }+            shots.push(entry);+        }+    }+    let editors = open_editors();+    let modal: Vec<Value> = scan_dialogs().into_iter().map(|d| json!({"hwnd": d.hwnd, "title": d.title, "body": d.body})).collect();+    let loop_events = native().etiquette_events();+    let mut events: Vec<Value> = FOCUS_EVENTS.lock().map(|g| g.iter().cloned().collect()).unwrap_or_default();+    events.extend(loop_events);+    let events: Vec<Value> = events.into_iter().rev().take(40).rev().collect();+    let focus_debug = match native().etiquette_debug() {+        Value::Null => json!({"guardian": "no etiquette loop on this OS; one-shot check per verb only", "oneShotCheck": true}),+        v => v,+    };+    let mut out = json!({+        "success": true, "running": !wins.is_empty(), "focusEvents": events,+        // PRESENCE GATE (John, 2026-08-17): automation that spawns windows checks this+        // first and defers while the user is active (< 600 = touched in the last 10 min).+        "userIdleSeconds": user_idle_seconds(),+        "focusDebug": focus_debug,+        "activeVersion": info.version,+        "windows": wins.iter().map(|w| json!({"hwnd": w.hwnd, "title": w.title, "className": w.class_name, "rect": w.rect_json(), "z": w.z, "kind": w.kind().as_str()})).collect::<Vec<_>>(),+        "windowSource": source,+        "shots": shots,+        "editors": {"kicadRunning": editors["kicadRunning"], "list": editors["editors"], "summary": editors["summary"]},+        "modalDialogs": modal,+        "_hint": "focusEvents is the focus log: a self-raise (pushed-to-background) entry is a KiCad window that took the foreground and was pushed back once; user-foregrounded means the caller asked with a reason. The full state of the union: every KiCad-owned window is in shots[] as inline base64 PNG (render them all; one window is NOT the app). modalDialogs non-empty means something is blocking; editors.list is the window inventory by kind. Poll before/after an action to confirm it landed. includeImages:false for a cheap logical poll.",+    });+    if ab_unavailable {+        out["abUnavailable"] = json!(true);+    }+    out+}++// ── kicad_send_key / kicad_click / kicad_fix_keyboard ─────────────────────────++fn foreground_kicad_hwnd() -> Option<u64> {+    if let Ok(fg) = native().foreground() {+        if fg != 0 && is_kicad_hwnd(fg) {+            return Some(fg);+        }+    }+    kicad_windows().first().map(|w| w.hwnd)+}++fn send_key(args: &Value) -> Value {+    // `menu`: a menu-item label (substring, case-insensitive) fired as WM_COMMAND on the+    // target window's menu bar, fully background. The way the PCM dialog is opened.+    if let Some(needle) = arg_str(args, "menu").map(str::trim).filter(|s| !s.is_empty()) {+        let target = match arg_any_u64(args, &["hwnd"]) {+            Some(h) => h,+            None => match foreground_kicad_hwnd() {+                Some(h) => h,+                None => return fail("no_window", "No KiCad window found to send the menu command to.", "kicad_window_info lists the windows; pass hwnd."),+            },+        };+        let title = title_of(target);+        return match menu_invoke_by_text(target, needle) {+            Ok(Some((id, label))) => json!({+                "success": true, "mechanism": "menu",+                "output": format!("Menu item '{label}' fired on '{title}' (background)"),+                "data": {"method": "menu-command", "cmdId": id, "menuItem": label, "hwnd": target, "title": title, "foreground": false},+                "_hint": "Resolved from the window's menu bar by label and fired with WM_COMMAND; no focus was touched. A dialog it opens is a child of this window: kicad_screenshot_all captures it.",+            }),+            Ok(None) => fail("menu_item_not_found", &format!("No menu item on '{title}' contains '{needle}'."), "kicad_window_info lists windows; the label match is a case-insensitive substring of the item text without '&'."),+            Err(e) if is_not_impl(&e) => unsupported("native menu walk (menu_tree)"),+            Err(e) => fail("menu_invoke_failed", &e, "Retry once the window is idle; pass hwnd explicitly."),+        };+    }+    let Some(key) = arg_str(args, "key").map(str::trim) else {+        return fail("missing_arg", "Missing required arg: key", "Pass key: a named key (enter/escape/tab/space/f1-f12), a single character, or a chord like 'ctrl+s'.");+    };+    let target = match arg_any_u64(args, &["hwnd"]) {+        Some(h) => h,+        None => match foreground_kicad_hwnd() {+            Some(h) => h,+            None => return fail("no_window", "No KiCad window found to send key to.", "kicad_window_info lists the windows; pass hwnd."),+        },+    };+    let title = title_of(target);+    // NEVER steal the user's screen: show without activating and post to THIS hwnd's queue.+    let _ = native().show_no_activate(target);+    sleep_ms(50);+    let allow_fg = args.get("allowForeground").and_then(Value::as_bool).unwrap_or(false);+    if let Some((_mods, _base)) = wm::parse_modifier_combo(key) {+        // A CHORD usually IS a menu accelerator (v0.9.106): resolve it from the window's+        // menu bar and fire WM_COMMAND, fully background.+        match menu_invoke_by_accel(target, key) {+            Ok(Some((id, label))) => {+                return json!({+                    "success": true, "mechanism": "menu",+                    "output": format!("Chord '{key}' delivered as menu command '{label}' (background)"),+                    "data": {"method": "menu-command", "cmdId": id, "menuItem": label, "hwnd": target, "title": title, "foreground": false},+                    "_hint": "Resolved via the window's menu-bar accelerator table and fired with WM_COMMAND; no focus was touched. This is the preferred path for every chord that maps to a menu item.",+                });+            }+            Err(e) if is_not_impl(&e) => return unsupported("native menu walk (menu_tree)"),+            _ => {}+        }+        if allow_fg {+            return unsupported("kicad_send_key allowForeground (real keystrokes via SendInput)");+        }+        return json!({+            "success": false, "errorCode": "foreground_required",+            "error": format!("send the key chord '{key}' to KiCad needs the foreground, which would take over the user's screen: no menu item carries this accelerator, so only real (focused) keystrokes can deliver it"),+            "_hint": "Use ab's desktop_press_key (it owns focus-first chord delivery and asks the user), or find the menu item and call it by name. The native bridge never sends global keystrokes.",+        });+    }+    if allow_fg {+        return unsupported("kicad_send_key allowForeground (real keystrokes via SendInput)");+    }+    let (vk, ch) = match wm::vk_for_key(key) {+        Some(vk) => (vk, None),+        None => {+            let mut chars = key.chars();+            match (chars.next(), chars.next()) {+                (Some(c), None) => (wm::vk_for_char(c).unwrap_or(0), Some(c)),+                _ => return fail("unknown_key", format!("Unknown key: '{key}'. Use a named key (enter/escape/tab/space/f1-f12), a single character, or a modifier combo like 'alt+3', 'ctrl+s', 'ctrl+shift+s' (modifiers: ctrl/control, alt/menu, shift, win/super)."), "Chords resolve through the menu bar; anything else needs desktop_press_key."),+            }+        }+    };+    match native().post_key(target, vk, ch) {+        Ok(()) => json!({+            "success": true, "mechanism": "post",+            "output": format!("Posted '{key}' to '{title}' (background)"),+            "data": {"hwnd": target, "title": title, "method": "postmessage", "foreground": false, "vk": vk},+            "_hint": "Delivered without touching the user's focus. If KiCad ignored it, the control may need real input: prefer a menu command by name; only as a last resort ask the user and use desktop_press_key.",+        }),+        Err(e) if is_not_impl(&e) => unsupported("posted keys (post_key)"),+        Err(e) => fail("post_failed", format!("Could not post '{key}' to the window: {e}"), "kicad_window_info shows whether the hwnd is still valid."),+    }+}++fn click(args: &Value) -> Value {+    let Some(hwnd) = arg_any_u64(args, &["hwnd"]) else {+        return fail("missing_arg", "Missing required arg: hwnd", "kicad_window_info lists the windows; pass hwnd.");+    };+    let (Some(x), Some(y)) = (args.get("x").and_then(Value::as_f64), args.get("y").and_then(Value::as_f64)) else {+        return fail("missing_arg", "Missing required args: x, y", "Pass x and y as fractions of the window (relative:true, default) or pixels (relative:false).");+    };+    let relative = args.get("relative").and_then(Value::as_bool).unwrap_or(true);+    if relative && !(0.0..=1.0).contains(&x) || relative && !(0.0..=1.0).contains(&y) {+        return json!({+            "success": false, "errorCode": "bad_arg",+            "error": format!("relative=True expects x/y as fractions 0.0-1.0 of the window, but got x={x}, y={y}. Pass fractions, or set relative=false to use pixels."),+            "_hint": "e.g. center = {\"x\":0.5,\"y\":0.5}; toolbar button ~ {\"x\":0.35,\"y\":0.09}. For exact pixel coords add \"relative\": false.",+        });+    }+    if args.get("allowForeground").and_then(Value::as_bool).unwrap_or(false) {+        return unsupported("kicad_click allowForeground (a real cursor click via SendInput)");+    }+    let info = match native().window_info(hwnd) {+        Ok(Some(i)) => i,+        Ok(None) => return fail("bad_hwnd", format!("HWND {hwnd} is not a valid window."), "kicad_window_info lists the current handles."),+        Err(e) => return plat::<()>("window lookup (window_info)", Err(e)).err().unwrap(),+    };+    let (w, h) = (info.rect.2, info.rect.3);+    let _ = native().show_no_activate(hwnd);+    sleep_ms(50);+    let (cx, cy) = if relative { ((x * w as f64) as i32, (y * h as f64) as i32) } else { (x as i32, y as i32) };+    match native().post_click(hwnd, cx, cy, false) {+        Ok(()) => json!({+            "success": true, "mechanism": "post",+            "output": format!("Posted click at ({cx},{cy}) in '{}' (background)", info.title),+            "data": {"hwnd": hwnd, "title": info.title, "client": [cx, cy], "screen": [info.rect.0 + cx, info.rect.1 + cy], "method": "postmessage", "foreground": false, "relative": relative},+            "_hint": "Clicked without moving the user's mouse or taking focus. If the canvas ignored it, that control may need real input: ab's desktop_click {space:\"image\", shotId, x, y} on a kicad_screenshot_all coordMap asks the user first.",+        }),+        Err(e) if is_not_impl(&e) => unsupported("posted clicks (post_click)"),+        Err(e) => fail("post_failed", format!("Could not post the click to the window: {e}"), "kicad_window_info shows whether the hwnd is still valid."),+    }+}++fn fix_keyboard() -> Value {+    json!({+        "success": true, "stuckKeys": [], "released": false,+        "output": "Nothing to fix: the native bridge never sends global keystrokes (no SendInput/keybd_event path), so it can never leave a modifier key held. Keys and clicks are posted to one window's queue, which touches no global keyboard state.",+        "_hint": "If keys ARE stuck on this desktop, another tool held them: ab's desktop_press_key path or a release of each modifier through it clears the OS state.",+    })+}++// ── kicad_place_footprint ─────────────────────────────────────────────────────++const BLANK_BOARD: &str = include_str!("../../../../templates/blank-board.kicad_pcb");++fn pseudo_uuid() -> String {+    // Not cryptographic: a KiCad item id only has to be unique within the board.+    let mut x: u64 = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos() as u64).unwrap_or(0x9E3779B97F4A7C15) ^ ((std::process::id() as u64) << 32);+    let mut bytes = [0u8; 16];+    for b in bytes.iter_mut() {+        x ^= x << 13;+        x ^= x >> 7;+        x ^= x << 17;+        *b = (x & 0xff) as u8;+    }+    bytes[6] = (bytes[6] & 0x0f) | 0x40;+    bytes[8] = (bytes[8] & 0x3f) | 0x80;+    let h: String = bytes.iter().map(|b| format!("{b:02x}")).collect();+    format!("{}-{}-{}-{}-{}", &h[0..8], &h[8..12], &h[12..16], &h[16..20], &h[20..32])+}++/// Quote an unquoted top-level `(layer X)` token: `(layer F.Cu)` -> `(layer "F.Cu")`.+fn quote_first_layer(text: &str) -> String {+    let Some(i) = text.find("(layer ") else { return text.to_string() };+    let rest = &text[i + 7..];+    let trimmed = rest.trim_start();+    if trimmed.starts_with('"') {+        return text.to_string();+    }+    let end = trimmed.find(')').unwrap_or(0);+    let name = trimmed[..end].trim();+    if name.is_empty() || !name.chars().next().map(|c| c.is_ascii_alphabetic()).unwrap_or(false) {+        return text.to_string();+    }+    format!("{}(layer \"{}\"){}", &text[..i], name, &trimmed[end + 1..])+}++/// Inject `(uuid ...)` and `(at x y)` right after the footprint's first quoted `(layer "...")`.+fn place_in_board(fp_text: &str, uid: &str, x: f64, y: f64) -> Option<String> {+    let i = fp_text.find("(layer \"")?;+    let close = fp_text[i..].find(')')? + i + 1;+    Some(format!("{}\n\t\t(uuid \"{uid}\")\n\t\t(at {x} {y}){}", &fp_text[..close], &fp_text[close..]))+}++fn place_footprint(state: &mut State, args: &Value) -> Value {+    let info = state.kicad_info();+    if !info.installed {+        return fail("not_installed", "KiCad not installed", "Install KiCad from https://www.kicad.org/download/ then retry.");+    }+    let file_name = arg_str(args, "fileName").unwrap_or("");+    let Some(content) = arg_str(args, "fileContent") else {+        return fail("missing_arg", "No fileContent (base64 .kicad_mod) specified", "Pass fileContent: the base64 of the .kicad_mod text.");+    };+    let mut fp_text = match kicad_core::install::b64_decode(content) {+        Ok(b) => String::from_utf8_lossy(&b).trim().to_string(),+        Err(e) => return fail("bad_content", format!("could not decode fileContent: {e}"), "fileContent must be standard base64."),+    };+    // Legacy `(module ...)` footprints (KiCad <= 5 / EAGLE imports): rename the wrapper+    // and quote the top-level layer token the splice relies on.+    if fp_text.starts_with("(module") {+        fp_text = format!("(footprint{}", &fp_text["(module".len()..]);+        fp_text = quote_first_layer(&fp_text);+    }+    if !fp_text.starts_with("(footprint") {+        return fail("bad_content", "fileContent is not a .kicad_mod (must start with '(footprint' or '(module')", "Send the raw .kicad_mod text, base64-encoded.");+    }+    let fp_name = arg_str(args, "footprintName").map(str::to_string)+        .or_else(|| kicad_core::install::footprint_name_in(&fp_text))+        .or_else(|| Path::new(file_name).file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.is_empty()))+        .unwrap_or_else(|| "footprint".into());+    let x = args.get("x").and_then(Value::as_f64).unwrap_or(148.0);+    let y = args.get("y").and_then(Value::as_f64).unwrap_or(105.0);+    let uid = pseudo_uuid();+    let Some(fp_board) = place_in_board(&fp_text, &uid, x, y) else {+        return fail("bad_content", "footprint has no (layer \"...\") token to anchor the placement", "A .kicad_mod carries (layer \"F.Cu\") near the top; this one does not parse.");+    };+    let skel = BLANK_BOARD.trim_end();+    let idx = skel.rfind(')').unwrap_or(skel.len());+    let combined = format!("{}\n{}\n)\n", skel[..idx].trim_end(), fp_board);+    let user_dir = info.primary().and_then(|i| native().user_dir(&i.version)).or_else(|| std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME")).map(PathBuf::from)).unwrap_or_else(|| PathBuf::from("."));+    let _ = std::fs::create_dir_all(&user_dir);+    let out_path = user_dir.join(format!("adom-place-{fp_name}.kicad_pcb"));+    if let Err(e) = std::fs::write(&out_path, combined) {+        return fail("write_failed", format!("could not write board: {e}"), "The preview board goes to Documents/KiCad/<version>; check it is writable.");+    }+    let board_path = norm(&out_path);+    let open_it = args.get("open").and_then(Value::as_bool).unwrap_or(true);+    let mut opened = false;+    let mut extra = Map::new();+    if open_it {+        match kicad_exe(&info, "pcbnew") {+            Some(exe) => match spawn_background(&exe, &[board_path.clone()], "pcbnew", None) {+                Ok((pid, _)) => {+                    opened = true;+                    extra.insert("pid".into(), json!(pid));+                    // Verify the frame by title with one deadline; the sweep runs after.+                    let stem = format!("adom-place-{fp_name}").to_lowercase();+                    let deadline = Instant::now() + Duration::from_secs(20);+                    while Instant::now() < deadline {+                        invalidate();+                        if let Some(w) = windows_of_kind(WindowKind::PcbEditor).into_iter().find(|w| w.title.to_lowercase().contains(&stem) || w.pid == pid) {+                            extra.insert("hwnd".into(), json!(w.hwnd));+                            extra.insert("title".into(), json!(w.title));+                            break;+                        }+                        sleep_ms(500);+                    }+                }+                Err(e) => {+                    if e["errorCode"] == json!("not_supported_on_platform") {+                        return e;+                    }+                    return json!({"success": true, "boardPath": board_path, "footprintName": fp_name, "placedAt": [x, y], "opened": false, "output": format!("Board written but pcbnew launch failed: {}", e["error"].as_str().unwrap_or("")), "_hint": format!("Open it manually: {board_path}")});+                }+            },+            None => {}+        }+    }+    let mut v = json!({+        "success": true, "boardPath": board_path, "footprintName": fp_name, "placedAt": [x, y], "opened": opened, "mechanism": if opened { "spawn" } else { "file" },+        "output": format!("Placed footprint '{fp_name}' at ({x}, {y}) mm on a preview board{}", if opened { " and opened it in pcbnew" } else { "" }),+    });+    merge(&mut v, Value::Object(extra));+    v+}++// ── kicad_enable_software_opengl ──────────────────────────────────────────────++const MESA_URL: &str = "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.60/mesa-llvmpipe-x64.zip";+const MESA_DLLS: &[&str] = &["opengl32.dll", "libgallium_wgl.dll", "dxil.dll"];+const MESA_EXES: &[&str] = &["kicad.exe", "pcbnew.exe", "eeschema.exe", "gerbview.exe", "pl_editor.exe", "bitmap2component.exe", "pcb_calculator.exe"];++fn enable_software_opengl(state: &mut State) -> Value {+    if native().capabilities().os != "windows" {+        return json!({"success": false, "error": "Windows-only", "errorCode": "unsupported_platform", "_hint": "Mesa llvmpipe DotLocal redirection only applies to KiCad's Windows build."});+    }+    let info = state.kicad_info();+    let mut bins: Vec<PathBuf> = Vec::new();+    for i in &info.installs {+        let mut b = PathBuf::from(if i.bin_dir.is_empty() { &i.base_dir } else { &i.bin_dir });+        if b.file_name().map(|n| n.to_string_lossy().to_lowercase() != "bin").unwrap_or(true) {+            b = b.join("bin");+        }+        if b.is_dir() && !bins.contains(&b) {+            bins.push(b);+        }+    }+    if bins.is_empty() {+        return json!({"success": false, "error": "No KiCad bin dir found; is KiCad installed?", "errorCode": "host_app_not_installed", "errorCodeLegacy": "kicad_not_installed", "hostApp": "KiCad", "_hint": "Run kicad_list_versions. Install KiCad first via kicad_upgrade."});+    }+    eprintln!("[kicad-bridge] enable_software_opengl: downloading Mesa llvmpipe...");+    let data = match pcm::http_fetch(MESA_URL, Duration::from_secs(180)) {+        Ok(d) => d,+        Err(e) => return json!({"success": false, "error": format!("Mesa download/unzip failed: {e}"), "errorCode": "download_failed"}),+    };+    let zip = match pcm::ZipFile::parse(&data) {+        Ok(z) => z,+        Err(e) => return json!({"success": false, "error": format!("Mesa download/unzip failed: {e}"), "errorCode": "download_failed"}),+    };+    let mut enabled = Vec::new();+    let mut errors = Vec::new();+    for bd in &bins {+        let mut ok = true;+        for dll in MESA_DLLS {+            let Some(entry) = zip.entries.iter().find(|e| e.name.rsplit('/').next() == Some(dll)) else {+                errors.push(format!("{}: {dll} missing from the Mesa zip", norm(bd)));+                ok = false;+                break;+            };+            let bytes = match zip.read(entry) {+                Ok(b) => b,+                Err(e) => { errors.push(format!("{}: {dll}: {e}", norm(bd))); ok = false; break; }+            };+            if let Err(e) = std::fs::write(bd.join(dll), bytes) {+                errors.push(if e.kind() == std::io::ErrorKind::PermissionDenied { format!("{}: permission denied (writing KiCad's bin needs elevation)", norm(bd)) } else { format!("{}: {e}", norm(bd)) });+                ok = false;+                break;+            }+        }+        if !ok {+            continue;+        }+        for exe in MESA_EXES {+            if bd.join(exe).exists() {+                let _ = std::fs::write(bd.join(format!("{exe}.local")), "");+            }+        }+        enabled.push(norm(bd));+        eprintln!("[kicad-bridge] enable_software_opengl: deployed to {}", norm(bd));+    }+    let ok = !enabled.is_empty();+    if !enabled.is_empty() {+        NO_OPENGL.store(false, std::sync::atomic::Ordering::Relaxed); // the next start renders through Mesa+    }+    json!({+        "success": ok, "enabledIn": enabled, "errors": errors, "renderer": "Mesa llvmpipe (CPU software OpenGL)",+        "_hint": if ok {+            "LAST-RESORT software-OpenGL fallback deployed: KiCad will render via Mesa llvmpipe on the CPU (SLOW). This is ONLY for a GPU-less host (Hyper-V/RDP/headless) where KiCad cannot render at all; do NOT enable it on a normal machine. CLOSE and REOPEN KiCad editors to load it. To undo, delete opengl32.dll/libgallium_wgl.dll/dxil.dll and the *.local files from KiCad's bin."+        } else {+            "Could not deploy; see errors[]. Writing KiCad's bin (Program Files) needs an elevated Bridge; a per-user KiCad install (kicad_upgrade scope=user) avoids that."+        },+    })+}++#[cfg(test)]+mod tests {+    #[test]+    fn a_library_browse_filters_on_the_library_itself() {+        assert_eq!(super::library_filter("Timer"), "Timer:");+        assert_eq!(super::library_filter(" Timer: "), "Timer:");+        assert_eq!(super::library_filter("My Parts"), "My Parts", "KiCad splits the filter on spaces");+    }++    #[test]+    fn a_library_browse_never_loads_a_part() {+        assert!(!super::loads_a_part(super::Verify::Never), "a browse with no part must stop at the filter");+        assert!(super::loads_a_part(super::Verify::Symbol { name: "R", library: "Device" }));+    }++    #[test]+    fn a_library_browse_says_what_the_canvas_still_shows() {+        let e = "Symbol Editor";+        let empty = super::library_browse_reply(true, "[no symbol loaded] \u{2014} Symbol Editor", e, "Tester", "None was loaded.");+        assert_eq!(empty["libraryShown"], "Tester");+        assert!(empty.get("canvasPart").is_none(), "{empty}");+        assert!(empty["output"].as_str().unwrap().contains("None was loaded."));+        let other = super::library_browse_reply(true, "MCU_Module:Flipper_Zero \u{2014} Symbol Editor", e, "Tester", "None was loaded.");+        assert_eq!(other["canvasPart"], "MCU_Module:Flipper_Zero");+        assert_eq!(other["canvasFromOtherLibrary"], true);+        assert!(other["output"].as_str().unwrap().contains("from before"), "{other}");+        let same = super::library_browse_reply(true, "Tester:BZT52C18Q \u{2014} Symbol Editor", e, "tester", "");+        assert_eq!(same["canvasFromOtherLibrary"], false);+        let bad = super::library_browse_reply(false, "", e, "Tester", "");+        assert_eq!(bad["success"], false);+        assert_eq!(bad["errorCode"], "library_filter_failed");+    }++    use super::*;+    #[test]+    fn viewer_selection_is_bound_to_source_and_refuses_ambiguity(){+        let win=|hwnd,owner,pid|Win{hwnd,owner,pid,title:"3D Viewer".into(),class_name:"wxWindowNR".into(),rect:(0,0,100,100),z:None,exe:"pcbnew.exe".into(),minimized:false,maximized:false,hung:false};+        assert_eq!(viewer_for_frame(2,200,vec![win(10,1,200),win(20,2,200)],&[10,20],2,None).unwrap().hwnd,20);+        assert!(viewer_for_frame(2,200,vec![win(10,1,200)],&[10],2,None).is_none());+        assert!(viewer_for_frame(2,200,vec![win(10,0,200)],&[10],2,None).is_none());+        assert_eq!(viewer_for_frame(2,200,vec![win(10,0,200),win(20,0,200)],&[10],2,None).unwrap().hwnd,20);+        assert!(viewer_for_frame(2,200,vec![win(20,0,200),win(21,0,200)],&[],2,None).is_none());+        assert_eq!(viewer_for_frame(2,200,vec![win(10,0,200),win(20,0,200)],&[10,20],2,Some(20)).unwrap().hwnd,20);+    }+++    #[test]+    fn footprint_splice_matches_python() {+        let fp = "(footprint \"X\"\n\t(version 20240108)\n\t(layer \"F.Cu\")\n\t(pad \"1\" smd rect)\n)";+        let placed = place_in_board(fp, "u-u-i-d", 148.0, 105.0).unwrap();+        assert!(placed.contains("(layer \"F.Cu\")\n\t\t(uuid \"u-u-i-d\")\n\t\t(at 148 105)"));+        let legacy = "(module X (layer F.Cu) (pad 1 smd rect))";+        let modern = format!("(footprint{}", &legacy["(module".len()..]);+        let q = quote_first_layer(&modern);+        assert!(q.starts_with("(footprint X (layer \"F.Cu\")"));+        assert_eq!(quote_first_layer("(footprint X (layer \"B.Cu\"))"), "(footprint X (layer \"B.Cu\"))");+        assert!(place_in_board("(footprint X)", "u", 1.0, 2.0).is_none());+        let u = pseudo_uuid();+        assert_eq!(u.len(), 36);+        assert_eq!(&u[14..15], "4");+    }++    #[test]+    fn base64_round_trip() {+        assert_eq!(b64_encode(b""), "");+        assert_eq!(b64_encode(b"f"), "Zg==");+        assert_eq!(b64_encode(b"fo"), "Zm8=");+        assert_eq!(b64_encode(b"foo"), "Zm9v");+        assert_eq!(b64_encode(b"foobar"), "Zm9vYmFy");+        let back = kicad_core::install::b64_decode(&b64_encode(b"\x89PNG\r\n\x1a\n")).unwrap();+        assert_eq!(back, b"\x89PNG\r\n\x1a\n");+    }++    #[test]+    fn stamp_and_foreground_args() {+        let s = utc_stamp();+        assert_eq!(s.len(), 15);+        assert_eq!(&s[8..9], "-");+        assert!(user_wants_foreground(&json!({"foreground": true})));+        assert!(user_wants_foreground(&json!({"showToUser": true})));+        assert!(user_wants_foreground(&json!({"bringToFront": true})));+        assert!(!user_wants_foreground(&json!({"foreground": false, "reason": "x"})));+        assert_eq!(arg_any_u64(&json!({"pcbHwnd": "123"}), &["pcbHwnd", "pcb_hwnd"]), Some(123));+        assert_eq!(arg_any_u64(&json!({"pcb_hwnd": 0}), &["pcbHwnd", "pcb_hwnd"]), None);+    }++    #[test]+    fn dispatch_claims_only_its_verbs_and_is_honest_off_windows() {+        let mut state = State::new();+        assert!(dispatch(&mut state, "kicad_errors", &json!({})).is_none());+        assert!(dispatch(&mut state, "kicad_run_drc", &json!({})).is_none());+        let fk = dispatch(&mut state, "kicad_fix_keyboard", &json!({})).unwrap();+        assert_eq!(fk["success"], json!(true));+        assert_eq!(fk["stuckKeys"], json!([]));+        // Without window control (this host) the window verbs answer not_supported_on_platform+        // or an ab-unavailable error, never a hang and never a fake success.+        if !native().capabilities().window_control {+            std::env::remove_var("ADOM_DIRECT_API_URL");+            let r = dispatch(&mut state, "kicad_window_info", &json!({})).unwrap();+            assert_eq!(r["success"], json!(false));+            let code = r["errorCode"].as_str().unwrap_or("");+            assert!(code == "not_supported_on_platform" || code == "ab_unavailable", "{r}");+            let r = dispatch(&mut state, "kicad_open_editors", &json!({})).unwrap();+            assert_eq!(r["success"], json!(false));+            let r = dispatch(&mut state, "kicad_send_key", &json!({"key": "escape", "hwnd": 5})).unwrap();+            assert_eq!(r["success"], json!(false));+        }+        let r = dispatch(&mut state, "kicad_click", &json!({"hwnd": 5, "x": 300, "y": 2})).unwrap();+        assert_eq!(r["errorCode"], json!("bad_arg"));+        let r = dispatch(&mut state, "kicad_send_key", &json!({})).unwrap();+        assert_eq!(r["errorCode"], json!("missing_arg"));+        let r = dispatch(&mut state, "kicad_open_3d_viewer", &json!({"editor": "sideways"})).unwrap();+        assert_eq!(r["errorCode"], json!("bad_arg"));+    }++    #[test]+    fn catalog_covers_the_python_window_group() {+        let names: Vec<&str> = VERBS.iter().map(|v| v.name).collect();+        for n in ["kicad_launch", "kicad_open_board", "kicad_open_schematic", "kicad_open_symbol_editor", "kicad_open_footprint_editor", "kicad_open_3d_viewer", "kicad_open_editors", "kicad_close", "kicad_close_symbol_editor", "kicad_close_footprint_editor", "kicad_close_3d_viewer", "kicad_dismiss_dialogs", "kicad_window_info", "kicad_state", "kicad_screenshot_all", "kicad_send_key", "kicad_click", "kicad_fix_keyboard", "kicad_place_footprint", "kicad_enable_software_opengl"] {+            assert!(names.contains(&n), "{n} missing from the catalog");+        }+        assert!(!names.contains(&"kicad_errors"));+        for v in VERBS {+            assert!(!v.summary.contains('\u{2014}') && !v.hint.contains('\u{2014}'), "{} carries an em dash", v.name);+        }+    }+}+++/// kicad_rescan_libraries: tree refresh first (#51), restart only on force plus confirmation.+fn rescan_libraries(state: &mut State, args: &Value) -> Value {+    let force = args.get("force").and_then(Value::as_bool).unwrap_or(false);+    if !force {+        let mut refreshed: Vec<u64> = Vec::new();+        for kind in [WindowKind::FootprintEditor, WindowKind::SymbolEditor] {+            if let Some(h) = first_of_kind(kind) {+                if let Ok(Some(_)) = menu_invoke_by_text(h, "refresh") {+                    refreshed.push(h);+                }+            }+        }+        if !refreshed.is_empty() {+            // The tree reloads asynchronously; the Python waited 3 s before probing.+            std::thread::sleep(std::time::Duration::from_secs(3));+            invalidate();+            let mut wanted: Vec<(String, String)> = Vec::new();+            for spec in args.get("footprints").and_then(Value::as_array).cloned().unwrap_or_default() {+                match spec {+                    Value::String(s) if s.contains(':') => {+                        let (lib, name) = s.split_once(':').unwrap();+                        wanted.push((lib.to_string(), name.to_string()));+                    }+                    Value::Object(o) => {+                        if let Some(name) = o.get("name").and_then(Value::as_str) {+                            wanted.push((o.get("library").and_then(Value::as_str).unwrap_or("Adom").to_string(), name.to_string()));+                        }+                    }+                    _ => {}+                }+            }+            let mut probed: Vec<Value> = Vec::new();+            if !wanted.is_empty() {+                let fp = first_of_kind(WindowKind::FootprintEditor);+                for (lib, name) in &wanted {+                    let (ok, trace, _mech) = match fp {+                        Some(h) => navigate_tree(h, &format!("{lib} {name}"), Verify::Footprint { name, library: lib }),+                        None => (false, json!({}), "none"),+                    };+                    probed.push(json!({+                        "library": lib, "name": name, "visible": ok,+                        "error": if ok { Value::Null } else if fp.is_none() { json!("no Footprint Editor open") } else { json!("editor tree did not load it") },+                        "navigation": trace,+                    }));+                }+            }+            let verified = !wanted.is_empty() && probed.iter().all(|p| p["visible"].as_bool().unwrap_or(false));+            let mut out = json!({"success": true, "method": "tree-refresh", "refreshedEditors": refreshed, "verified": verified, "probed": probed});+            out["_hint"] = json!(if !wanted.is_empty() && !verified {+                format!("Tree refreshed but KiCad still cannot load: {}. Check the name with kicad_list_footprints; if it is on disk, call again with force:true and confirmRestart:true for the process restart (about a minute).",+                    probed.iter().filter(|p| !p["visible"].as_bool().unwrap_or(false)).map(|p| format!("{}:{}", p["library"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or(""))).collect::<Vec<_>>().join(", "))+            } else if !wanted.is_empty() {+                "Verified in the editor: every listed footprint loaded after the refresh, no restart. The last one is now shown in the Footprint Editor.".to_string()+            } else {+                "Refresh invoked on the open editor tree(s). Pass footprints:[\"Lib:Name\"] to have each loaded in the editor as proof; without that list nothing is verified. force:true is the process restart, the last resort.".to_string()+            });+            post_verb(state, "kicad_rescan_libraries", args, &mut out, None);+            return out;+        }+    }+    // Restart path: the native build has no ownership ledger yet (phase 4), so a running+    // KiCad is treated as the user's unless the caller confirms.+    let running = native().processes(&["kicad.exe", "pcbnew.exe", "eeschema.exe"]).unwrap_or_default();+    let confirm = args.get("confirmRestart").and_then(Value::as_bool).unwrap_or(false);+    if !running.is_empty() && !confirm {+        return json!({"success": false, "errorCode": "rescan_refused",+            "reason": "KiCad is running and the native build cannot yet tell a user-launched KiCad from its own",+            "runningPids": running.iter().map(|p| p.pid).collect::<Vec<_>>(),+            "_hint": "Ask the user whether KiCad may be restarted, then call again with force:true and confirmRestart:true. Without force the verb refreshes the open editors' trees in place, which is enough for footprints installed after the editor started."});+    }+    // Wiki #48: a fresh process places every window on the primary display, so the user's+    // monitor choice is captured here, while the windows still exist, and restored below.+    let remembered = remember_placement();+    let mut close_how = "nothing was running";+    let mut graceful_failure = Value::Null;+    if !running.is_empty() {+        // Wiki #48 ask 3: graceful first, so KiCad writes its own geometry and its own files;+        // the kill is the fallback for a window that will not go.+        let mut closed = dispatch(state, "kicad_close", &json!({})).unwrap_or_else(|| json!({}));+        close_how = "graceful";+        if !closed["success"].as_bool().unwrap_or(false) {+            if closed["errorCode"] == json!("unsaved_changes") {+                return json!({"success": false, "errorCode": "restart_close_failed", "close": closed,+                    "_hint": "KiCad is asking whether to save the user's work, so nothing was closed or killed. Ask them, then save (or call kicad_close {\"discardChanges\": true}) before restarting."});+            }+            // Keep WHY the graceful close failed: otherwise the kill overwrites the only+            // evidence and the next person sees "force-kill" with no cause.+            graceful_failure = json!({"errorCode": closed.get("errorCode").cloned().unwrap_or(Value::Null), "error": closed.get("error").cloned().unwrap_or(Value::Null), "output": closed.get("output").cloned().unwrap_or(Value::Null)});+            closed = dispatch(state, "kicad_close", &json!({"force": true})).unwrap_or_else(|| json!({}));+            close_how = "force-kill after the graceful close failed";+            if !closed["success"].as_bool().unwrap_or(false) {+                return json!({"success": false, "errorCode": "restart_close_failed", "close": closed, "closeHow": close_how, "gracefulClose": graceful_failure});+            }+        }+        std::thread::sleep(std::time::Duration::from_secs(4));+    }+    let launched = launch(state, &json!({}));+    if !launched["success"].as_bool().unwrap_or(false) {+        return json!({"success": false, "errorCode": "restart_launch_failed", "launch": launched});+    }+    for _ in 0..80 {+        std::thread::sleep(std::time::Duration::from_millis(750));+        invalidate();+        if !kicad_windows().is_empty() {+            sleep_ms(1200); // let the fresh windows settle before moving them+            let restored = restore_placement();+            let moved = restored.iter().filter(|r| r["moved"] == json!(true)).count();+            return json!({"success": true, "restarted": true, "ready": true, "method": "process-restart",+                "placementRemembered": remembered, "placementRestored": restored, "closeHow": close_how, "gracefulClose": graceful_failure,+                "_hint": format!("Library caches rebuilt. Every part installed before this call is now visible; show verbs will not trigger another restart until the next install-while-open. Placement: {moved} window(s) put back where the user had them (wiki #48), without activating anything.")});+        }+    }+    json!({"success": false, "restarted": true, "ready": false, "errorCode": "restart_no_window",+        "_hint": "KiCad was restarted but no window appeared within 60 s; kicad_window_info shows what is up."})+}+++#[cfg(test)]+mod linked_refresh_tests {+    #[test]+    fn exact_view_refresh_excludes_plugins_and_partial_labels() {+        let path = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();+        assert!(super::is_view_refresh(&path(&["&View", "&Refresh\tF5"])));+        for a in [+            vec!["Tools", "Refresh Plugins"],+            vec!["View", "Refresh All"],+            vec!["Refresh"],+        ] {+            assert!(!super::is_view_refresh(&path(&a)));+        }+    }+}++/// The boards KiCad's API says are open; without the ipc feature (the macOS type-check build) there is+/// no API to ask, and the callers fall back to the window title.+#[cfg(feature = "ipc")]+fn open_board_paths(timeout: Duration) -> Result<Vec<String>, String> {+    kicad_core::ipc::open_board_paths(timeout)+}++#[cfg(not(feature = "ipc"))]+fn open_board_paths(_timeout: Duration) -> Result<Vec<String>, String> {+    Err("this build has the ipc feature off".into())+}++#[cfg(feature = "ipc")]+fn same_board_path(a: &str, b: &str) -> bool {+    kicad_core::ipc::same_board_path(a, b)+}++#[cfg(not(feature = "ipc"))]+fn same_board_path(a: &str, b: &str) -> bool {+    a.replace('\\', "/").eq_ignore_ascii_case(&b.replace('\\', "/"))+}++// ---------------------------------------------------------------------------+// The standard's document verbs: check_dialogs, save, close_document, close_all_documents+// ---------------------------------------------------------------------------++/// What a dialog is, in the standard's words: (category, safeAction, meaning).+fn classify_dialog(title: &str, body: &str) -> (&'static str, &'static str, &'static str) {+    if wm::is_save_prompt(title, body) {+        return ("save_prompt", "ask the person; never answer it for them", "KiCad is asking whether to keep unsaved changes, which may be someone's work.");+    }+    match wm::sweep_decision(title, body, false) {+        DialogAction::LeaveProgress => ("progress", "wait", "KiCad is working; it closes itself."),+        DialogAction::Dismiss { benign: true } => ("benign", "kicad_dismiss_dialogs", "A notice that blocks nothing of value; the sweep clears it."),+        _ if wm::is_tool_dialog(title) => ("tool", "leave it, or close it when finished", "A tool window someone opened on purpose."),+        _ => ("other", "read it, then answer it", "KiCad is waiting on this; read the body or the shot before answering."),+    }+}++fn check_dialogs(args: &Value) -> Value {+    if let Err(e) = find_windows(true, true) {+        return e;+    }+    let shots = args.get("screenshots").and_then(Value::as_bool).unwrap_or(true);+    let rows: Vec<Value> = scan_dialogs().iter().map(|d| {+        let info = native().window_info(d.hwnd).ok().flatten();+        let buttons: Vec<String> = native().child_windows(d.hwnd).map(|c| c.into_iter().filter(|w| w.class_name == "Button").map(|w| w.title.replace('&', "")).collect()).unwrap_or_default();+        let (category, safe, meaning) = classify_dialog(&d.title, &d.body);+        json!({"hwnd": d.hwnd, "pid": info.as_ref().map(|i| i.pid), "title": d.title, "className": d.class_name, "body": d.body, "buttons": buttons,+            "category": category, "safeAction": safe, "meaning": meaning, "ownedByMainInstance": d.owner != 0 && !d.via_global_sweep,+            "rect": info.as_ref().map(|i| json!(i.rect)), "shot": if shots { json!(screenshot_dialog(d.hwnd, &d.title)) } else { Value::Null }})+    }).collect();+    json!({"success": true, "dialogsDetected": rows.len(), "dialogs": rows})+}++/// The editor holding a board or schematic path, matched on the whole file name, not a prefix of it.+fn editor_of(path: &str) -> Option<Win> {+    let p = Path::new(path);+    let kind = match p.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() {+        Some("kicad_pcb") => WindowKind::PcbEditor,+        Some("kicad_sch") => WindowKind::SchematicEditor,+        _ => return None,+    };+    let stem = p.file_stem()?.to_string_lossy().to_string();+    windows_of_kind(kind).into_iter().find(|w| crate::verbs_std_more::parse_title(&w.title).is_some_and(|t| t.0.eq_ignore_ascii_case(&stem)))+}++/// Ask an editor to save itself; true once the unsaved mark has gone from its title.+fn save_editor(state: &mut State, w: &Win, path: Option<&str>) -> Result<&'static str, String> {+    if w.kind() == WindowKind::PcbEditor {+        let args = path.map_or(json!({}), |p| json!({"filePath": p}));+        if ipc_save(state, &args).is_ok() {+            return Ok("IPC SaveDocument");+        }+    }+    match menu_invoke_by_text(w.hwnd, "Save") {+        Ok(Some(_)) => {}+        Ok(None) => return Err("the editor has no File > Save".into()),+        Err(e) => return Err(e),+    }+    let started = Instant::now();+    while started.elapsed() < Duration::from_secs(60) {+        sleep_ms(250);+        invalidate();+        let title = native().window_info(w.hwnd).ok().flatten().map(|i| i.title).unwrap_or_default();+        if !title.starts_with('*') {+            return Ok("File > Save");+        }+        if !scan_dialogs().is_empty() {+            break;+        }+    }+    Err("KiCad did not finish saving".into())+}++fn save_document(state: &mut State, args: &Value) -> Value {+    let started = Instant::now();+    let given = arg_any(args, &["filePath", "path"]);+    let win = match given {+        Some(p) => editor_of(p),+        None => windows_of_kind(WindowKind::PcbEditor).into_iter().chain(windows_of_kind(WindowKind::SchematicEditor)).min_by_key(|w| w.z),+    };+    let Some(win) = win else {+        return match given {+            Some(p) => json!({"success": true, "saved": false, "path": p, "note": format!("not open: {p}. The file already holds what it holds; nothing to save."), "data": {"process": Value::Null, "params": {}}}),+            None => fail("no_document", "no board or schematic is open", "kicad_open_document opens one; kicad_state lists KiCad's windows."),+        };+    };+    let was_dirty = win.title.starts_with('*');+    let mut out = match save_editor(state, &win, given) {+        Ok(how) => json!({"success": true, "saved": true, "wasUnsaved": was_dirty, "title": win.title, "data": {"process": how, "params": {}}}),+        Err(e) => {+            let mut v = fail("save_failed", e, "dialogs lists anything KiCad is asking. A never-saved document asks for a name.");+            v["saved"] = json!(false);+            v+        }+    };+    out["dialogs"] = json!(scan_dialogs().iter().map(|d| json!({"title": d.title, "body": d.body})).collect::<Vec<_>>());+    out["roundtripMs"] = json!(started.elapsed().as_millis() as u64);+    out+}++fn close_document(args: &Value) -> Value {+    let started = Instant::now();+    let Some(path) = arg_any(args, &["path", "filePath"]) else {+        return fail("missing_arg", "path is required", "The absolute path of the open .kicad_pcb or .kicad_sch.");+    };+    let Some(win) = editor_of(path) else {+        return json!({"success": true, "data": {"closed": false, "path": path, "note": format!("not open: {path}")}, "roundtripMs": started.elapsed().as_millis() as u64});+    };+    let mut st = CloseState { discard: true, ..Default::default() };+    let gone = close_one(&mut st, &win);+    let discarded = gone.is_none();+    if gone == Some(false) {+        let mut v = fail("close_failed", format!("{} did not close", win.title), "dialogsSeen lists what is up; kicad_check_dialogs reads them.");+        v["dialogsSeen"] = json!(scan_dialogs().iter().map(|d| json!({"title": d.title, "body": d.body})).collect::<Vec<_>>());+        v["unsavedChanges"] = json!(st.unsaved);+        return v;+    }+    json!({"success": true, "data": {"closed": true, "path": path, "discarded": discarded}, "roundtripMs": started.elapsed().as_millis() as u64})+}++fn close_all_documents(state: &mut State, args: &Value) -> Value {+    let flag = |k: &str| args.get(k).and_then(Value::as_bool).unwrap_or(false);+    let (save, discard, scratch) = (flag("save"), flag("discardUnsaved"), flag("includeScratch"));+    invalidate();+    let wins = match find_windows(false, true) {+        Ok((w, _)) => w,+        Err(e) => return e,+    };+    let (mut closed, mut saved, mut kept) = (Vec::new(), Vec::new(), Vec::new());+    for w in wins.iter().filter(|w| w.owner == 0 && matches!(w.kind(), WindowKind::PcbEditor | WindowKind::SchematicEditor | WindowKind::SymbolEditor | WindowKind::FootprintEditor)) {+        let name = crate::verbs_std_more::parse_title(&w.title).map(|t| t.0).unwrap_or_else(|| w.title.clone());+        let dirty = w.title.starts_with('*');+        let never_saved = w.title.contains("[Unsaved]") || name.is_empty() || name.eq_ignore_ascii_case("untitled") || name.eq_ignore_ascii_case("noname");+        if never_saved && !scratch {+            kept.push(json!({"name": name, "why": "scratch"}));+            continue;+        }+        if dirty && !never_saved && save {+            match save_editor(state, w, None) {+                Ok(_) => saved.push(name.clone()),+                Err(e) => {+                    kept.push(json!({"name": name, "why": format!("unsaved changes: the save failed ({e})")}));+                    continue;+                }+            }+        } else if dirty && !discard {+            kept.push(json!({"name": name, "why": "unsaved changes"}));+            continue;+        }+        let mut st = CloseState { discard, ..Default::default() };+        match close_one(&mut st, w) {+            Some(false) => kept.push(json!({"name": name, "why": if st.unsaved.is_empty() { "did not close" } else { "unsaved changes" }})),+            _ => closed.push(name),+        }+    }+    json!({"success": true, "closed": closed, "saved": saved, "keptOpen": kept})+}++// ---------------------------------------------------------------------------+// kicad_teardrops: KiCad's own Edit > Edit Teardrops+// ---------------------------------------------------------------------------++/// Teardrop areas on the board the editor serves (zones carrying `(teardrop (type ..))`), or+/// None without the IPC API. Read straight from KiCad, not through another verb: this runs+/// under GUI_LOCK, which verbs::call would try to take again.+/// (teardrop areas, the board's revision) for the board the editor serves; None without IPC.+#[cfg(feature = "ipc")]+fn teardrop_count(state: &mut State, args: &Value) -> Option<(usize, String)> {+    use kicad_core::{detect, ipc};+    let info = state.kicad_info();+    let ctx = ipc::Ctx {+        kicad_cli: info.primary().map(|p| PathBuf::from(&p.kicad_cli)).filter(|p| p.is_file()),+        config_dir: info.primary().and_then(|p| detect::config_dir(&p.version)),+    };+    let (text, _, _) = ipc::board_text(&ctx, args).ok()?;+    Some((text.match_indices("(teardrop").filter(|(i, _)| !text[i + 9..].starts_with('s')).count(), ipc::revision_of(&text)))+}++#[cfg(not(feature = "ipc"))]+fn teardrop_count(_state: &mut State, _args: &Value) -> Option<(usize, String)> {+    None+}++#[cfg(feature = "ipc")]+fn ipc_save(state: &mut State, args: &Value) -> Result<(), String> {+    use kicad_core::{detect, ipc};+    let info = state.kicad_info();+    let ctx = ipc::Ctx {+        kicad_cli: info.primary().map(|p| PathBuf::from(&p.kicad_cli)).filter(|p| p.is_file()),+        config_dir: info.primary().and_then(|p| detect::config_dir(&p.version)),+    };+    ipc::save_board(&ctx, args).map(|_| ()).map_err(|e| e.to_string())+}++#[cfg(not(feature = "ipc"))]+fn ipc_save(_state: &mut State, _args: &Value) -> Result<(), String> {+    Err("saving needs the IPC API".into())+}++/// KiCad's one "Track to track" box answers both of the standard's tracks and tJunctions: an explicit+/// answer on either decides it (either true turns it on), and neither given leaves it on.+fn track_to_track(args: &Value) -> bool {+    let (t, j) = (args.get("tracks").and_then(Value::as_bool), args.get("tJunctions").and_then(Value::as_bool));+    match (t, j) {+        (None, None) => true,+        _ => t == Some(true) || j == Some(true),+    }+}++fn teardrops(state: &mut State, args: &Value) -> Value {+    if args.get("force") == Some(&json!(true)) {+        return fail("not_supported", "KiCad's Set Teardrops has no way to force a teardrop that breaks a rule", "Leave force off, then check with kicad_run_drc: KiCad reports a teardrop that breaks clearance there.");+    }+    let editors = windows_of_kind(WindowKind::PcbEditor);+    let editor = match arg_str(args, "filePath") {+        Some(path) => {+            let stem = Path::new(path).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();+            match editors.into_iter().find(|w| w.title.trim_start_matches('*').starts_with(&stem)) {+                Some(w) => w,+                None => return fail("board_not_open", format!("{stem} is not open in a PCB editor"), "Open it with kicad_open_board: teardrops run in KiCad's PCB editor."),+            }+        }+        None => match editors.into_iter().next() {+            Some(w) => w,+            None => return fail("board_not_open", "no PCB editor is open", "Pass filePath after kicad_open_board: teardrops run in KiCad's PCB editor."),+        },+    };+    let flag = |k: &str, d: bool| args.get(k).and_then(Value::as_bool).unwrap_or(d);+    let remove = flag("remove", false);+    let save = flag("save", true);+    let before = teardrop_count(state, args).map(|(n, _)| n);+    match menu_invoke_by_text(editor.hwnd, "Edit Teardrops...") {+        Ok(Some(_)) => {}+        Ok(None) => return fail("menu_not_found", "the PCB editor has no Edit Teardrops... command", "KiCad 8 or newer has it under Edit."),+        Err(e) => return fail("menu_failed", e, "Check the editor with kicad_window_info."),+    }+    let mut dialog = None;+    for _ in 0..60 {+        if let Some(d) = scan_dialogs().into_iter().find(|d| d.title.trim() == "Set Teardrops") {+            dialog = Some(d.hwnd);+            break;+        }+        sleep_ms(250);+    }+    let Some(dlg) = dialog else {+        return fail("dialog_not_shown", "KiCad did not show the Set Teardrops dialog within 15 s", "kicad_window_info lists what KiCad shows; another dialog may be in the way.");+    };+    let mut steps = Vec::new();+    let mut set = |label: &str, on: bool| -> Result<(), String> { native().uia_set_checked(dlg, label, on).map(|r| steps.push(r)) };+    let vias = flag("vias", true);+    let options = [("PTH pads", flag("pthPads", vias)), ("SMD pads", flag("smdPads", true)), ("Vias", vias), ("Track to track", track_to_track(args)), ("Selected items only", flag("selectedOnly", false))];+    let mut failed = None;+    for (label, on) in options {+        if let Err(e) = set(label, on) {+            failed = Some(e);+            break;+        }+    }+    if failed.is_none() {+        let action = if remove { "Remove all teardrops" } else { "Add teardrops with default values for shape" };+        match native().uia_invoke(dlg, action) {+            Ok(r) => steps.push(r),+            Err(e) => failed = Some(e),+        }+    }+    if let Some(e) = failed {+        let _ = native().click_button(dlg, &["close"]);+        return fail("dialog_control_failed", format!("Set Teardrops: {e}"), "Nothing was applied; the dialog was closed. kicad_screenshot_all shows KiCad's windows.");+    }+    if let Err(e) = native().click_button(dlg, &["apply and close"]) {+        let _ = native().click_button(dlg, &["close"]);+        return fail("dialog_control_failed", format!("Set Teardrops: {e}"), "Nothing was applied; the dialog was closed.");+    }+    steps.push("apply and close".into());+    let started = Instant::now();+    while window_alive(dlg) && started.elapsed() < Duration::from_secs(150) {+        sleep_ms(250);+    }+    if window_alive(dlg) {+        return fail("dialog_still_open", "Set Teardrops is still open after 150 s", "KiCad may still be working; check with kicad_window_info before calling again.");+    }+    let save_error = if save { ipc_save(state, args).err() } else { None };+    if save && save_error.is_none() {+        steps.push("save".into());+    }+    let dialogs_left: Vec<Value> = scan_dialogs().into_iter().map(|d| json!({"title": d.title, "body": d.body})).collect();+    let after_read = teardrop_count(state, args);+    let after = after_read.as_ref().map(|(n, _)| *n);+    let revision = after_read.map(|(_, r)| r);+    let delta = match (before, after) {+        (Some(b), Some(a)) => Some(a as i64 - b as i64),+        _ => None,+    };+    json!({+        "success": true,+        "output": match delta {+            Some(d) => format!("Teardrops {}: {} before, {} after", if remove { "removed" } else { "applied" }, before.unwrap_or(0), after.unwrap_or(0)) + if d == 0 && !remove { " (nothing new: every item in scope already had one, or none qualified)" } else { "" },+            None => format!("Teardrops {} through KiCad's dialog (counts need the IPC API)", if remove { "removed" } else { "applied" }),+        },+        "before": before, "after": after, "added": delta.filter(|d| *d > 0), "removed": delta.filter(|d| *d < 0).map(|d| -d),+        "teardropsBefore": before, "teardropsAfter": after,+        "mutated": delta.map(|d| d != 0),+        "dialogSteps": steps, "steps": steps,+        "dialogsLeft": dialogs_left,+        "revision": revision,+        "saved": save && save_error.is_none(),+        "saveError": save_error,+        "_hint": "One KiCad Undo step; revision is the board's new revision (KiCad touches the board even when it adds none), so pass it to the next edit. saved says whether it reached the file (save:false leaves that to kicad_save_board). Then kicad_run_drc: teardrops are copper areas and take part in the fill and the checks.",+    })+}+

Comments

No comments yet.

Log in to comment.