← Commit history

1.0.13: NATIVE (Rust) build. 1.0.13: KiCad window placement survives a bridge restart and a cold spawn, so a multi-monitor user keeps their monitor (wiki #48); a bridge restart closes KiCad gracefully first and kills only if that fails; kicadRunning and restartNeeded come from the real process scan instead of always answering unknown-restart-anyway (wiki #63); a symbol the editor cannot navigate to is no longer called library_cache_stale when this very call opened the editor (wiki #54, #71).

John Lauer ·4a6d9d7565 ·1mo ago ·parent b04d86b
3 files changed +186−5
rust/crates/kicad-core/src/pcm.rs+5−4
@@ -593,7 +593,7 @@ fn write_kicad_json(path: &Path, doc: &Value) -> Result<Option<PathBuf>, String>     Ok(backup) } -const REPO_RESTART_NOTE: &str = "KiCad reads pcm.repositories at start: a running KiCad keeps its old list until it is closed and launched again, and a KiCad that is running NOW will overwrite this edit when it saves its settings on exit, so close KiCad first when in doubt (the native build cannot tell whether KiCad is running: kicadRunning is null).";+const REPO_RESTART_NOTE: &str = "KiCad reads pcm.repositories at start: a running KiCad keeps its old list until it is closed and launched again, and a KiCad that is running NOW will overwrite this edit when it saves its settings on exit, so close KiCad first when kicadRunning is true or null.";  fn valid_repo_url(u: &str) -> bool {     (u.starts_with("https://") || u.starts_with("http://") || u.starts_with("file://")) && !u[u.find("://").unwrap_or(0) + 3..].is_empty()@@ -866,10 +866,11 @@ fn ident_of(entry: &Value) -> Option<&str> {     entry.get("package").and_then(|p| p.get("identifier")).and_then(Value::as_str) } -/// The native build has no process scan yet, so a running KiCad is not detected:-/// kicadRunning is null and restartNeeded is true (the safe answer).+/// kicadRunning / restartNeeded for a PCM verb: the process scan answers it where the OS+/// layer has one, and an unseeable KiCad is null with restartNeeded true (wiki #63). fn running_block() -> (Value, bool) {-    (Value::Null, true)+    let (running, restart, _, _) = crate::install_kicad::running_report();+    (running, restart) }  /// The repository URL an installed package came from: its registry row's repository_id
rust/crates/kicad-core/src/window_memory.rsadded+180
@@ -0,0 +1,180 @@+//! Where the user keeps each KiCad window (wiki #48).+//!+//! A fresh KiCad process places its windows by Windows defaults, which means the PRIMARY+//! display. A multi-monitor user who dragged the PCB Editor to their external monitor gets+//! it back on the laptop panel after any bridge-initiated restart, which is the complaint in+//! wiki #48 ("it keeps moving and annoying").+//!+//! So the bridge remembers, per window KIND (hwnds do not survive a restart), where the+//! window was and whether it was maximized, and puts it back after the relaunch with+//! `SetWindowPos` + `SWP_NOACTIVATE`. Restoring placement is not focus steal, so this stays+//! inside the background-first contract. The memory is persisted, so a COLD spawn on a box+//! that has run KiCad before also lands on the monitor the user last used.+//!+//! This module owns the file and the decisions; the caller owns window enumeration and the+//! actual move, because that lives in the bridge crate's window layer.++use std::collections::BTreeMap;+use std::path::PathBuf;++use serde_json::{json, Value};++use kicad_platform::Placement;++/// `~/.adom/kicad-bridge-placement.json`, the same path the Python build used, so a box that+/// upgrades from it keeps its memory.+pub fn memory_path() -> Option<PathBuf> {+    let home = std::env::var_os("ADOM_KICAD_PLACEMENT_HOME")+        .or_else(|| std::env::var_os("HOME"))+        .or_else(|| std::env::var_os("USERPROFILE"))+        .map(PathBuf::from)?;+    Some(home.join(".adom").join("kicad-bridge-placement.json"))+}++/// One remembered window: where it was, and when we saw it there.+#[derive(Clone, Debug)]+pub struct Remembered {+    pub place: Placement,+    pub at: String,+}++pub type Memory = BTreeMap<String, Remembered>;++fn i32_of(v: &Value, k: &str) -> Option<i32> {+    v.get(k).and_then(Value::as_i64).map(|n| n as i32)+}++pub fn from_json(doc: &Value) -> Memory {+    let mut out = Memory::new();+    let Some(windows) = doc.get("windows").and_then(Value::as_object) else { return out };+    for (kind, v) in windows {+        let (Some(x), Some(y), Some(width), Some(height)) = (i32_of(v, "x"), i32_of(v, "y"), i32_of(v, "width"), i32_of(v, "height")) else { continue };+        if width <= 0 || height <= 0 {+            continue; // a zero rect is not a placement, it is a window that was not up+        }+        out.insert(kind.clone(), Remembered {+            place: Placement { x, y, width, height, maximized: v.get("maximized").and_then(Value::as_bool).unwrap_or(false) },+            at: v.get("at").and_then(Value::as_str).unwrap_or("").to_string(),+        });+    }+    out+}++pub fn to_json(mem: &Memory) -> Value {+    let mut windows = serde_json::Map::new();+    for (kind, r) in mem {+        windows.insert(kind.clone(), json!({+            "x": r.place.x, "y": r.place.y, "width": r.place.width, "height": r.place.height,+            "maximized": r.place.maximized, "at": r.at,+        }));+    }+    json!({"version": 1, "why": "KiCad window placement per window kind, so a bridge restart puts each window back where the user had it (wiki #48)", "windows": Value::Object(windows)})+}++pub fn load() -> Memory {+    memory_path()+        .and_then(|p| std::fs::read_to_string(p).ok())+        .and_then(|s| serde_json::from_str::<Value>(&s).ok())+        .map(|d| from_json(&d))+        .unwrap_or_default()+}++pub fn save(mem: &Memory) -> Result<PathBuf, String> {+    let path = memory_path().ok_or_else(|| "neither HOME nor USERPROFILE is set, so there is nowhere to remember placement".to_string())?;+    if let Some(dir) = path.parent() {+        std::fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;+    }+    let body = serde_json::to_string_pretty(&to_json(mem)).unwrap_or_default() + "\n";+    std::fs::write(&path, body).map_err(|e| format!("cannot write {}: {e}", path.display()))?;+    Ok(path)+}++/// Fold one observed window into the memory. A minimized or zero-sized window is NOT+/// remembered: its rect says nothing about where the user keeps it.+pub fn observe(mem: &mut Memory, kind: &str, place: &Placement, minimized: bool, now: &str) -> bool {+    if minimized || place.width <= 0 || place.height <= 0 {+        return false;+    }+    mem.insert(kind.to_string(), Remembered { place: place.clone(), at: now.to_string() });+    true+}++/// What to do with one window when the memory has an entry for its kind.+#[derive(Debug, PartialEq)]+pub enum Restore {+    /// Leave it alone, with the reason for the reply.+    Skip(&'static str),+    /// Put it back here.+    Move(Placement),+}++/// The restore decision. `on_monitor` is whether the remembered rect still overlaps a+/// display that is connected NOW: an undocked laptop must not have its window moved onto a+/// monitor that is no longer there.+pub fn decide(remembered: &Placement, current: &Placement, on_monitor: bool) -> Restore {+    if !on_monitor {+        return Restore::Skip("the remembered rect is on no monitor that is connected now");+    }+    if remembered.maximized {+        return if current.maximized { Restore::Skip("already maximized") } else { Restore::Move(remembered.clone()) };+    }+    if current.maximized {+        return Restore::Move(remembered.clone()); // the user's own rect beats a default maximize+    }+    let same = (remembered.x - current.x).abs() <= 2+        && (remembered.y - current.y).abs() <= 2+        && (remembered.width - current.width).abs() <= 2+        && (remembered.height - current.height).abs() <= 2;+    if same { Restore::Skip("already where the user had it") } else { Restore::Move(remembered.clone()) }+}++#[cfg(test)]+mod tests {+    use super::*;++    fn p(x: i32, y: i32, w: i32, h: i32, max: bool) -> Placement {+        Placement { x, y, width: w, height: h, maximized: max }+    }++    #[test]+    fn a_window_on_a_second_monitor_is_remembered_and_put_back() {+        let mut mem = Memory::new();+        // The external monitor sits to the right of a 2560-wide primary.+        assert!(observe(&mut mem, "pcb_editor", &p(2600, 120, 1600, 1000, false), false, "2026-09-17T19:00:00Z"));+        let back = mem.get("pcb_editor").unwrap().place.clone();+        // A fresh process put it on the primary: move it back.+        assert_eq!(decide(&back, &p(120, 90, 1600, 1000, false), true), Restore::Move(back.clone()));+        // Same place already: leave it alone, so a restore never fights the user.+        assert_eq!(decide(&back, &p(2601, 121, 1600, 1000, false), true), Restore::Skip("already where the user had it"));+        // Undocked: the monitor is gone, so nothing is moved anywhere.+        assert_eq!(decide(&back, &p(120, 90, 1600, 1000, false), false), Restore::Skip("the remembered rect is on no monitor that is connected now"));+    }++    #[test]+    fn maximized_is_carried_and_a_parked_or_minimized_window_is_never_learned() {+        let mut mem = Memory::new();+        assert!(observe(&mut mem, "symbol_editor", &p(0, 0, 2560, 1440, true), false, "now"));+        assert_eq!(decide(&mem["symbol_editor"].place, &p(0, 0, 2560, 1440, true), true), Restore::Skip("already maximized"));+        assert!(matches!(decide(&mem["symbol_editor"].place, &p(300, 200, 900, 600, false), true), Restore::Move(_)));+        // The park position and a minimized window teach the memory nothing (the 0.9.224 trap:+        // a window closed while parked made KiCad save -32000 as its geometry).+        assert!(!observe(&mut mem, "pcb_editor", &p(-32000, -32000, 237, 39, false), true, "now"));+        assert!(!observe(&mut mem, "pcb_editor", &p(100, 100, 0, 0, false), false, "now"));+        assert!(!mem.contains_key("pcb_editor"));+    }++    #[test]+    fn the_file_round_trips_and_junk_rows_are_dropped() {+        let mut mem = Memory::new();+        observe(&mut mem, "pcb_editor", &p(2600, 120, 1600, 1000, false), false, "2026-09-17T19:00:00Z");+        observe(&mut mem, "project_manager", &p(10, 20, 800, 600, true), false, "2026-09-17T19:00:00Z");+        let back = from_json(&to_json(&mem));+        assert_eq!(back.len(), 2);+        assert_eq!(back["pcb_editor"].place.x, 2600);+        assert!(back["project_manager"].place.maximized);+        assert_eq!(back["pcb_editor"].at, "2026-09-17T19:00:00Z");+        // A row with no rect, or a zero rect, is not a placement.+        let junk = from_json(&json!({"windows": {"a": {"x": 1}, "b": {"x": 1, "y": 2, "width": 0, "height": 0}}}));+        assert!(junk.is_empty());+    }+}
rust/crates/kicad-platform/src/lib.rs+1−1
@@ -82,7 +82,7 @@ pub struct MenuItem {     pub accel: Option<String>, } -#[derive(Clone, Debug, Default, Serialize)]+#[derive(Clone, Debug, Default, PartialEq, Serialize)] pub struct Placement {     pub x: i32,     pub y: i32,