use std::cell::{Cell, RefCell};
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime};
use serde::{Deserialize, Serialize};
use teksilo_core::WindowPlacement;
use crate::DEFAULT_DEBOUNCE;
use crate::file::{SettingsFileError, disk_stamp, read_toml_with_retry};
use crate::flush::{DebouncedWriter, FlushError};
use crate::migration::{MigrationError, Migrator, Versioned};
use crate::path::AppPaths;
use crate::reload::Reloadable;
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct PerWindowState {
pub label: String,
pub x: i32,
pub y: i32,
pub width: u32,
pub height: u32,
#[serde(default)]
pub placement: WindowPlacement,
}
impl PerWindowState {
pub fn sanitize(&self, min_size: (u32, u32), work_area: (u32, u32)) -> PerWindowState {
let (min_w, min_h) = min_size;
let (max_w, max_h) = work_area;
let width = clamp_size(self.width, min_w, max_w);
let height = clamp_size(self.height, min_h, max_h);
const MIN_VISIBLE_PX: i32 = 50;
let saved_right = self.x.saturating_add(width as i32);
let saved_bottom = self.y.saturating_add(height as i32);
let visible_w = saved_right.min(max_w as i32) - self.x.max(0);
let visible_h = saved_bottom.min(max_h as i32) - self.y.max(0);
let x = if visible_w < MIN_VISIBLE_PX {
((max_w as i32) - (width as i32)).max(0) / 2
} else {
self.x
};
let y = if visible_h < MIN_VISIBLE_PX {
((max_h as i32) - (height as i32)).max(0) / 2
} else {
self.y
};
let placement = match self.placement {
WindowPlacement::Minimized => WindowPlacement::Floating,
other => other,
};
PerWindowState {
label: self.label.clone(),
x,
y,
width,
height,
placement,
}
}
}
fn clamp_size(value: u32, min: u32, max: u32) -> u32 {
if max < min {
return min.max(1);
}
value.clamp(min, max).max(1)
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub(crate) struct WindowStateFile {
#[serde(default = "default_version")]
pub version: u32,
#[serde(default = "Vec::new")]
pub windows: Vec<PerWindowState>,
}
fn default_version() -> u32 {
WindowStateFile::CURRENT_VERSION
}
impl Default for WindowStateFile {
fn default() -> Self {
Self {
version: WindowStateFile::CURRENT_VERSION,
windows: Vec::new(),
}
}
}
impl Versioned for WindowStateFile {
const CURRENT_VERSION: u32 = 2;
fn version(&self) -> u32 {
self.version
}
fn set_version(&mut self, v: u32) {
self.version = v;
}
}
fn migrate_v1_to_v2(mut raw: toml::Value) -> Result<toml::Value, String> {
let table = raw
.as_table_mut()
.ok_or_else(|| "WindowStateFile root is not a table".to_string())?;
if let Some(windows) = table.get_mut("windows").and_then(|v| v.as_array_mut()) {
for entry in windows {
let Some(entry_table) = entry.as_table_mut() else {
continue;
};
let was_maximized = entry_table
.get("maximized")
.and_then(|v| v.as_bool())
.unwrap_or(false);
entry_table.remove("maximized");
entry_table.insert(
"placement".into(),
toml::Value::String(
if was_maximized {
"Maximized"
} else {
"Floating"
}
.into(),
),
);
}
}
Ok(raw)
}
fn make_migrator() -> Migrator<WindowStateFile> {
Migrator::new().step(1, migrate_v1_to_v2)
}
fn read_window_state_or_default(
path: &Path,
migrator: &Migrator<WindowStateFile>,
) -> Result<WindowStateFile, SettingsFileError> {
match read_toml_with_retry(path)? {
Some(raw) => {
let mut file = migrator.run(raw).map_err(SettingsFileError::Migrate)?;
file.version = <WindowStateFile as Versioned>::CURRENT_VERSION;
Ok(file)
}
None => Ok(WindowStateFile::default()),
}
}
fn parse_window_state_text(
text: Option<&str>,
migrator: &Migrator<WindowStateFile>,
) -> Result<WindowStateFile, SettingsFileError> {
match text {
Some(text) => {
let raw: toml::Value = toml::from_str(text).map_err(SettingsFileError::Parse)?;
let mut file = migrator.run(raw).map_err(SettingsFileError::Migrate)?;
file.version = <WindowStateFile as Versioned>::CURRENT_VERSION;
Ok(file)
}
None => Ok(WindowStateFile::default()),
}
}
#[allow(dead_code)]
fn _migration_error_is_exported(_: MigrationError) {}
#[derive(Clone, Debug)]
enum WindowOp {
Set(Box<PerWindowState>),
Forget(String),
}
fn apply_window_op(windows: &mut Vec<PerWindowState>, op: &WindowOp) {
match op {
WindowOp::Set(state) => match windows.iter_mut().find(|w| w.label == state.label) {
Some(existing) => *existing = (**state).clone(),
None => windows.push((**state).clone()),
},
WindowOp::Forget(label) => windows.retain(|w| w.label != *label),
}
}
#[derive(Clone)]
pub struct WindowStateService {
current: Rc<RefCell<WindowStateFile>>,
writer: Rc<DebouncedWriter>,
migrator: Migrator<WindowStateFile>,
last_known_stamp: Rc<Cell<(Option<SystemTime>, Option<u64>)>>,
pending_write_stamp: Arc<Mutex<Option<crate::flush::LandedStamp>>>,
}
impl WindowStateService {
pub fn open(paths: &AppPaths) -> Result<Self, SettingsFileError> {
Self::open_at(paths.data_file("window_state"), DEFAULT_DEBOUNCE)
}
pub fn open_with_delay(paths: &AppPaths, delay: Duration) -> Result<Self, SettingsFileError> {
Self::open_at(paths.data_file("window_state"), delay)
}
pub fn open_at(path: PathBuf, delay: Duration) -> Result<Self, SettingsFileError> {
let migrator = make_migrator();
let current = read_window_state_or_default(&path, &migrator)?;
let stamp = disk_stamp(&path);
let writer = Rc::new(DebouncedWriter::new(path, delay));
let pending_write_stamp = Arc::new(Mutex::new(None));
let pending_write_stamp_for_sink = Arc::clone(&pending_write_stamp);
writer.set_landed_sink(Arc::new(move |landed| {
*pending_write_stamp_for_sink.lock().unwrap() = Some(landed);
}));
Ok(Self {
current: Rc::new(RefCell::new(current)),
writer,
migrator,
last_known_stamp: Rc::new(Cell::new(stamp)),
pending_write_stamp,
})
}
pub fn state_for(&self, label: &str) -> Option<PerWindowState> {
self.current
.borrow()
.windows
.iter()
.find(|w| w.label == label)
.cloned()
}
pub fn record(&self, state: PerWindowState) -> Result<(), SettingsFileError> {
self.apply(WindowOp::Set(Box::new(state)));
Ok(())
}
pub fn forget(&self, label: &str) -> Result<(), SettingsFileError> {
self.apply(WindowOp::Forget(label.to_string()));
Ok(())
}
pub fn labels(&self) -> Vec<String> {
self.current
.borrow()
.windows
.iter()
.map(|w| w.label.clone())
.collect()
}
fn apply(&self, op: WindowOp) {
apply_window_op(&mut self.current.borrow_mut().windows, &op);
let migrator = self.migrator.clone();
let patch: crate::flush::Patch = Box::new(move |current: Option<String>| {
let mut file = parse_window_state_text(current.as_deref(), &migrator)
.map_err(|e| FlushError::Merge(e.to_string()))?;
apply_window_op(&mut file.windows, &op);
file.version = <WindowStateFile as Versioned>::CURRENT_VERSION;
toml::to_string_pretty(&file).map_err(|e| FlushError::Merge(e.to_string()))
});
self.writer.schedule(patch);
}
pub fn flush_now(&self) -> Result<(), SettingsFileError> {
self.writer.flush_now().map_err(SettingsFileError::Flush)?;
self.last_known_stamp.set(disk_stamp(self.writer.path()));
Ok(())
}
pub fn path(&self) -> &Path {
self.writer.path()
}
}
impl Reloadable for WindowStateService {
fn path(&self) -> &Path {
WindowStateService::path(self)
}
fn reload_from_disk(&self) -> Result<bool, SettingsFileError> {
if let Some(landed) = self.pending_write_stamp.lock().unwrap().take() {
self.last_known_stamp.set(landed);
}
let path = self.writer.path();
let current_stamp = disk_stamp(path);
if current_stamp == self.last_known_stamp.get() {
return Ok(false);
}
let file = read_window_state_or_default(path, &self.migrator)?;
self.last_known_stamp.set(current_stamp);
if *self.current.borrow() == file {
return Ok(false);
}
*self.current.borrow_mut() = file;
Ok(true)
}
}
impl std::fmt::Debug for WindowStateService {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WindowStateService")
.field("path", &self.path())
.field("labels", &self.labels())
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn open(dir: &Path) -> WindowStateService {
let paths = AppPaths::for_testing(dir);
WindowStateService::open_with_delay(&paths, Duration::ZERO).unwrap()
}
#[test]
fn record_then_recall() {
let dir = tempdir().unwrap();
let svc = open(dir.path());
svc.record(PerWindowState {
label: "main".into(),
x: 100,
y: 200,
width: 800,
height: 600,
placement: WindowPlacement::Floating,
})
.unwrap();
let got = svc.state_for("main").unwrap();
assert_eq!(got.x, 100);
assert_eq!(got.width, 800);
}
#[test]
fn record_replaces_existing_entry() {
let dir = tempdir().unwrap();
let svc = open(dir.path());
for i in 0..3 {
svc.record(PerWindowState {
label: "main".into(),
x: i,
y: 0,
width: 100,
height: 100,
placement: WindowPlacement::Floating,
})
.unwrap();
}
assert_eq!(svc.labels(), vec!["main".to_string()]);
assert_eq!(svc.state_for("main").unwrap().x, 2);
}
#[test]
fn two_handles_recording_different_labels_both_survive() {
let dir = tempdir().unwrap();
let paths = AppPaths::for_testing(dir.path());
let a = WindowStateService::open(&paths).unwrap();
let b = WindowStateService::open(&paths).unwrap();
a.record(PerWindowState {
label: "main".into(),
x: 10,
y: 20,
width: 800,
height: 600,
placement: WindowPlacement::Floating,
})
.unwrap();
b.record(PerWindowState {
label: "inspector".into(),
x: 900,
y: 20,
width: 300,
height: 600,
placement: WindowPlacement::Floating,
})
.unwrap();
a.flush_now().unwrap();
b.flush_now().unwrap();
let c = WindowStateService::open(&paths).unwrap();
let mut labels = c.labels();
labels.sort();
assert_eq!(labels, vec!["inspector".to_string(), "main".to_string()]);
assert_eq!(c.state_for("main").unwrap().width, 800);
assert_eq!(c.state_for("inspector").unwrap().width, 300);
}
#[test]
fn a_burst_of_records_coalesces_into_one_write() {
let dir = tempdir().unwrap();
let path = dir.path().join("window_state.toml");
let svc = WindowStateService::open_at(path.clone(), Duration::from_millis(50)).unwrap();
for i in 0..60 {
svc.record(PerWindowState {
label: "main".into(),
x: i,
y: i,
width: 800,
height: 600,
placement: WindowPlacement::Floating,
})
.unwrap();
}
assert!(
!path.exists(),
"a burst of records must not write per-record"
);
svc.flush_now().unwrap();
let on_disk = read_window_state_or_default(&path, &make_migrator()).unwrap();
assert_eq!(on_disk.windows.len(), 1);
assert_eq!(on_disk.windows[0].x, 59);
assert_eq!(svc.state_for("main").unwrap().x, 59);
}
#[test]
fn reload_from_disk_picks_up_a_peers_recorded_label() {
let dir = tempdir().unwrap();
let paths = AppPaths::for_testing(dir.path());
let a = WindowStateService::open(&paths).unwrap();
let b = WindowStateService::open(&paths).unwrap();
a.record(PerWindowState {
label: "main".into(),
x: 1,
y: 2,
width: 111,
height: 222,
placement: WindowPlacement::Floating,
})
.unwrap();
a.flush_now().unwrap();
assert!(b.state_for("main").is_none(), "b hasn't reloaded yet");
assert!(Reloadable::reload_from_disk(&b).unwrap());
assert_eq!(b.state_for("main").unwrap().width, 111);
}
fn wait_for_own_write_to_land(svc: &WindowStateService) {
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
if svc.pending_write_stamp.lock().unwrap().is_some() {
return;
}
assert!(
std::time::Instant::now() < deadline,
"debounced write never landed"
);
std::thread::sleep(Duration::from_millis(5));
}
}
#[test]
fn reload_from_disk_short_circuits_via_adopted_landed_stamp_not_content() {
let dir = tempdir().unwrap();
let path = dir.path().join("window_state.toml");
let svc = WindowStateService::open_at(path.clone(), Duration::ZERO).unwrap();
svc.record(PerWindowState {
label: "main".into(),
x: 1,
y: 2,
width: 111,
height: 222,
placement: WindowPlacement::Floating,
})
.unwrap();
wait_for_own_write_to_land(&svc);
let good_bytes = std::fs::read(&path).unwrap();
let good_mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
let mut garbage = vec![b'x'; good_bytes.len()];
garbage[0] = b'['; assert_eq!(garbage.len(), good_bytes.len(), "must preserve `len`");
std::fs::write(&path, &garbage).unwrap();
std::fs::OpenOptions::new()
.write(true)
.open(&path)
.unwrap()
.set_modified(good_mtime)
.unwrap();
assert_eq!(
disk_stamp(&path),
(Some(good_mtime), Some(good_bytes.len() as u64)),
"test setup must reproduce the exact landed stamp"
);
assert!(toml::from_str::<toml::Value>(&String::from_utf8(garbage).unwrap()).is_err());
let result = Reloadable::reload_from_disk(&svc);
assert!(
matches!(result, Ok(false)),
"expected Ok(false) via the adopted landed-stamp short-circuit \
(corrupted content must never be read), got {result:?}"
);
}
#[test]
fn reload_from_disk_still_detects_a_peer_write_after_our_own_lands() {
let dir = tempdir().unwrap();
let paths = AppPaths::for_testing(dir.path());
let a = WindowStateService::open_with_delay(&paths, Duration::ZERO).unwrap();
a.record(PerWindowState {
label: "main".into(),
x: 1,
y: 2,
width: 111,
height: 222,
placement: WindowPlacement::Floating,
})
.unwrap();
wait_for_own_write_to_land(&a);
let b = WindowStateService::open_with_delay(&paths, Duration::ZERO).unwrap();
b.record(PerWindowState {
label: "inspector".into(),
x: 9,
y: 9,
width: 300,
height: 400,
placement: WindowPlacement::Floating,
})
.unwrap();
wait_for_own_write_to_land(&b);
assert!(Reloadable::reload_from_disk(&a).unwrap());
assert_eq!(a.state_for("inspector").unwrap().width, 300);
assert_eq!(a.state_for("main").unwrap().width, 111);
}
#[test]
fn reload_from_disk_returns_false_when_unchanged() {
let dir = tempdir().unwrap();
let svc = open(dir.path());
assert!(!Reloadable::reload_from_disk(&svc).unwrap());
}
#[test]
fn multiple_windows_independent() {
let dir = tempdir().unwrap();
let svc = open(dir.path());
svc.record(PerWindowState {
label: "main".into(),
x: 0,
y: 0,
width: 100,
height: 100,
placement: WindowPlacement::Floating,
})
.unwrap();
svc.record(PerWindowState {
label: "log".into(),
x: 1000,
y: 0,
width: 400,
height: 800,
placement: WindowPlacement::Maximized,
})
.unwrap();
assert_eq!(svc.state_for("main").unwrap().width, 100);
assert_eq!(
svc.state_for("log").unwrap().placement,
WindowPlacement::Maximized
);
}
#[test]
fn forget_removes_entry() {
let dir = tempdir().unwrap();
let svc = open(dir.path());
svc.record(PerWindowState {
label: "main".into(),
x: 0,
y: 0,
width: 100,
height: 100,
placement: WindowPlacement::Floating,
})
.unwrap();
svc.forget("main").unwrap();
assert!(svc.state_for("main").is_none());
}
fn sample(x: i32, y: i32, w: u32, h: u32) -> PerWindowState {
PerWindowState {
label: "main".into(),
x,
y,
width: w,
height: h,
placement: WindowPlacement::Floating,
}
}
#[test]
fn sanitize_clamps_oversized_window() {
let s = sample(0, 0, 3000, 2000).sanitize((400, 300), (1920, 1080));
assert_eq!(s.width, 1920);
assert_eq!(s.height, 1080);
}
#[test]
fn sanitize_promotes_undersized_window_to_min() {
let s = sample(0, 0, 100, 100).sanitize((400, 300), (1920, 1080));
assert_eq!(s.width, 400);
assert_eq!(s.height, 300);
}
#[test]
fn sanitize_recenters_offscreen_position() {
let s = sample(2200, 100, 800, 600).sanitize((320, 240), (1920, 1080));
assert_eq!(s.x, 560);
assert_eq!(s.y, 100);
}
#[test]
fn sanitize_keeps_position_when_visible_enough() {
let s = sample(1500, 100, 800, 600).sanitize((320, 240), (1920, 1080));
assert_eq!(s.x, 1500);
assert_eq!(s.y, 100);
}
#[test]
fn sanitize_recenters_when_top_left_is_negative() {
let s = sample(-2000, -2000, 800, 600).sanitize((320, 240), (1920, 1080));
assert_eq!(s.x, 560);
assert_eq!(s.y, 240);
}
#[test]
fn sanitize_preserves_maximized_and_label() {
let mut p = sample(0, 0, 800, 600);
p.placement = WindowPlacement::Maximized;
p.label = "log".into();
let s = p.sanitize((320, 240), (1920, 1080));
assert_eq!(s.placement, WindowPlacement::Maximized);
assert_eq!(s.label, "log");
}
#[test]
fn sanitize_preserves_fullscreen() {
let mut p = sample(0, 0, 800, 600);
p.placement = WindowPlacement::Fullscreen;
let s = p.sanitize((320, 240), (1920, 1080));
assert_eq!(s.placement, WindowPlacement::Fullscreen);
}
#[test]
fn sanitize_downgrades_minimized_to_floating() {
let mut p = sample(100, 100, 800, 600);
p.placement = WindowPlacement::Minimized;
let s = p.sanitize((320, 240), (1920, 1080));
assert_eq!(s.placement, WindowPlacement::Floating);
}
#[test]
fn sanitize_handles_pathological_min_above_work_area() {
let s = sample(0, 0, 5000, 5000).sanitize((400, 300), (200, 200));
assert!(s.width > 0 && s.height > 0);
}
#[test]
fn migrates_v1_maximized_bool_to_v2_placement_enum() {
let dir = tempdir().unwrap();
let path = dir.path().join("window_state.toml");
std::fs::write(
&path,
"version = 1\n\n\
[[windows]]\n\
label = \"main\"\n\
x = 100\n\
y = 200\n\
width = 800\n\
height = 600\n\
maximized = true\n\n\
[[windows]]\n\
label = \"log\"\n\
x = 0\n\
y = 0\n\
width = 400\n\
height = 300\n\
maximized = false\n",
)
.unwrap();
let svc = WindowStateService::open_at(path.clone(), Duration::ZERO).unwrap();
assert_eq!(
svc.state_for("main").unwrap().placement,
WindowPlacement::Maximized
);
assert_eq!(
svc.state_for("log").unwrap().placement,
WindowPlacement::Floating
);
svc.forget("log").unwrap();
svc.flush_now().unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
let parsed: toml::Value = toml::from_str(&raw).unwrap();
assert_eq!(parsed.get("version").and_then(|v| v.as_integer()), Some(2));
let win = parsed
.get("windows")
.and_then(|w| w.as_array())
.and_then(|a| a.first())
.unwrap();
assert!(
win.get("maximized").is_none(),
"maximized field should be gone in v2"
);
assert_eq!(
win.get("placement").and_then(|v| v.as_str()),
Some("Maximized"),
);
}
#[test]
fn persists_across_reopen() {
let dir = tempdir().unwrap();
{
let svc = open(dir.path());
svc.record(PerWindowState {
label: "main".into(),
x: 50,
y: 50,
width: 1024,
height: 768,
placement: WindowPlacement::Fullscreen,
})
.unwrap();
svc.flush_now().unwrap();
}
let svc = open(dir.path());
let got = svc.state_for("main").unwrap();
assert_eq!(got.width, 1024);
assert_eq!(got.height, 768);
assert_eq!(got.placement, WindowPlacement::Fullscreen);
}
}