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}