--- a/crates/aiflow-bridge/src/eda.rs +++ b/crates/aiflow-bridge/src/eda.rs @@ -213,15 +213,17 @@ let d = r.get("data").cloned().unwrap_or(r.clone()); let eds: Vec = d.get("editors").and_then(|e| e.as_array()).cloned().unwrap_or_default(); let pcbs: Vec<&Value> = eds.iter().filter(|w| w.get("kind").and_then(|k| k.as_str()) == Some("pcb_editor")).collect(); - let stem = std::path::Path::new(&self.board).file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string(); + let stem = file_stem(&self.board); let title_of = |w: &Value| w.get("title").and_then(|t| t.as_str()).unwrap_or("").to_string(); - let matching = if stem.is_empty() { None } else { pcbs.iter().find(|w| title_of(w).contains(&stem)).and_then(|w| w.get("hwnd")).and_then(|h| h.as_i64()) }; + let matching = if stem.is_empty() { None } else { pcbs.iter().find(|w| names_board(&title_of(w), &stem)).and_then(|w| w.get("hwnd")).and_then(|h| h.as_i64()) }; + // another project's PCB editor is never the camera: with the board known and not open, no window match cached { Some(h) => { - let other_board = pcbs.iter().any(|w| w.get("hwnd").and_then(|x| x.as_i64()) == Some(h) && !stem.is_empty() && !title_of(w).contains(&stem)); - if other_board { matching.or(Some(h)) } else { Some(h) } + let other_board = pcbs.iter().any(|w| w.get("hwnd").and_then(|x| x.as_i64()) == Some(h) && !stem.is_empty() && !names_board(&title_of(w), &stem)); + if other_board { matching } else { Some(h) } } - None => matching.or_else(|| pcbs.first().and_then(|w| w.get("hwnd")).and_then(|h| h.as_i64())), + None if stem.is_empty() => pcbs.first().and_then(|w| w.get("hwnd")).and_then(|h| h.as_i64()), + None => matching, } } /// KiCad keeps its own windows in front; the flow never swaps to Hydrogen for it. @@ -416,6 +418,27 @@ RUN.lock().unwrap().clone().unwrap_or_else(|| Arc::new(KicadEda { board: String::new() })) } +/// The file stem of the run's KiCad board ("" when the run has none yet). KiCad's editor titles carry +/// the project name, so the flow can tell this project's windows from another project open on the +/// same desktop. +pub fn run_kicad_stem() -> String { + match active().board() { BoardRef::Kicad(p) => file_stem(&p), _ => String::new() } +} + +/// The file name without its extension, for a Windows or a Unix path. +pub fn file_stem(path: &str) -> String { + let name = path.rsplit(['/', '\\']).next().unwrap_or(""); + name.rsplit_once('.').map(|(s, _)| s).unwrap_or(name).to_string() +} + +/// Whether a window title names this board: the stem as a whole name ("esc" is not named by +/// "esc-v1 — PCB Editor"), so a sibling project's editor is never taken for the run's. +pub fn names_board(title: &str, stem: &str) -> bool { + if stem.is_empty() { return false; } + let part = |c: Option| c.map_or(false, |c| c.is_ascii_alphanumeric() || c == '-'); + title.match_indices(stem).any(|(i, _)| !part(title[..i].chars().next_back()) && !part(title[i + stem.len()..].chars().next())) +} + /// The backend a single call is addressed to: a `filePath` naming another tool's board wins (a /// verb may name its board explicitly), else the run's. pub fn for_call(args: &Value) -> Arc { @@ -452,6 +475,34 @@ } #[test] + fn a_title_names_a_board_only_as_a_whole_name() { + assert_eq!(file_stem("C:/Users/me/KiCad/esc/esc-2s6s.kicad_pcb"), "esc-2s6s"); + assert_eq!(file_stem("C:\\work\\esc\\esc-2s6s.kicad_pcb"), "esc-2s6s"); + assert!(names_board("esc-2s6s — PCB Editor", "esc-2s6s")); + assert!(names_board("*esc-2s6s — PCB Editor", "esc-2s6s")); + assert!(names_board("top [esc-2s6s/] — Schematic Editor", "esc-2s6s")); + assert!(names_board("PCM_esc-2s6s:R — Symbol Editor", "esc-2s6s")); + assert!(!names_board("esc-2s6s-v2 — PCB Editor", "esc-2s6s")); + assert!(!names_board("other-esc-v1 — PCB Editor", "esc")); + assert!(!names_board("anything", "")); + } + + #[test] + fn the_camera_never_falls_back_to_another_boards_editor() { + let t = |reply: Value| move |_v: &str, _a: &Value| reply.clone(); + let eds = json!({"data": {"editors": [ + {"kind": "pcb_editor", "hwnd": 1, "title": "other-board — PCB Editor"}, + {"kind": "pcb_editor", "hwnd": 2, "title": "esc-2s6s — PCB Editor"}]}}); + let k = KicadEda { board: "C:/p/esc-2s6s.kicad_pcb".into() }; + assert_eq!(k.camera_window(&t(eds.clone()), "placement", None), Some(2)); + assert_eq!(k.camera_window(&t(eds.clone()), "placement", Some(1)), Some(2)); + let only_other = json!({"data": {"editors": [{"kind": "pcb_editor", "hwnd": 1, "title": "other-board — PCB Editor"}]}}); + assert_eq!(k.camera_window(&t(only_other.clone()), "placement", None), None); + assert_eq!(k.camera_window(&t(only_other.clone()), "placement", Some(1)), None); + assert_eq!(KicadEda { board: String::new() }.camera_window(&t(only_other), "placement", None), Some(1)); + } + + #[test] fn a_fab_pack_reads_as_its_floors() { let dru = "(rule a (constraint track_width (min 0.127mm)))\n(rule b (constraint hole_size (min 0.3mm)))\n(rule c (constraint hole_size (min 0.5mm)))\n(rule d (constraint edge_clearance (min 0.3mm)))\n(rule e (constraint clearance (min 5mil)))"; let n = fab_numbers(dru); --- a/crates/aiflow-bridge/src/lib.rs +++ b/crates/aiflow-bridge/src/lib.rs @@ -286,6 +286,16 @@ result } +/// The PCB editor in a `kicad_window_info` reply whose title names the board `stem`; with no stem, +/// the first PCB editor. +pub fn pcb_editor_of(info: &Value, stem: &str) -> Option { + let eds = info.get("editors").and_then(|e| e.as_array()).cloned().unwrap_or_default(); + let title = |w: &Value| w.get("title").and_then(|t| t.as_str()).unwrap_or("").to_string(); + eds.iter().filter(|w| w.get("kind").and_then(|k| k.as_str()) == Some("pcb_editor")) + .find(|w| stem.is_empty() || eda::names_board(&title(w), stem)) + .and_then(|w| w.get("hwnd")).and_then(|h| h.as_i64()) +} + #[derive(Clone)] pub struct Bridge { pub ai_thread: String, @@ -417,10 +427,12 @@ match hwnd { Some(h) => self.raw_call("desktop_bring_to_front", &serde_json::json!({"hwnd": h, "reason": format!("show {what} for this step")}))["success"].as_bool().unwrap_or(true), None => false } } + /// The PCB editor of the run's board (its title names the board's file stem); with no board set, + /// the first PCB editor. Another project's PCB editor on the same desktop is never returned. pub fn pcb_editor_hwnd(&self) -> Option { let r = self.call("kicad_window_info", &json!({})); let d = r.get("data").cloned().unwrap_or(r.clone()); - d.get("editors").and_then(|e| e.as_array()).and_then(|a| a.iter().find(|w| w.get("kind").and_then(|k| k.as_str()) == Some("pcb_editor"))).and_then(|w| w.get("hwnd")).and_then(|h| h.as_i64()) + pcb_editor_of(&d, &eda::run_kicad_stem()) } pub fn record_start(&self, reason: &str) -> Result { --- a/crates/adom-aiflow/src/clipwatch.rs +++ b/crates/adom-aiflow/src/clipwatch.rs @@ -63,13 +63,16 @@ /// The schematic or library window of the run's tool for a tool step: KiCad's schematic editor /// (any editor that is not the PCB editor) from `kicad_window_info`, else the tool's main window. -pub fn tool_window(step: &str, eda_id: &str, kicad_info: &Value, main: Option) -> Option<(i64, String)> { +/// With the run's board known (`stem`), only an editor whose title names it: another project's +/// schematic editor open on the same desktop is not this run's to film. +pub fn tool_window(step: &str, eda_id: &str, kicad_info: &Value, main: Option, stem: &str) -> Option<(i64, String)> { if !tool_step(step) { return None; } if eda_id == "kicad" { let d = kicad_info.get("data").cloned().unwrap_or(kicad_info.clone()); let eds = d["editors"].as_array().cloned().unwrap_or_default(); let want = |w: &Value| { let k = w["kind"].as_str().unwrap_or("").to_ascii_lowercase(); if step == "schematic" { k.contains("sch") } else { k.contains("symbol") || k.contains("footprint") || k.contains("lib") } }; - return eds.iter().find(|w| want(w)).and_then(|w| w["hwnd"].as_i64().map(|h| (h, format!("KiCad's {}", w["kind"].as_str().unwrap_or("editor").replace('_', " "))))); + let ours = |w: &Value| stem.is_empty() || aiflow_bridge::eda::names_board(w["title"].as_str().unwrap_or(""), stem); + return eds.iter().find(|w| want(w) && ours(w)).and_then(|w| w["hwnd"].as_i64().map(|h| (h, format!("KiCad's {}", w["kind"].as_str().unwrap_or("editor").replace('_', " "))))); } main.map(|h| (h, format!("the {eda_id} window"))) } @@ -80,7 +83,7 @@ let tool = if tool_step(step) { let info = if cam.id() == "kicad" { br.raw_call("kicad_window_info", &json!({})) } else { Value::Null }; let main = if cam.id() == "kicad" { None } else { cam.main_window(&tx) }; - tool_window(step, cam.id(), &info, main) + tool_window(step, cam.id(), &info, main, &aiflow_bridge::eda::run_kicad_stem()) } else { None }; let windows = windows_of(&br.raw_call("desktop_list_windows", &json!({}))); preboard_camera(step, &windows, engine, tool) @@ -235,10 +238,30 @@ #[test] fn the_schematic_editor_is_the_tool_window_of_the_schematic_step() { let info = json!({"data": {"editors": [{"kind": "pcb_editor", "hwnd": 1}, {"kind": "schematic_editor", "hwnd": 2}]}}); - assert_eq!(tool_window("schematic", "kicad", &info, None), Some((2, "KiCad's schematic editor".into()))); - assert_eq!(tool_window("schematic", "kicad", &json!({"data": {"editors": [{"kind": "pcb_editor", "hwnd": 1}]}}), None), None); - assert_eq!(tool_window("design", "kicad", &info, None), None); - assert_eq!(tool_window("library", "fusion", &Value::Null, Some(9)), Some((9, "the fusion window".into()))); + assert_eq!(tool_window("schematic", "kicad", &info, None, ""), Some((2, "KiCad's schematic editor".into()))); + assert_eq!(tool_window("schematic", "kicad", &json!({"data": {"editors": [{"kind": "pcb_editor", "hwnd": 1}]}}), None, ""), None); + assert_eq!(tool_window("design", "kicad", &info, None, ""), None); + assert_eq!(tool_window("library", "fusion", &Value::Null, Some(9), ""), Some((9, "the fusion window".into()))); + } + + #[test] + fn another_projects_schematic_editor_is_never_filmed() { + // the human's own project open in another KiCad: its schematic editor is listed first + let info = json!({"data": {"editors": [ + {"kind": "schematic_editor", "hwnd": 397712, "title": "sense [other-esc/Sensing] — Schematic Editor"}, + {"kind": "pcb_editor", "hwnd": 461626, "title": "other-esc — PCB Editor"}, + {"kind": "pcb_editor", "hwnd": 1905110, "title": "esc-2s6s — PCB Editor"}]}}); + assert_eq!(tool_window("schematic", "kicad", &info, None, "esc-2s6s"), None); + let mut both = info.clone(); + both["data"]["editors"].as_array_mut().unwrap().push(json!({"kind": "schematic_editor", "hwnd": 77, "title": "esc-2s6s [esc-2s6s/] — Schematic Editor"})); + assert_eq!(tool_window("schematic", "kicad", &both, None, "esc-2s6s"), Some((77, "KiCad's schematic editor".into()))); + // a symbol editor showing the project's own PCM library counts as the project's + let lib = json!({"data": {"editors": [{"kind": "symbol_editor", "hwnd": 5, "title": "PCM_esc-2s6s:DRV8300DRGER — Symbol Editor"}]}}); + assert_eq!(tool_window("library", "kicad", &lib, None, "esc-2s6s"), Some((5, "KiCad's symbol editor".into()))); + // the board steps' window: the run's PCB editor, not the first one listed + assert_eq!(aiflow_bridge::pcb_editor_of(&info["data"], "esc-2s6s"), Some(1905110)); + assert_eq!(aiflow_bridge::pcb_editor_of(&info["data"], "esc"), None); + assert_eq!(aiflow_bridge::pcb_editor_of(&info["data"], ""), Some(461626)); } #[test]