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