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
//! 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());
    }
}