Skip to main content

teksilo_settings/
window_state.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Per-window geometry persistence via [`WindowStateService`].
5//!
6//! Each named window — identified by a stable string label such as
7//! `"main"` or `"inspector"` — can have its position, size, and
8//! placement (`Floating` / `Maximized` / `Fullscreen`) saved across
9//! sessions. In-memory is the source of truth: [`state_for`] reads
10//! directly from memory without touching disk. On load, the file is
11//! migrated through [`Migrator`] steps (currently v1 → v2: `maximized:
12//! bool` → `placement: WindowPlacement`) before deserializing, and
13//! corrupt files are quarantined automatically by [`SettingsFile`](crate::SettingsFile).
14//!
15//! ## `record` is debounced, not synchronous
16//!
17//! See [`WindowStateService`]'s "Why this is debounced, unlike
18//! `SettingsFile`" doc below for the full rationale: [`record`]/[`forget`]
19//! update the in-memory state instantly and schedule a coalesced, locked
20//! read-merge-write via a [`DebouncedWriter`] — a live window drag (which
21//! calls [`record`] once per reported geometry frame) costs one disk
22//! write per debounce window, not one per frame.
23//!
24//! In a typical Teksilo app, `WindowStateService` is managed by the
25//! framework's `SettingsBundle` and wired automatically when the
26//! `WindowConfig` carries a stable `id(...)` — no widget-side plumbing
27//! needed. The service is only used directly when building custom window
28//! management or embedding it outside the standard `TeksiloAppBuilder`
29//! path.
30//!
31//! ## Wayland caveat
32//!
33//! Wayland does not let applications choose their window position;
34//! the compositor places windows. Position fields (`x`, `y`) are still
35//! recorded and persisted (so the config roams across an X11/Wayland
36//! switch), but a Wayland host must ignore them when restoring.
37//! Width, height, and [`WindowPlacement`] are honored on every platform.
38//!
39//! ## Example
40//!
41//! ```ignore
42//! use std::time::Duration;
43//! use teksilo_settings::{AppPaths, WindowStateService, PerWindowState};
44//! use teksilo_core::WindowPlacement;
45//!
46//! // In tests use AppPaths::for_testing(tmp_dir); in production use AppPaths::new(...).
47//! let paths = AppPaths::for_testing(std::path::Path::new("/tmp/my-app"));
48//! let svc = WindowStateService::open_with_delay(&paths, Duration::ZERO).unwrap();
49//!
50//! // On window move / resize, record the new geometry.
51//! svc.record(PerWindowState {
52//!     label: "main".into(),
53//!     x: 100, y: 80,
54//!     width: 1280, height: 800,
55//!     placement: WindowPlacement::Floating,
56//! }).unwrap();
57//!
58//! // On next launch, restore if available.
59//! if let Some(saved) = svc.state_for("main") {
60//!     let ready = saved.sanitize((400, 300), (1920, 1080));
61//!     println!("restore to {}x{} at ({},{})", ready.width, ready.height, ready.x, ready.y);
62//! }
63//! ```
64//!
65//! [`record`]: WindowStateService::record
66//! [`forget`]: WindowStateService::forget
67//! [`state_for`]: WindowStateService::state_for
68//! [`DebouncedWriter`]: crate::flush::DebouncedWriter
69
70use std::cell::{Cell, RefCell};
71use std::path::{Path, PathBuf};
72use std::rc::Rc;
73use std::sync::{Arc, Mutex};
74use std::time::{Duration, SystemTime};
75
76use serde::{Deserialize, Serialize};
77use teksilo_core::WindowPlacement;
78
79use crate::DEFAULT_DEBOUNCE;
80use crate::file::{SettingsFileError, disk_stamp, read_toml_with_retry};
81use crate::flush::{DebouncedWriter, FlushError};
82use crate::migration::{MigrationError, Migrator, Versioned};
83use crate::path::AppPaths;
84use crate::reload::Reloadable;
85
86/// Persisted geometry for one labeled window.
87///
88/// `placement` captures the full `WindowPlacement` enum (Floating /
89/// Maximized / Fullscreen / Minimized). On restore, `Minimized` is
90/// downgraded to `Floating` so the app doesn't appear to fail to
91/// start; every other variant is honored if the OS supports it
92/// (Wayland will ignore `position` regardless — see `sanitize`'s
93/// docs).
94#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
95pub struct PerWindowState {
96    pub label: String,
97    pub x: i32,
98    pub y: i32,
99    pub width: u32,
100    pub height: u32,
101    #[serde(default)]
102    pub placement: WindowPlacement,
103}
104
105impl PerWindowState {
106    /// Validate this state against a `(width, height)` work area
107    /// (typically the size of the largest available monitor's usable
108    /// region) and return a sanitized copy:
109    ///
110    /// * `width` / `height` are clamped to `[min, work_area]`. If the
111    ///   minimum is larger than the work area the work area wins —
112    ///   this should not happen with sensible mins (e.g. 320x240).
113    /// * The position is checked: if the **top-left** point lies
114    ///   outside `[0, work_area_w) x [0, work_area_h)` *and* the
115    ///   window would not have at least 50 logical-pixel intersection
116    ///   with the work area, the position is recentered on the
117    ///   monitor so the window comes back on screen instead of
118    ///   spawning at coordinates from a missing monitor.
119    /// * `maximized` and `label` are preserved.
120    ///
121    /// Use this on app startup with `(work_area_w, work_area_h)`
122    /// pulled from the OS (e.g. winit's `MonitorHandle::size()` minus
123    /// known taskbars). Without an OS hint, pass conservative
124    /// fallbacks like `(1920, 1080)` — the result still improves on
125    /// re-using stale coordinates from a monitor that's no longer
126    /// connected.
127    pub fn sanitize(&self, min_size: (u32, u32), work_area: (u32, u32)) -> PerWindowState {
128        let (min_w, min_h) = min_size;
129        let (max_w, max_h) = work_area;
130
131        let width = clamp_size(self.width, min_w, max_w);
132        let height = clamp_size(self.height, min_h, max_h);
133
134        // Compute the intersection between the saved rectangle and
135        // the work area, axis by axis. Recenter only the axes that
136        // actually fall short of `MIN_VISIBLE_PX` — preserving the
137        // user's position on any axis that's still on-screen.
138        const MIN_VISIBLE_PX: i32 = 50;
139        let saved_right = self.x.saturating_add(width as i32);
140        let saved_bottom = self.y.saturating_add(height as i32);
141        let visible_w = saved_right.min(max_w as i32) - self.x.max(0);
142        let visible_h = saved_bottom.min(max_h as i32) - self.y.max(0);
143
144        let x = if visible_w < MIN_VISIBLE_PX {
145            ((max_w as i32) - (width as i32)).max(0) / 2
146        } else {
147            self.x
148        };
149        let y = if visible_h < MIN_VISIBLE_PX {
150            ((max_h as i32) - (height as i32)).max(0) / 2
151        } else {
152            self.y
153        };
154
155        // Minimized is downgraded on restore: a window that comes
156        // back invisible looks like the app failed to start. Every
157        // other placement variant round-trips.
158        let placement = match self.placement {
159            WindowPlacement::Minimized => WindowPlacement::Floating,
160            other => other,
161        };
162
163        PerWindowState {
164            label: self.label.clone(),
165            x,
166            y,
167            width,
168            height,
169            placement,
170        }
171    }
172}
173
174fn clamp_size(value: u32, min: u32, max: u32) -> u32 {
175    if max < min {
176        // Pathological hint — return the larger of the two so we
177        // don't go below the user's declared minimum, and don't
178        // produce a 0-sized window.
179        return min.max(1);
180    }
181    value.clamp(min, max).max(1)
182}
183
184#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
185pub(crate) struct WindowStateFile {
186    #[serde(default = "default_version")]
187    pub version: u32,
188    #[serde(default = "Vec::new")]
189    pub windows: Vec<PerWindowState>,
190}
191
192fn default_version() -> u32 {
193    WindowStateFile::CURRENT_VERSION
194}
195
196impl Default for WindowStateFile {
197    fn default() -> Self {
198        Self {
199            version: WindowStateFile::CURRENT_VERSION,
200            windows: Vec::new(),
201        }
202    }
203}
204
205impl Versioned for WindowStateFile {
206    /// v1: `maximized: bool`
207    /// v2: `placement: WindowPlacement` (full enum). Migration step
208    ///     below converts `maximized = true` → `placement = "Maximized"`.
209    const CURRENT_VERSION: u32 = 2;
210    fn version(&self) -> u32 {
211        self.version
212    }
213    fn set_version(&mut self, v: u32) {
214        self.version = v;
215    }
216}
217
218/// v1 → v2: replace each window entry's `maximized: bool` with
219/// `placement: WindowPlacement` (`"Maximized"` if `maximized = true`,
220/// `"Floating"` otherwise). The bool is dropped from the v2 shape.
221fn migrate_v1_to_v2(mut raw: toml::Value) -> Result<toml::Value, String> {
222    let table = raw
223        .as_table_mut()
224        .ok_or_else(|| "WindowStateFile root is not a table".to_string())?;
225
226    if let Some(windows) = table.get_mut("windows").and_then(|v| v.as_array_mut()) {
227        for entry in windows {
228            let Some(entry_table) = entry.as_table_mut() else {
229                continue;
230            };
231            let was_maximized = entry_table
232                .get("maximized")
233                .and_then(|v| v.as_bool())
234                .unwrap_or(false);
235            entry_table.remove("maximized");
236            entry_table.insert(
237                "placement".into(),
238                toml::Value::String(
239                    if was_maximized {
240                        "Maximized"
241                    } else {
242                        "Floating"
243                    }
244                    .into(),
245                ),
246            );
247        }
248    }
249    Ok(raw)
250}
251
252fn make_migrator() -> Migrator<WindowStateFile> {
253    Migrator::new().step(1, migrate_v1_to_v2)
254}
255
256/// Read + migrate the document at `path`, or `default` when it does not exist.
257fn read_window_state_or_default(
258    path: &Path,
259    migrator: &Migrator<WindowStateFile>,
260) -> Result<WindowStateFile, SettingsFileError> {
261    match read_toml_with_retry(path)? {
262        Some(raw) => {
263            let mut file = migrator.run(raw).map_err(SettingsFileError::Migrate)?;
264            file.version = <WindowStateFile as Versioned>::CURRENT_VERSION;
265            Ok(file)
266        }
267        None => Ok(WindowStateFile::default()),
268    }
269}
270
271/// Like [`read_window_state_or_default`], but for text already in hand — which
272/// is what a [`crate::flush::Patch`] closure receives (the current file text,
273/// read by the worker under the lock) rather than a path to re-read.
274fn parse_window_state_text(
275    text: Option<&str>,
276    migrator: &Migrator<WindowStateFile>,
277) -> Result<WindowStateFile, SettingsFileError> {
278    match text {
279        Some(text) => {
280            let raw: toml::Value = toml::from_str(text).map_err(SettingsFileError::Parse)?;
281            let mut file = migrator.run(raw).map_err(SettingsFileError::Migrate)?;
282            file.version = <WindowStateFile as Versioned>::CURRENT_VERSION;
283            Ok(file)
284        }
285        None => Ok(WindowStateFile::default()),
286    }
287}
288
289/// Document the migration error type publicly so callers can pattern
290/// match — used in tests below.
291#[allow(dead_code)]
292fn _migration_error_is_exported(_: MigrationError) {}
293
294/// One replayable mutation of the window-state document.
295///
296/// Pure owned data (a `PerWindowState`, a label), so it is `Send` and can be
297/// captured into a [`crate::flush::Patch`] and applied on the writer thread to
298/// the document *just read under the lock* — which is what lets two processes
299/// record two different windows without either erasing the other.
300#[derive(Clone, Debug)]
301enum WindowOp {
302    Set(Box<PerWindowState>),
303    Forget(String),
304}
305
306fn apply_window_op(windows: &mut Vec<PerWindowState>, op: &WindowOp) {
307    match op {
308        WindowOp::Set(state) => match windows.iter_mut().find(|w| w.label == state.label) {
309            Some(existing) => *existing = (**state).clone(),
310            None => windows.push((**state).clone()),
311        },
312        WindowOp::Forget(label) => windows.retain(|w| w.label != *label),
313    }
314}
315
316/// Persistent, in-memory-backed store for per-window geometry.
317///
318/// Entries are [`PerWindowState`], keyed by a stable string label. [`state_for`]
319/// reads straight from memory with no I/O.
320///
321/// ## Why this is debounced, unlike [`SettingsFile`](crate::SettingsFile)
322///
323/// `SettingsFile`'s `mutate` is a *synchronous* locked read-modify-write, which
324/// is right for a document written rarely (a settings change; one record per
325/// backup). Window geometry is the opposite: `teksilo-app`'s `window_persist`
326/// observes the `size` / `position` / `placement` signals and calls [`record`]
327/// on **every change** — i.e. once per frame while the user drags a window. A
328/// synchronous `flock` + read + parse + serialize + fsync per frame would make
329/// dragging visibly janky.
330///
331/// So this service owns its own [`DebouncedWriter`] and schedules a
332/// `WindowOp` patch per `record`, exactly like [`crate::PersistedListModel`]:
333/// in-memory state updates instantly (so `state_for` is always current), and
334/// the burst collapses into **one** locked read-merge-write at the debounce
335/// deadline. Frequent writes ⇒ debounced patch; rare writes ⇒ synchronous
336/// locked RMW. Both are cross-process correct; they differ only in when the
337/// disk write happens.
338///
339/// [`record`]: WindowStateService::record
340/// [`state_for`]: WindowStateService::state_for
341#[derive(Clone)]
342pub struct WindowStateService {
343    /// Instant, authoritative-for-reads view. Kept in step with every op.
344    current: Rc<RefCell<WindowStateFile>>,
345    writer: Rc<DebouncedWriter>,
346    migrator: Migrator<WindowStateFile>,
347    /// `(mtime, len)` of the last write *we* made — so the file watcher can tell
348    /// a peer's write from the echo of our own and not reload pointlessly.
349    last_known_stamp: Rc<Cell<(Option<SystemTime>, Option<u64>)>>,
350    /// The REAL post-write `(mtime, len)` stamp of our own most recent
351    /// debounced write, delivered by [`DebouncedWriter`]'s
352    /// [`WriteLandedSink`](crate::flush::WriteLandedSink) the instant it
353    /// lands on the shared worker thread — `Arc<Mutex<_>>`, not `Rc<Cell<_>>`,
354    /// because it is written from that (non-UI) thread. `reload_from_disk`
355    /// drains it (adopting the value into `last_known_stamp`) before doing
356    /// its own `disk_stamp` comparison, so a debounced `apply()` write no
357    /// longer looks like a peer's change and forces a wasted re-parse (F11).
358    pending_write_stamp: Arc<Mutex<Option<crate::flush::LandedStamp>>>,
359}
360
361impl WindowStateService {
362    /// Open the window-state file at the standard location inside `paths`.
363    pub fn open(paths: &AppPaths) -> Result<Self, SettingsFileError> {
364        Self::open_at(paths.data_file("window_state"), DEFAULT_DEBOUNCE)
365    }
366
367    /// Open at the standard location with an explicit debounce window.
368    pub fn open_with_delay(paths: &AppPaths, delay: Duration) -> Result<Self, SettingsFileError> {
369        Self::open_at(paths.data_file("window_state"), delay)
370    }
371
372    /// Open the window-state file at an explicit `path`.
373    ///
374    /// `delay` is the debounce window: geometry changes arriving inside it
375    /// coalesce into a single disk write. `Duration::ZERO` writes on the
376    /// worker's next tick (used by tests).
377    pub fn open_at(path: PathBuf, delay: Duration) -> Result<Self, SettingsFileError> {
378        let migrator = make_migrator();
379        let current = read_window_state_or_default(&path, &migrator)?;
380        let stamp = disk_stamp(&path);
381        let writer = Rc::new(DebouncedWriter::new(path, delay));
382        let pending_write_stamp = Arc::new(Mutex::new(None));
383        let pending_write_stamp_for_sink = Arc::clone(&pending_write_stamp);
384        writer.set_landed_sink(Arc::new(move |landed| {
385            *pending_write_stamp_for_sink.lock().unwrap() = Some(landed);
386        }));
387        Ok(Self {
388            current: Rc::new(RefCell::new(current)),
389            writer,
390            migrator,
391            last_known_stamp: Rc::new(Cell::new(stamp)),
392            pending_write_stamp,
393        })
394    }
395
396    /// Saved state for the window with `label`, or `None` if there's no entry.
397    pub fn state_for(&self, label: &str) -> Option<PerWindowState> {
398        self.current
399            .borrow()
400            .windows
401            .iter()
402            .find(|w| w.label == label)
403            .cloned()
404    }
405
406    /// Record the current geometry for `label`, replacing any prior entry.
407    ///
408    /// Updates memory immediately and schedules a debounced, locked
409    /// read-merge-write — so a drag costs one write, not one per frame.
410    pub fn record(&self, state: PerWindowState) -> Result<(), SettingsFileError> {
411        self.apply(WindowOp::Set(Box::new(state)));
412        Ok(())
413    }
414
415    /// Forget the entry for `label`.
416    pub fn forget(&self, label: &str) -> Result<(), SettingsFileError> {
417        self.apply(WindowOp::Forget(label.to_string()));
418        Ok(())
419    }
420
421    /// All recorded labels. Useful for "restore last session" features.
422    pub fn labels(&self) -> Vec<String> {
423        self.current
424            .borrow()
425            .windows
426            .iter()
427            .map(|w| w.label.clone())
428            .collect()
429    }
430
431    fn apply(&self, op: WindowOp) {
432        apply_window_op(&mut self.current.borrow_mut().windows, &op);
433
434        let migrator = self.migrator.clone();
435        let patch: crate::flush::Patch = Box::new(move |current: Option<String>| {
436            // Merge against the document as it is on disk RIGHT NOW, not against
437            // this process's snapshot — a peer may have recorded its own window
438            // in the meantime, and it must survive.
439            let mut file = parse_window_state_text(current.as_deref(), &migrator)
440                .map_err(|e| FlushError::Merge(e.to_string()))?;
441            apply_window_op(&mut file.windows, &op);
442            file.version = <WindowStateFile as Versioned>::CURRENT_VERSION;
443            toml::to_string_pretty(&file).map_err(|e| FlushError::Merge(e.to_string()))
444        });
445        self.writer.schedule(patch);
446    }
447
448    /// Flush any pending geometry to disk immediately, bypassing the debounce.
449    ///
450    /// Flushes the **op queue**, never a re-derived snapshot of the in-memory
451    /// document — dumping the snapshot is exactly how a cleanly-exiting process
452    /// would erase a peer's window entry.
453    pub fn flush_now(&self) -> Result<(), SettingsFileError> {
454        self.writer.flush_now().map_err(SettingsFileError::Flush)?;
455        self.last_known_stamp.set(disk_stamp(self.writer.path()));
456        Ok(())
457    }
458
459    /// Absolute path of the underlying TOML file managed by this service.
460    pub fn path(&self) -> &Path {
461        self.writer.path()
462    }
463}
464
465impl Reloadable for WindowStateService {
466    fn path(&self) -> &Path {
467        WindowStateService::path(self)
468    }
469
470    /// Pick up a peer process's window entry.
471    ///
472    /// Merging is trivially safe here: entries are keyed by `label`, and
473    /// distinct labels (distinct windows) never conflict — so whatever a peer
474    /// wrote simply appears. The `(mtime, len)` stamp check short-circuits the
475    /// echo of our *own* write, and the content comparison guarantees we never
476    /// touch anything when nothing actually changed.
477    fn reload_from_disk(&self) -> Result<bool, SettingsFileError> {
478        // Adopt our own debounced write's REAL landed stamp, if one arrived
479        // since we last looked (F11). This is exact, not probabilistic: the
480        // value was produced by an actual `fs::metadata` call taken on the
481        // worker thread immediately after our own write really landed, so
482        // adopting it can never cause a later, genuinely distinct peer write
483        // to be missed — the very next `disk_stamp` call below will differ
484        // from this now-current `last_known_stamp` if anything further
485        // changed on disk.
486        if let Some(landed) = self.pending_write_stamp.lock().unwrap().take() {
487            self.last_known_stamp.set(landed);
488        }
489
490        let path = self.writer.path();
491        let current_stamp = disk_stamp(path);
492        if current_stamp == self.last_known_stamp.get() {
493            return Ok(false);
494        }
495
496        let file = read_window_state_or_default(path, &self.migrator)?;
497        self.last_known_stamp.set(current_stamp);
498
499        if *self.current.borrow() == file {
500            return Ok(false);
501        }
502        *self.current.borrow_mut() = file;
503        Ok(true)
504    }
505}
506
507impl std::fmt::Debug for WindowStateService {
508    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
509        f.debug_struct("WindowStateService")
510            .field("path", &self.path())
511            .field("labels", &self.labels())
512            .finish()
513    }
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519    use tempfile::tempdir;
520
521    fn open(dir: &Path) -> WindowStateService {
522        let paths = AppPaths::for_testing(dir);
523        WindowStateService::open_with_delay(&paths, Duration::ZERO).unwrap()
524    }
525
526    #[test]
527    fn record_then_recall() {
528        let dir = tempdir().unwrap();
529        let svc = open(dir.path());
530
531        svc.record(PerWindowState {
532            label: "main".into(),
533            x: 100,
534            y: 200,
535            width: 800,
536            height: 600,
537            placement: WindowPlacement::Floating,
538        })
539        .unwrap();
540
541        let got = svc.state_for("main").unwrap();
542        assert_eq!(got.x, 100);
543        assert_eq!(got.width, 800);
544    }
545
546    #[test]
547    fn record_replaces_existing_entry() {
548        let dir = tempdir().unwrap();
549        let svc = open(dir.path());
550
551        for i in 0..3 {
552            svc.record(PerWindowState {
553                label: "main".into(),
554                x: i,
555                y: 0,
556                width: 100,
557                height: 100,
558                placement: WindowPlacement::Floating,
559            })
560            .unwrap();
561        }
562        assert_eq!(svc.labels(), vec!["main".to_string()]);
563        assert_eq!(svc.state_for("main").unwrap().x, 2);
564    }
565
566    /// THE HEADLINE TEST for this type. Two independent `WindowStateService`
567    /// handles over the *same* file — standing in for two Skribisto
568    /// processes sharing `window_state.toml` — each record a *different*
569    /// window label with no coordination between them. Because `record`
570    /// always goes through the locked read-modify-write, both labels must
571    /// survive: distinct labels never conflict, so the merge is trivially
572    /// clean, but the *old* whole-snapshot design would still have let one
573    /// handle's stale in-memory copy clobber the other's label entirely.
574    #[test]
575    fn two_handles_recording_different_labels_both_survive() {
576        let dir = tempdir().unwrap();
577        let paths = AppPaths::for_testing(dir.path());
578
579        let a = WindowStateService::open(&paths).unwrap();
580        let b = WindowStateService::open(&paths).unwrap();
581
582        a.record(PerWindowState {
583            label: "main".into(),
584            x: 10,
585            y: 20,
586            width: 800,
587            height: 600,
588            placement: WindowPlacement::Floating,
589        })
590        .unwrap();
591        b.record(PerWindowState {
592            label: "inspector".into(),
593            x: 900,
594            y: 20,
595            width: 300,
596            height: 600,
597            placement: WindowPlacement::Floating,
598        })
599        .unwrap();
600
601        // `record` is debounced (geometry changes arrive once per frame during a
602        // drag), so force both queues out before reading the file back.
603        a.flush_now().unwrap();
604        b.flush_now().unwrap();
605
606        // A third, fresh handle proves both labels are actually on disk
607        // together.
608        let c = WindowStateService::open(&paths).unwrap();
609        let mut labels = c.labels();
610        labels.sort();
611        assert_eq!(labels, vec!["inspector".to_string(), "main".to_string()]);
612        assert_eq!(c.state_for("main").unwrap().width, 800);
613        assert_eq!(c.state_for("inspector").unwrap().width, 300);
614    }
615
616    /// A window drag fires `record` on every frame. Those must coalesce into
617    /// **one** disk write, not one per frame.
618    ///
619    /// This is a regression guard: `record` originally went through
620    /// `SettingsFile::mutate`, whose write is a *synchronous* locked
621    /// read-modify-write (right for rarely-written documents like `backup.toml`,
622    /// catastrophic here) — so a drag did a `flock` + read + parse + serialize +
623    /// fsync **per frame**.
624    #[test]
625    fn a_burst_of_records_coalesces_into_one_write() {
626        let dir = tempdir().unwrap();
627        let path = dir.path().join("window_state.toml");
628        // A real debounce window, so the burst genuinely has something to
629        // coalesce into.
630        let svc = WindowStateService::open_at(path.clone(), Duration::from_millis(50)).unwrap();
631
632        for i in 0..60 {
633            svc.record(PerWindowState {
634                label: "main".into(),
635                x: i,
636                y: i,
637                width: 800,
638                height: 600,
639                placement: WindowPlacement::Floating,
640            })
641            .unwrap();
642        }
643
644        // Nothing has touched the disk yet: 60 frames of dragging, zero writes.
645        assert!(
646            !path.exists(),
647            "a burst of records must not write per-record"
648        );
649
650        svc.flush_now().unwrap();
651
652        // One write, carrying the LAST geometry — no intermediate frame leaked.
653        let on_disk = read_window_state_or_default(&path, &make_migrator()).unwrap();
654        assert_eq!(on_disk.windows.len(), 1);
655        assert_eq!(on_disk.windows[0].x, 59);
656        // ...and memory agreed all along, without any I/O.
657        assert_eq!(svc.state_for("main").unwrap().x, 59);
658    }
659
660    #[test]
661    fn reload_from_disk_picks_up_a_peers_recorded_label() {
662        let dir = tempdir().unwrap();
663        let paths = AppPaths::for_testing(dir.path());
664
665        let a = WindowStateService::open(&paths).unwrap();
666        let b = WindowStateService::open(&paths).unwrap();
667
668        a.record(PerWindowState {
669            label: "main".into(),
670            x: 1,
671            y: 2,
672            width: 111,
673            height: 222,
674            placement: WindowPlacement::Floating,
675        })
676        .unwrap();
677        a.flush_now().unwrap(); // `record` is debounced — force it to disk
678
679        assert!(b.state_for("main").is_none(), "b hasn't reloaded yet");
680        assert!(Reloadable::reload_from_disk(&b).unwrap());
681        assert_eq!(b.state_for("main").unwrap().width, 111);
682    }
683
684    /// Wait (bounded) for `svc`'s own debounced write to land on the shared
685    /// worker thread — confirmed by its `WriteLandedSink` callback actually
686    /// firing (`pending_write_stamp` becomes `Some`), not merely by the file
687    /// existing (which can be observed by this thread a hair before the
688    /// worker thread has finished running the sink in the same tick).
689    ///
690    /// Deliberately does **not** use `flush_now()`: that method independently
691    /// sets `last_known_stamp` itself (pre-existing code, unrelated to the
692    /// F11 fix), which would mask whether the fix under test did anything at
693    /// all. Polling `pending_write_stamp` directly exercises exactly the
694    /// natural, un-forced debounce path a live geometry drag takes.
695    fn wait_for_own_write_to_land(svc: &WindowStateService) {
696        let deadline = std::time::Instant::now() + Duration::from_secs(5);
697        loop {
698            if svc.pending_write_stamp.lock().unwrap().is_some() {
699                return;
700            }
701            assert!(
702                std::time::Instant::now() < deadline,
703                "debounced write never landed"
704            );
705            std::thread::sleep(Duration::from_millis(5));
706        }
707    }
708
709    /// THE F11 REGRESSION TEST. Proves `reload_from_disk` adopts our own
710    /// debounced write's REAL landed stamp (via `WriteLandedSink`) into
711    /// `last_known_stamp` *before* doing its disk-stamp staleness check —
712    /// and that the check that follows is driven purely by the `(mtime,
713    /// len)` stamp, never by re-reading content.
714    ///
715    /// We corrupt the on-disk file to invalid TOML but force its `(mtime,
716    /// len)` stamp to exactly match what our own write just produced (same
717    /// byte length, `File::set_modified` restores the exact mtime). Before
718    /// the fix, `last_known_stamp` is never refreshed by `apply()` — it is
719    /// still whatever `open_at` captured before any write happened — so it
720    /// cannot match *any* post-write stamp, forcing `reload_from_disk` to
721    /// attempt `read_window_state_or_default` on the now-corrupted file and
722    /// propagate a parse error. After the fix, the adopted stamp matches the
723    /// (deliberately stamp-preserved) corrupted file exactly, so the method
724    /// short-circuits to `Ok(false)` and the corrupted bytes are never
725    /// parsed at all.
726    #[test]
727    fn reload_from_disk_short_circuits_via_adopted_landed_stamp_not_content() {
728        let dir = tempdir().unwrap();
729        let path = dir.path().join("window_state.toml");
730        let svc = WindowStateService::open_at(path.clone(), Duration::ZERO).unwrap();
731
732        svc.record(PerWindowState {
733            label: "main".into(),
734            x: 1,
735            y: 2,
736            width: 111,
737            height: 222,
738            placement: WindowPlacement::Floating,
739        })
740        .unwrap();
741        wait_for_own_write_to_land(&svc);
742
743        let good_bytes = std::fs::read(&path).unwrap();
744        let good_mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
745
746        // Corrupt the content but preserve the exact (mtime, len) stamp —
747        // proving the short-circuit below is stamp-driven, not content-driven.
748        let mut garbage = vec![b'x'; good_bytes.len()];
749        garbage[0] = b'['; // still not valid TOML syntax
750        assert_eq!(garbage.len(), good_bytes.len(), "must preserve `len`");
751        std::fs::write(&path, &garbage).unwrap();
752        std::fs::OpenOptions::new()
753            .write(true)
754            .open(&path)
755            .unwrap()
756            .set_modified(good_mtime)
757            .unwrap();
758        assert_eq!(
759            disk_stamp(&path),
760            (Some(good_mtime), Some(good_bytes.len() as u64)),
761            "test setup must reproduce the exact landed stamp"
762        );
763        // Sanity: the corrupted bytes really are invalid TOML, so a
764        // read+parse attempt (the pre-fix behavior) would error out.
765        assert!(toml::from_str::<toml::Value>(&String::from_utf8(garbage).unwrap()).is_err());
766
767        let result = Reloadable::reload_from_disk(&svc);
768        assert!(
769            matches!(result, Ok(false)),
770            "expected Ok(false) via the adopted landed-stamp short-circuit \
771             (corrupted content must never be read), got {result:?}"
772        );
773    }
774
775    /// Correctness guard alongside the test above: the stamp-adoption fast
776    /// path must NOT swallow a genuine peer write that lands *after* our own.
777    #[test]
778    fn reload_from_disk_still_detects_a_peer_write_after_our_own_lands() {
779        let dir = tempdir().unwrap();
780        let paths = AppPaths::for_testing(dir.path());
781
782        let a = WindowStateService::open_with_delay(&paths, Duration::ZERO).unwrap();
783        a.record(PerWindowState {
784            label: "main".into(),
785            x: 1,
786            y: 2,
787            width: 111,
788            height: 222,
789            placement: WindowPlacement::Floating,
790        })
791        .unwrap();
792        wait_for_own_write_to_land(&a);
793
794        // A second, independent handle over the same file stands in for a
795        // peer process recording a *different* window label after `a`'s own
796        // write already landed.
797        let b = WindowStateService::open_with_delay(&paths, Duration::ZERO).unwrap();
798        b.record(PerWindowState {
799            label: "inspector".into(),
800            x: 9,
801            y: 9,
802            width: 300,
803            height: 400,
804            placement: WindowPlacement::Floating,
805        })
806        .unwrap();
807        wait_for_own_write_to_land(&b);
808
809        // `a` never called `reload_from_disk` before now, so its own
810        // (already-drained-on-next-call) landed stamp is still pending — the
811        // fast path must still notice that the *current* on-disk stamp has
812        // moved on past it (because of `b`'s later write) and do a real
813        // reload, not swallow the peer's change.
814        assert!(Reloadable::reload_from_disk(&a).unwrap());
815        assert_eq!(a.state_for("inspector").unwrap().width, 300);
816        // ...and `a`'s own entry is still intact after picking up the peer's.
817        assert_eq!(a.state_for("main").unwrap().width, 111);
818    }
819
820    #[test]
821    fn reload_from_disk_returns_false_when_unchanged() {
822        let dir = tempdir().unwrap();
823        let svc = open(dir.path());
824        assert!(!Reloadable::reload_from_disk(&svc).unwrap());
825    }
826
827    #[test]
828    fn multiple_windows_independent() {
829        let dir = tempdir().unwrap();
830        let svc = open(dir.path());
831
832        svc.record(PerWindowState {
833            label: "main".into(),
834            x: 0,
835            y: 0,
836            width: 100,
837            height: 100,
838            placement: WindowPlacement::Floating,
839        })
840        .unwrap();
841        svc.record(PerWindowState {
842            label: "log".into(),
843            x: 1000,
844            y: 0,
845            width: 400,
846            height: 800,
847            placement: WindowPlacement::Maximized,
848        })
849        .unwrap();
850
851        assert_eq!(svc.state_for("main").unwrap().width, 100);
852        assert_eq!(
853            svc.state_for("log").unwrap().placement,
854            WindowPlacement::Maximized
855        );
856    }
857
858    #[test]
859    fn forget_removes_entry() {
860        let dir = tempdir().unwrap();
861        let svc = open(dir.path());
862        svc.record(PerWindowState {
863            label: "main".into(),
864            x: 0,
865            y: 0,
866            width: 100,
867            height: 100,
868            placement: WindowPlacement::Floating,
869        })
870        .unwrap();
871        svc.forget("main").unwrap();
872        assert!(svc.state_for("main").is_none());
873    }
874
875    // -- sanitize --------------------------------------------------------
876
877    fn sample(x: i32, y: i32, w: u32, h: u32) -> PerWindowState {
878        PerWindowState {
879            label: "main".into(),
880            x,
881            y,
882            width: w,
883            height: h,
884            placement: WindowPlacement::Floating,
885        }
886    }
887
888    #[test]
889    fn sanitize_clamps_oversized_window() {
890        // Saved 3000x2000 (e.g., user had a 4K monitor) but now on
891        // 1920x1080 — width and height should clamp.
892        let s = sample(0, 0, 3000, 2000).sanitize((400, 300), (1920, 1080));
893        assert_eq!(s.width, 1920);
894        assert_eq!(s.height, 1080);
895    }
896
897    #[test]
898    fn sanitize_promotes_undersized_window_to_min() {
899        let s = sample(0, 0, 100, 100).sanitize((400, 300), (1920, 1080));
900        assert_eq!(s.width, 400);
901        assert_eq!(s.height, 300);
902    }
903
904    #[test]
905    fn sanitize_recenters_offscreen_position() {
906        // Saved on a now-disconnected secondary monitor at x=2200.
907        let s = sample(2200, 100, 800, 600).sanitize((320, 240), (1920, 1080));
908        // Center: (1920-800)/2 = 560
909        assert_eq!(s.x, 560);
910        // y had 100px overlap; y stays.
911        assert_eq!(s.y, 100);
912    }
913
914    #[test]
915    fn sanitize_keeps_position_when_visible_enough() {
916        // Window at (1500, 100), 800x600 on a 1920x1080. Right edge
917        // is at 2300 (off-screen) but plenty visible on the left
918        // — keep position.
919        let s = sample(1500, 100, 800, 600).sanitize((320, 240), (1920, 1080));
920        assert_eq!(s.x, 1500);
921        assert_eq!(s.y, 100);
922    }
923
924    #[test]
925    fn sanitize_recenters_when_top_left_is_negative() {
926        let s = sample(-2000, -2000, 800, 600).sanitize((320, 240), (1920, 1080));
927        // x recenter: (1920-800)/2 = 560
928        assert_eq!(s.x, 560);
929        // y recenter: (1080-600)/2 = 240
930        assert_eq!(s.y, 240);
931    }
932
933    #[test]
934    fn sanitize_preserves_maximized_and_label() {
935        let mut p = sample(0, 0, 800, 600);
936        p.placement = WindowPlacement::Maximized;
937        p.label = "log".into();
938        let s = p.sanitize((320, 240), (1920, 1080));
939        assert_eq!(s.placement, WindowPlacement::Maximized);
940        assert_eq!(s.label, "log");
941    }
942
943    #[test]
944    fn sanitize_preserves_fullscreen() {
945        let mut p = sample(0, 0, 800, 600);
946        p.placement = WindowPlacement::Fullscreen;
947        let s = p.sanitize((320, 240), (1920, 1080));
948        assert_eq!(s.placement, WindowPlacement::Fullscreen);
949    }
950
951    #[test]
952    fn sanitize_downgrades_minimized_to_floating() {
953        // A window saved while minimized must come back visible —
954        // otherwise the user thinks the app failed to start.
955        let mut p = sample(100, 100, 800, 600);
956        p.placement = WindowPlacement::Minimized;
957        let s = p.sanitize((320, 240), (1920, 1080));
958        assert_eq!(s.placement, WindowPlacement::Floating);
959    }
960
961    #[test]
962    fn sanitize_handles_pathological_min_above_work_area() {
963        // If min > max, neither produces a sensible window. We guard
964        // against zero-sized output by clamping width to at least 1.
965        let s = sample(0, 0, 5000, 5000).sanitize((400, 300), (200, 200));
966        assert!(s.width > 0 && s.height > 0);
967    }
968
969    #[test]
970    fn migrates_v1_maximized_bool_to_v2_placement_enum() {
971        // Hand-write a v1 file (`maximized: bool`) and verify
972        // `WindowStateService::open` runs the migration on read,
973        // producing a v2 in-memory representation with the matching
974        // placement enum.
975        let dir = tempdir().unwrap();
976        let path = dir.path().join("window_state.toml");
977        std::fs::write(
978            &path,
979            "version = 1\n\n\
980             [[windows]]\n\
981             label = \"main\"\n\
982             x = 100\n\
983             y = 200\n\
984             width = 800\n\
985             height = 600\n\
986             maximized = true\n\n\
987             [[windows]]\n\
988             label = \"log\"\n\
989             x = 0\n\
990             y = 0\n\
991             width = 400\n\
992             height = 300\n\
993             maximized = false\n",
994        )
995        .unwrap();
996
997        let svc = WindowStateService::open_at(path.clone(), Duration::ZERO).unwrap();
998        assert_eq!(
999            svc.state_for("main").unwrap().placement,
1000            WindowPlacement::Maximized
1001        );
1002        assert_eq!(
1003            svc.state_for("log").unwrap().placement,
1004            WindowPlacement::Floating
1005        );
1006
1007        // Mutate to trigger a flush of the migrated structure, then
1008        // verify the on-disk shape is now v2 (no `maximized` field).
1009        svc.forget("log").unwrap();
1010        svc.flush_now().unwrap();
1011        let raw = std::fs::read_to_string(&path).unwrap();
1012        let parsed: toml::Value = toml::from_str(&raw).unwrap();
1013        assert_eq!(parsed.get("version").and_then(|v| v.as_integer()), Some(2));
1014        let win = parsed
1015            .get("windows")
1016            .and_then(|w| w.as_array())
1017            .and_then(|a| a.first())
1018            .unwrap();
1019        assert!(
1020            win.get("maximized").is_none(),
1021            "maximized field should be gone in v2"
1022        );
1023        assert_eq!(
1024            win.get("placement").and_then(|v| v.as_str()),
1025            Some("Maximized"),
1026        );
1027    }
1028
1029    #[test]
1030    fn persists_across_reopen() {
1031        let dir = tempdir().unwrap();
1032        {
1033            let svc = open(dir.path());
1034            svc.record(PerWindowState {
1035                label: "main".into(),
1036                x: 50,
1037                y: 50,
1038                width: 1024,
1039                height: 768,
1040                placement: WindowPlacement::Fullscreen,
1041            })
1042            .unwrap();
1043            svc.flush_now().unwrap();
1044        }
1045
1046        let svc = open(dir.path());
1047        let got = svc.state_for("main").unwrap();
1048        assert_eq!(got.width, 1024);
1049        assert_eq!(got.height, 768);
1050        assert_eq!(got.placement, WindowPlacement::Fullscreen);
1051    }
1052}