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}