app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
master
John Lauer
1.0.34: 1.0.34 (wiki #107): the window placement memory reads the flat file the Python build (0.9.x) wrote, so an upgra…
134c93f
1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
//! 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)
}
fn legacy_kind(k: &str) -> String {
match k {
"kicad" => "project_manager".to_string(),
other => other.replace(' ', "_"),
}
}
pub fn from_json(doc: &Value) -> Memory {
let mut out = Memory::new();
// The Python build (0.9.x) wrote a flat file: {"pcb editor": {x, y, w, h, maximized}}. Read it
// too, or the first native save replaces it and the user's other window kinds are lost
// (seen on arav-rog, wiki #107).
let legacy = doc.get("windows").is_none();
let Some(windows) = doc.get("windows").and_then(Value::as_object).or_else(|| if legacy { doc.as_object() } else { None }) else { return out };
for (kind, v) in windows {
let kind = &if legacy { legacy_kind(kind) } else { kind.clone() };
let (Some(x), Some(y), Some(width), Some(height)) = (i32_of(v, "x"), i32_of(v, "y"), i32_of(v, "width").or_else(|| i32_of(v, "w")), i32_of(v, "height").or_else(|| i32_of(v, "h"))) 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 the_python_builds_flat_file_is_read_not_discarded() {
let doc = json!({"pcb editor": {"x": -2300, "y": 300, "w": 1600, "h": 1000, "maximized": false},
"kicad": {"x": -11, "y": -11, "w": 2582, "h": 1390, "maximized": true},
"3d viewer": {"x": 0, "y": 0, "w": 0, "h": 0, "maximized": false}});
let mem = from_json(&doc);
assert_eq!(mem.get("pcb_editor").unwrap().place, p(-2300, 300, 1600, 1000, false));
assert!(mem.get("project_manager").unwrap().place.maximized);
assert!(mem.get("3d_viewer").is_none());
}
#[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());
}
}