Browse / github.com/storytold/craft-libs / patch
craft-window: restore store: window.json beside config.toml and a pure place()
craft-window: restore store: window.json beside config.toml and a pure place() Adds `craft_window::restore` (feature `restore`, no egui): one window-state store and one placement rule for every Craft app, so apps stop keeping window geometry in eframe's `app.ron` or their own files. Part of the shared-chrome work in #350. ## Store - `window.json` beside the app's `config.toml`, found through `craft_settings::paths::resolve`, so portable installs and `<APP>_CONFIG_DIR` apply. Isolated or in-memory configurations get an in-memory store (tests, agents, web). - Format: `{"schema_version": 1, "windows": {"main": Placement, "<key>": Placement}}`. Multi-window apps save the r
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- aiongg (imported from pull request #400, open)
- Base
6a66b95cbc2c4d4b732630113eb9a37263658c58- Size
- +1305 −0 in 9 files
- Review
- pending
- Shared
- 2026-10-28
- Id
1b72741d481a491c7a59c590
How it went
No other operator has reported using it yet.
Diff
raw · .github/workflows/window.yml Cargo.lock crates/window/Cargo.toml crates/window/README.md crates/window/src/lib.rs crates/window/src/restore/legacy.rs crates/window/src/restore/mod.rs crates/window/src/restore/place.rs crates/window/src/restore/store.rs
@@ -34,3 +34,5 @@ jobs: - run: cargo clippy -p craft-window --all-targets --locked --features egui -- -D warnings - run: cargo test -p craft-window --locked -- --nocapture - run: cargo test -p craft-window --locked --features egui -- --nocapture+ - run: cargo clippy -p craft-window --all-targets --locked --features restore -- -D warnings+ - run: cargo test -p craft-window --locked --features restore,egui -- --nocapture@@ -1070,9 +1070,14 @@ dependencies = [ name = "craft-window" version = "0.1.0" dependencies = [+ "craft-atomic",+ "craft-settings", "egui", "objc2 0.6.5", "objc2-app-kit 0.3.2",+ "serde",+ "serde_json",+ "tempfile", "wayland-client", ] @@ -11,9 +11,15 @@ repository.workspace = true # `decorate`: set the decision on an `egui::ViewportBuilder`. `title_bar`: an app-drawn strip as # the title bar (on macOS with AppKit's `movable` flag). `Viewports`: one immediate viewport per window. egui = ["dep:egui", "dep:objc2", "dep:objc2-app-kit"]+# `restore`: the window-restore store (`window.json` beside `config.toml`) and the pure `place`. No egui.+restore = ["dep:craft-atomic", "dep:craft-settings", "dep:serde", "dep:serde_json"] [dependencies] egui = { workspace = true, optional = true }+craft-atomic = { workspace = true, optional = true }+craft-settings = { workspace = true, optional = true }+serde = { workspace = true, optional = true }+serde_json = { workspace = true, optional = true } # Only Linux and the BSDs probe a Wayland compositor; everywhere else the frame is the OS's. [target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))'.dependencies]@@ -24,5 +30,8 @@ wayland-client = "0.31" objc2 = { version = "0.6.4", default-features = false, features = ["std"], optional = true } objc2-app-kit = { version = "0.3.2", default-features = false, features = ["std", "NSApplication", "NSButton", "NSControl", "NSResponder", "NSView", "NSWindow"], optional = true } +[dev-dependencies]+tempfile = "3"+ [lints] workspace = true@@ -137,6 +137,24 @@ for event in viewports.show( Immediate viewports repaint every window when one repaints, so cache expensive per-window drawing. +## Window restore++Feature `restore` (no egui): `craft_window::restore`.++- `place(saved, monitors, home, &Defaults) -> Placement` is pure. First launch: the default size fitted+ to the home monitor, maximized when it does not fit. Saved sizes are points at interface zoom 1.+ A window with less than 64×64 pt of its top strip on any one monitor moves to its saved monitor,+ else the nearest, else the primary; sizes clamp to the monitor's usable area and `min`. Positions+ are physical pixels (negative left of or above the primary). `Defaults::no_position` (Wayland)+ neither reads nor returns a position. Full screen returns only with `Defaults::restore_fullscreen`.+- `Store::open(app_id)` keeps `window.json` beside `config.toml` (same folder rules as+ `craft-settings`), `Store::in_memory()` keeps nothing. `get`, `set`, `save`.+ Format: `{ "schema_version": 1, "windows": { "main": Placement, "<key>": Placement } }`.+ Writes are atomic and private; a damaged file is moved aside as `window.broken-<ts>.json`; a newer+ `schema_version` is read-only; unknown keys are written back unchanged.+- `restore::legacy` reads eframe's `"window"` entry of `app.ron` (without a RON crate) and has small+ JSON helpers for an app's own old format.+ ## Notes - The Wayland probe opens a short-lived connection and reads the global registry once, on its own@@ -127,6 +127,11 @@ //! } //! } //! ```+//!+//! # Window restore+//!+//! The `restore` feature (no egui) adds [`restore`]: where a window opens, from the last session's+//! geometry and the connected monitors, and the `window.json` store beside `config.toml`. mod list; mod menu;@@ -136,6 +141,9 @@ pub mod title_bar; #[cfg(feature = "egui")] mod viewports; +#[cfg(feature = "restore")]+pub mod restore;+ pub use list::{Closed, WindowId, WindowList}; pub use menu::{Document, MenuEntry, WindowMenu}; pub use place::{CASCADE_STEP, Rect, cascade};new file mode 100644@@ -0,0 +1,281 @@+//! Readers for window geometry that apps stored before the `window.json` store.+//!+//! eframe keeps a RON map of strings in `app.ron` (`ui.ron`); its `"window"` entry is the RON of+//! egui-winit's `WindowSettings`, and `"egui"` holds the interface zoom. No RON crate is+//! involved: the readers look up the few named fields they need, in both layouts eframe has+//! used (a flat struct, and one with a nested `normal_geometry`). Nothing is written back; old+//! builds keep working from the old file.+//!+//! An old layout has no scale factor, so imports assume 1; [`place`](super::place) takes the+//! real one from the monitor the window lands on.++use std::path::Path;++use serde_json::Value;++use super::place::{NormalRect, Placement};++/// The placement in an eframe `app.ron` text: its `"window"` entry, with the size converted to+/// points at zoom 1 using the `"egui"` entry's `zoom_factor` (1 when absent).+pub fn eframe_app_ron(text: &str) -> Option<Placement> {+ let window = ron_string_entry(text, "window")?;+ let zoom = ron_string_entry(text, "egui").and_then(|e| number(field(&e, "zoom_factor")?)).unwrap_or(1.0);+ eframe_window_settings(&window, zoom)+}++/// [`eframe_app_ron`] for a file; `None` when it is missing or unreadable.+pub fn eframe_app_ron_file(path: &Path) -> Option<Placement> {+ eframe_app_ron(&std::fs::read_to_string(path).ok()?)+}++/// The placement in the RON of one eframe `WindowSettings`. `zoom` is the interface zoom factor+/// the size was saved under (eframe saves points at that zoom).+pub fn eframe_window_settings(ron: &str, zoom: f32) -> Option<Placement> {+ let zoom = if zoom.is_finite() && zoom > 0.0 { zoom } else { 1.0 };+ // The nested layout also names the scale; the flat one does not.+ let geometry = field(ron, "normal_geometry").and_then(group).unwrap_or(ron);+ let scale = field(geometry, "scale_factor").and_then(number).filter(|s| *s > 0.0).unwrap_or(1.0);+ let size = field(geometry, "inner_size_points").and_then(vec2)?;+ let pos = field(geometry, "outer_position_pixels").and_then(vec2).or_else(|| field(geometry, "inner_position_pixels").and_then(vec2));+ // The window's pixel size divided by zoom × scale (eframe >= 0.36), so × zoom is points at+ // zoom 1. Older eframe saved points at the then-current zoom, which this reads the same way.+ let size = size.map(|v| v * zoom);+ // Windows parks a minimized window near (-32000, -32000).+ let pos = pos.filter(|p| p.iter().all(|v| *v > -30000.0));+ Some(Placement {+ normal: NormalRect { pos: pos.map_or([0.0; 2], |p| p.map(|v| v / scale)), size },+ position_px: pos.map(|p| p.map(|v| v.round() as i32)),+ scale,+ zoom,+ maximized: field(ron, "maximized").is_some_and(truth),+ fullscreen: field(ron, "fullscreen").is_some_and(truth),+ monitor: None,+ })+}++/// The value at `path` of a JSON object (`json_at(&v, &["window", "size"])`).+pub fn json_at<'a>(v: &'a Value, path: &[&str]) -> Option<&'a Value> {+ path.iter().try_fold(v, |v, k| v.get(*k))+}++/// A finite number at `path`.+pub fn json_f32(v: &Value, path: &[&str]) -> Option<f32> {+ json_at(v, path)?.as_f64().filter(|n| n.is_finite()).map(|n| n as f32)+}++/// A bool at `path`.+pub fn json_bool(v: &Value, path: &[&str]) -> Option<bool> {+ json_at(v, path)?.as_bool()+}++/// A pair of finite numbers at `path`, written as `[a, b]` or as an object with the two given keys.+pub fn json_pair(v: &Value, path: &[&str], keys: [&str; 2]) -> Option<[f32; 2]> {+ let at = json_at(v, path)?;+ let num = |x: Option<&Value>| x?.as_f64().filter(|n| n.is_finite()).map(|n| n as f32);+ match at {+ Value::Array(a) if a.len() == 2 => Some([num(a.first())?, num(a.get(1))?]),+ Value::Object(_) => Some([num(at.get(keys[0]))?, num(at.get(keys[1]))?]),+ _ => None,+ }+}++/// [`json_at`] on the text of a JSON file; `None` when missing or not JSON.+pub fn read_json_file(path: &Path) -> Option<Value> {+ serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()+}++// ---- RON scanning --------------------------------------------------------------------------++/// The text after `name:` for the first `name` that is a whole identifier.+fn field<'a>(text: &'a str, name: &str) -> Option<&'a str> {+ let mut from = 0;+ while let Some(i) = text[from..].find(name) {+ let start = from + i;+ let end = start + name.len();+ let before_ok = text[..start].chars().next_back().is_none_or(|c| !(c.is_alphanumeric() || c == '_'));+ let rest = text[end..].trim_start();+ if before_ok && let Some(value) = rest.strip_prefix(':') {+ return Some(value.trim_start());+ }+ from = end;+ }+ None+}++/// The text inside the parentheses that `s` starts with, as `(…)` or `Some(…)`.+fn group(s: &str) -> Option<&str> {+ let s = s.strip_prefix("Some").map_or(s, str::trim_start);+ let s = s.strip_prefix('(')?;+ let mut depth = 1usize;+ for (i, c) in s.char_indices() {+ match c {+ '(' => depth += 1,+ ')' => {+ depth -= 1;+ if depth == 0 {+ return Some(&s[..i]);+ }+ }+ _ => {}+ }+ }+ None+}++fn number(s: &str) -> Option<f32> {+ let end = s.find(|c: char| !(c.is_ascii_digit() || matches!(c, '-' | '+' | '.' | 'e' | 'E'))).unwrap_or(s.len());+ s[..end].parse::<f32>().ok().filter(|n| n.is_finite())+}++fn truth(s: &str) -> bool {+ s.starts_with("true")+}++/// `Some((x: 1.0, y: 2.0))`, `(x: 1.0, y: 2.0)` or `(1.0, 2.0)`; `None` for `None`.+fn vec2(s: &str) -> Option<[f32; 2]> {+ let inner = group(s)?;+ // `Some((…))` wraps the pair in a second group.+ let inner = if inner.trim_start().starts_with('(') { group(inner.trim_start())? } else { inner };+ if let (Some(x), Some(y)) = (field(inner, "x").and_then(number), field(inner, "y").and_then(number)) {+ return Some([x, y]);+ }+ let (a, b) = inner.split_once(',')?;+ Some([number(a.trim())?, number(b.trim())?])+}++/// The string value for `key` in a RON map of strings (`{"key": "value", …}`), unescaped.+fn ron_string_entry(text: &str, key: &str) -> Option<String> {+ let mut literals = string_literals(text).into_iter();+ while let Some(k) = literals.next() {+ let v = literals.next()?;+ if k == key {+ return Some(v);+ }+ }+ None+}++/// Every string literal of `text` in order: `"…"` with escapes, and raw `r#"…"#`.+fn string_literals(text: &str) -> Vec<String> {+ let mut out = Vec::new();+ let b = text.as_bytes();+ let mut i = 0;+ while i < b.len() {+ if b[i] == b'r' && matches!(b.get(i + 1), Some(b'"' | b'#')) {+ let hashes = b[i + 1..].iter().take_while(|c| **c == b'#').count();+ if b.get(i + 1 + hashes) == Some(&b'"') {+ let body = i + 2 + hashes;+ let close = format!("\"{}", "#".repeat(hashes));+ let Some(len) = text[body..].find(&close) else { break };+ out.push(text[body..body + len].to_owned());+ i = body + len + close.len();+ continue;+ }+ }+ if b[i] == b'"' {+ let (s, next) = escaped(text, i + 1);+ out.push(s);+ i = next;+ continue;+ }+ i += 1;+ }+ out+}++/// Reads an escaped literal body starting at byte `from`; returns it and the index after the closing quote.+fn escaped(text: &str, from: usize) -> (String, usize) {+ let mut s = String::new();+ let mut chars = text[from..].char_indices();+ while let Some((i, c)) = chars.next() {+ match c {+ '"' => return (s, from + i + 1),+ '\\' => match chars.next().map(|(_, e)| e) {+ Some('n') => s.push('\n'),+ Some('t') => s.push('\t'),+ Some('r') => s.push('\r'),+ Some('u') => {+ let mut hex = String::new();+ for (_, h) in chars.by_ref() {+ if h == '}' {+ break;+ }+ if h != '{' {+ hex.push(h);+ }+ }+ if let Some(ch) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) {+ s.push(ch);+ }+ }+ Some(other) => s.push(other),+ None => break,+ },+ c => s.push(c),+ }+ }+ (s, text.len())+}++#[cfg(test)]+#[allow(clippy::unwrap_used, clippy::expect_used)]+mod tests {+ use super::*;+ use crate::restore::Store;++ const FLAT: &str = r#"{+ "egui": "(options:(zoom_factor:1.5,other:1))",+ "window": "(outer_position_pixels:Some((x:120.0,y:-30.0)),fullscreen:false,maximized:true,inner_size_points:Some((x:800.0,y:600.0)))",+}"#;++ #[test]+ fn imports_the_flat_eframe_layout_with_zoom() {+ let p = eframe_app_ron(FLAT).unwrap();+ assert_eq!(p.position_px, Some([120, -30]));+ assert_eq!(p.normal.size, [1200.0, 900.0]);+ assert_eq!(p.zoom, 1.5);+ assert!(p.maximized && !p.fullscreen);+ }++ #[test]+ fn imports_the_nested_layout_with_its_scale() {+ let w = "(normal_geometry:(outer_position_pixels:Some((x:200.0,y:100.0)),scale_factor:2.0,inner_size_points:(x:640.0,y:480.0)),fullscreen:true,maximized:false)";+ let text = format!("{{\"window\": r#\"{w}\"#}}");+ let p = eframe_app_ron(&text).unwrap();+ assert_eq!((p.scale, p.position_px, p.normal.size), (2.0, Some([200, 100]), [640.0, 480.0]));+ assert_eq!(p.normal.pos, [100.0, 50.0]);+ assert!(p.fullscreen);+ }++ #[test]+ fn missing_position_and_garbage_are_handled() {+ let w = "(outer_position_pixels:None,fullscreen:false,maximized:false,inner_size_points:Some((x:300.0,y:200.0)))";+ assert_eq!(eframe_window_settings(w, 1.0).unwrap().position_px, None);+ assert!(eframe_window_settings("(", 1.0).is_none());+ assert!(eframe_app_ron("not ron").is_none());+ }++ #[test]+ fn minimized_windows_parked_off_screen_have_no_position() {+ let w = "(outer_position_pixels:Some((x:-32000.0,y:-32000.0)),fullscreen:false,maximized:false,inner_size_points:Some((x:300.0,y:200.0)))";+ let p = eframe_window_settings(w, 1.0).unwrap();+ assert_eq!((p.position_px, p.normal.size), (None, [300.0, 200.0]));+ }++ #[test]+ fn imported_placement_lands_in_the_store() {+ let mut store = Store::in_memory();+ store.set("main", &eframe_app_ron(FLAT).unwrap());+ assert_eq!(store.get("main").unwrap().position_px, Some([120, -30]));+ }++ #[test]+ fn json_helpers_read_nested_values() {+ let v: Value = serde_json::from_str(r#"{"window":{"size":[800,600],"pos":{"x":1,"y":2},"max":true,"w":3.5}}"#).unwrap();+ assert_eq!(json_pair(&v, &["window", "size"], ["w", "h"]), Some([800.0, 600.0]));+ assert_eq!(json_pair(&v, &["window", "pos"], ["x", "y"]), Some([1.0, 2.0]));+ assert_eq!(json_bool(&v, &["window", "max"]), Some(true));+ assert_eq!(json_f32(&v, &["window", "w"]), Some(3.5));+ assert_eq!(json_f32(&v, &["window", "nope"]), None);+ }+}new file mode 100644@@ -0,0 +1,30 @@+//! Window restore: where a window opens and the file that remembers it. No egui.+//!+//! - [`place`]: saved [`Placement`] + connected [`Monitor`]s + app [`Defaults`] → the geometry to+//! open with. Pure; the rules are on its documentation.+//! - [`Store`]: `window.json` beside the app's `config.toml`, one [`Placement`] per window key+//! (`"main"` for the first window).+//! - [`legacy`]: readers for what apps stored before, such as eframe's `"window"` entry in+//! `app.ron`.+//!+//! Keep one [`Store`] per app, shared by every window (for example behind an `Arc<Mutex<_>>`):+//! each write rewrites the whole file from that store's copy, so two stores in one process+//! overwrite each other.+//!+//! ```+//! use craft_window::restore::{Defaults, Monitor, MonitorKey, Store, place};+//!+//! let mut store = Store::in_memory();+//! let monitors = [Monitor::new(MonitorKey::new("DP-1", [1920, 1080]), [0, 0], [1920, 1080], 1.0)];+//! let defaults = Defaults::new([1200.0, 800.0], [480.0, 320.0]);+//! let opened = place(store.get("main").as_ref(), &monitors, Some(0), &defaults);+//! store.set("main", &opened);+//! assert_eq!(store.get("main"), Some(opened));+//! ```++pub mod legacy;+mod place;+mod store;++pub use place::{Defaults, Monitor, MonitorKey, NormalRect, Placement, place};+pub use store::{FILE_NAME, MAIN, Pending, ReadOnly, SCHEMA_VERSION, Store, write_pending};new file mode 100644@@ -0,0 +1,538 @@+//! The model and the pure placement rules: saved geometry plus the connected monitors in, the+//! geometry to open with out.+//!+//! Units. Sizes and `NormalRect`s are logical points at interface zoom 1, so a window saved while the+//! user ran the interface at 150 % does not open smaller each launch. Positions are physical+//! pixels in the desktop's coordinate space (negative for a monitor left of or above the primary).++use serde::{Deserialize, Serialize};++/// How much of the window's top strip must lie on one monitor, in points on each axis, for the+/// user to still be able to grab it.+const BAR: f32 = 64.0;+const MAX_POINTS: f32 = 100_000.0;++/// A rectangle in logical points at zoom 1: top-left corner and size. (Not `craft_window::Rect`,+/// which is the cascade module's.)+///+/// `pos` is `Placement::position_px` divided by `Placement::scale`; it is informational, and+/// `[0, 0]` when the position is unknown.+#[derive(Clone, Copy, Debug, PartialEq, Default, Serialize, Deserialize)]+pub struct NormalRect {+ pub pos: [f32; 2],+ pub size: [f32; 2],+}++/// Identifies a monitor across launches: the name the platform reports and its size in pixels.+///+/// The size is part of the key because names repeat (`"Built-in Display"`) or change (`DP-1`+/// after a re-plug), and a monitor switched to another resolution is a different place to put a+/// window. A key that no longer matches falls back to the nearest connected monitor.+#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]+pub struct MonitorKey {+ pub name: String,+ pub size_px: [u32; 2],+}++impl MonitorKey {+ pub fn new(name: impl Into<String>, size_px: [u32; 2]) -> Self {+ Self { name: name.into(), size_px }+ }+}++/// What to remember about a window, and what [`place`] returns for the app to open it with.+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]+#[non_exhaustive]+pub struct Placement {+ /// The last window state that was neither maximized nor full screen.+ pub normal: NormalRect,+ /// Outer top-left corner in physical pixels; `None` where the platform does not tell (Wayland).+ #[serde(default)]+ pub position_px: Option<[i32; 2]>,+ /// Physical pixels per point of the monitor the window was on, without interface zoom.+ #[serde(default = "one")]+ pub scale: f32,+ /// The interface zoom factor while saving. Informational: `normal` is already at zoom 1.+ #[serde(default = "one")]+ pub zoom: f32,+ #[serde(default)]+ pub maximized: bool,+ #[serde(default)]+ pub fullscreen: bool,+ #[serde(default)]+ pub monitor: Option<MonitorKey>,+}++impl Placement {+ /// A window of `size` points, position unknown, on a monitor of `scale`, not maximized.+ pub fn new(size: [f32; 2], scale: f32) -> Self {+ make(size, None, scale, 1.0, None)+ }+}++fn one() -> f32 {+ 1.0+}++/// A connected monitor.+#[derive(Clone, Debug, PartialEq)]+#[non_exhaustive]+pub struct Monitor {+ pub key: MonitorKey,+ /// Top-left corner in the desktop's pixel space.+ pub pos_px: [i32; 2],+ pub size_px: [u32; 2],+ /// Physical pixels per point.+ pub scale: f32,+ /// The part not taken by the taskbar, Dock or menu bar: top-left corner and size in pixels.+ /// When set, it replaces [`Defaults::screen_room`] for this monitor.+ pub work_px: Option<([i32; 2], [u32; 2])>,+}++impl Monitor {+ pub fn new(key: MonitorKey, pos_px: [i32; 2], size_px: [u32; 2], scale: f32) -> Self {+ Self { key, pos_px, size_px, scale, work_px: None }+ }++ /// Sets the usable area (see [`work_px`](Self::work_px)).+ pub fn with_work_px(mut self, pos_px: [i32; 2], size_px: [u32; 2]) -> Self {+ self.work_px = Some((pos_px, size_px));+ self+ }+}++/// What the app wants when nothing (usable) is saved, and how the platform behaves.+#[derive(Clone, Debug, PartialEq)]+#[non_exhaustive]+pub struct Defaults {+ /// Size of a first window, in points.+ pub size: [f32; 2],+ /// The smallest window, in points. Yields to a monitor smaller than this.+ pub min: [f32; 2],+ /// Points a monitor loses to the taskbar, Dock or menu bar, per axis.+ pub screen_room: [f32; 2],+ /// Restore full screen. Off by default: most apps open as a window.+ pub restore_fullscreen: bool,+ /// The platform hides window positions (Wayland): none is applied or returned.+ pub no_position: bool,+}++impl Defaults {+ pub fn new(size: [f32; 2], min: [f32; 2]) -> Self {+ Self { size, min, screen_room: [0.0, 48.0], restore_fullscreen: false, no_position: false }+ }++ pub fn with_screen_room(mut self, room: [f32; 2]) -> Self {+ self.screen_room = room;+ self+ }++ pub fn with_restore_fullscreen(mut self, on: bool) -> Self {+ self.restore_fullscreen = on;+ self+ }++ pub fn with_no_position(mut self, on: bool) -> Self {+ self.no_position = on;+ self+ }+}++impl Monitor {+ fn scale(&self) -> f32 {+ if self.scale.is_finite() && self.scale > 0.0 { self.scale } else { 1.0 }+ }++ fn points(&self) -> [f32; 2] {+ self.size_px.map(|s| s as f32 / self.scale())+ }++ /// Position and size in pixels of the area windows may use.+ fn bounds(&self) -> ([i32; 2], [u32; 2]) {+ self.work_px.unwrap_or((self.pos_px, self.size_px))+ }++ /// Size in points of the area windows may use: the work area, else the monitor less `room`.+ fn usable(&self, room: [f32; 2]) -> [f32; 2] {+ let s = self.scale();+ match self.work_px {+ Some((_, size)) => size.map(|v| (v as f32 / s).max(1.0)),+ None => {+ let p = self.points();+ std::array::from_fn(|i| (p[i] - room[i].max(0.0)).max(1.0))+ }+ }+ }++ fn centre_px(&self) -> [f64; 2] {+ std::array::from_fn(|i| f64::from(self.pos_px[i]) + f64::from(self.size_px[i]) / 2.0)+ }++ /// `pos` moved the least that puts a window of `size` points fully in the usable area.+ fn keep_on(&self, pos: [i32; 2], size: [f32; 2]) -> [i32; 2] {+ let (bpos, bsize) = self.bounds();+ std::array::from_fn(|i| {+ let lo = i64::from(bpos[i]);+ let win = (f64::from(size[i]) * f64::from(self.scale())).round() as i64;+ let hi = (lo + i64::from(bsize[i]) - win).max(lo);+ i32::try_from(i64::from(pos[i]).clamp(lo, hi)).unwrap_or(bpos[i])+ })+ }++ /// Area in points of the window's top strip (full width, up to 64 pt tall) lying on this+ /// monitor, if at least min(width, 64) × min(height, 64) pt of it does. A few pixels of slack+ /// absorb Windows' snapped offset of about -7 px.+ fn bar_overlap(&self, pos: [i32; 2], size: [f32; 2]) -> Option<f32> {+ const SLACK_PX: f64 = 8.0;+ let s = f64::from(self.scale());+ let strip = [size[0], size[1].min(BAR)];+ let need = [size[0].min(BAR), size[1].min(BAR)];+ let mut got = [0.0f64; 2];+ for i in 0..2 {+ let a0 = f64::from(pos[i]);+ let a1 = a0 + f64::from(strip[i]) * s;+ let b0 = f64::from(self.pos_px[i]);+ let b1 = b0 + f64::from(self.size_px[i]);+ got[i] = (a1.min(b1) - a0.max(b0)).max(0.0);+ }+ (0..2).all(|i| got[i] + SLACK_PX >= f64::from(need[i]) * s).then(|| (got[0] / s * got[1] / s) as f32)+ }+}++/// Size no bigger than `room` and no smaller than `min` unless `room` is.+fn fit(size: [f32; 2], room: [f32; 2], min: [f32; 2]) -> [f32; 2] {+ std::array::from_fn(|i| size[i].min(room[i]).max(min[i].min(room[i])).max(1.0))+}++fn in_points(v: f32) -> bool {+ v.is_finite() && (1.0..=MAX_POINTS).contains(&v)+}++/// The saved record with out-of-range numbers repaired, or `None` when its size is no size.+pub(super) fn sane(s: &Placement) -> Option<Placement> {+ if !s.normal.size.iter().all(|v| in_points(*v)) {+ return None;+ }+ let mut s = s.clone();+ if !s.normal.pos.iter().all(|v| v.is_finite()) {+ s.normal.pos = [0.0; 2];+ }+ if !(s.scale.is_finite() && s.scale > 0.0 && s.scale <= 16.0) {+ s.scale = 1.0;+ }+ if !(s.zoom.is_finite() && (0.1..=10.0).contains(&s.zoom)) {+ s.zoom = 1.0;+ }+ Some(s)+}++fn make(size: [f32; 2], pos_px: Option<[i32; 2]>, scale: f32, zoom: f32, monitor: Option<MonitorKey>) -> Placement {+ let pos = pos_px.map_or([0.0, 0.0], |p| p.map(|v| v as f32 / scale));+ Placement { normal: NormalRect { pos, size }, position_px: pos_px, scale, zoom, maximized: false, fullscreen: false, monitor }+}++/// Where a window opens: `saved` kept on a connected monitor, else the default size on `home`+/// (index into `monitors` of the monitor the system opens it on; the one at the origin, else the+/// first, when `None`).+///+/// - Nothing saved: [`Defaults::size`] fitted to the usable area of `home`; maximized when the+/// monitor is too small for it. The position is left to the system.+/// - The saved size is points at zoom 1 and is not scaled by `zoom`.+/// - `normal` is kept as saved while `maximized`; only the flag says it was maximized.+/// - Off screen: when less than 64×64 pt of the window's top strip lies on any one monitor, the+/// window moves to its saved monitor, else the one nearest its centre, else the primary.+/// - The size is clamped to the usable area of the monitor it lands on and to `min`.+/// - A different `scale` than saved is taken from the monitor; the size stays in points.+/// - [`Defaults::no_position`]: no position in, none out.+/// - Full screen comes back only with [`Defaults::restore_fullscreen`].+/// - Without monitors (the platform lists none) nothing can be checked: the saved geometry is kept,+/// with the size raised to `min`.+pub fn place(saved: Option<&Placement>, monitors: &[Monitor], home: Option<usize>, d: &Defaults) -> Placement {+ let saved = saved.and_then(sane);+ let primary = monitors.iter().find(|m| m.pos_px == [0, 0]).or(monitors.first());+ let home = home.and_then(|i| monitors.get(i)).or(primary);+ match (saved, home) {+ (None, None) => make(fit(d.size, [MAX_POINTS; 2], d.min), None, 1.0, 1.0, None),+ (None, Some(h)) => first_launch(h, d),+ (Some(s), None) => {+ let size = fit(s.normal.size, [MAX_POINTS; 2], d.min);+ let pos = if d.no_position { None } else { s.position_px };+ Placement { maximized: s.maximized, fullscreen: s.fullscreen && d.restore_fullscreen, ..make(size, pos, s.scale, s.zoom, s.monitor) }+ }+ (Some(s), Some(h)) => restore(&s, monitors, h, primary.unwrap_or(h), d),+ }+}++fn first_launch(home: &Monitor, d: &Defaults) -> Placement {+ let usable = home.usable(d.screen_room);+ let too_small = (0..2).any(|i| d.size[i] > usable[i]);+ let size = fit(d.size, usable, d.min);+ Placement { maximized: too_small, ..make(size, None, home.scale(), 1.0, Some(home.key.clone())) }+}++fn restore(s: &Placement, monitors: &[Monitor], home: &Monitor, primary: &Monitor, d: &Defaults) -> Placement {+ let size = s.normal.size;+ let saved_pos = if d.no_position { None } else { s.position_px };+ let by_key = || s.monitor.as_ref().and_then(|k| monitors.iter().find(|m| &m.key == k));++ let mut visible = false;+ let target = match saved_pos {+ Some(p) => {+ // On mixed-DPI desktops pixel ranges overlap; the saved monitor decides first.+ let shown = monitors+ .iter()+ .filter_map(|m| m.bar_overlap(p, size).map(|a| (a, m)))+ .max_by(|a, b| {+ let key = |m: &Monitor| s.monitor.as_ref() == Some(&m.key);+ key(a.1).cmp(&key(b.1)).then(a.0.total_cmp(&b.0))+ })+ .map(|(_, m)| m);+ visible = shown.is_some();+ shown.or_else(by_key).unwrap_or_else(|| nearest(monitors, p, size, s.scale).unwrap_or(primary))+ }+ None => by_key().unwrap_or(home),+ };++ let fitted = fit(size, target.usable(d.screen_room), d.min);+ let kept = visible && fitted == size;+ let pos = saved_pos.map(|p| if kept { p } else { target.keep_on(p, fitted) });+ // A kept position on the saved monitor keeps its saved scale: the pixel ranges of mixed-DPI+ // monitors overlap, and the position was measured at that scale.+ let scale = if kept && s.monitor.as_ref() == Some(&target.key) { s.scale } else { target.scale() };+ Placement {+ maximized: s.maximized,+ fullscreen: s.fullscreen && d.restore_fullscreen,+ ..make(fitted, pos, scale, s.zoom, Some(target.key.clone()))+ }+}++/// The monitor whose centre is closest to the window's centre.+fn nearest(monitors: &[Monitor], pos: [i32; 2], size: [f32; 2], scale: f32) -> Option<&Monitor> {+ let c: [f64; 2] = std::array::from_fn(|i| f64::from(pos[i]) + f64::from(size[i]) * f64::from(scale) / 2.0);+ let dist = |m: &Monitor| {+ let mc = m.centre_px();+ (mc[0] - c[0]).powi(2) + (mc[1] - c[1]).powi(2)+ };+ monitors.iter().min_by(|a, b| dist(a).total_cmp(&dist(b)))+}++#[cfg(test)]+#[allow(clippy::unwrap_used, clippy::expect_used)]+mod tests {+ use super::*;++ fn mon(name: &str, pos: [i32; 2], size: [u32; 2], scale: f32) -> Monitor {+ Monitor::new(MonitorKey::new(name, size), pos, size, scale)+ }++ fn hd() -> Monitor {+ mon("A", [0, 0], [1920, 1080], 1.0)+ }++ fn defaults() -> Defaults {+ let mut d = Defaults::new([1200.0, 800.0], [400.0, 300.0]);+ d.screen_room = [0.0, 40.0];+ d+ }++ fn saved(pos: Option<[i32; 2]>, size: [f32; 2], scale: f32, m: &Monitor) -> Placement {+ let mut p = make(size, pos, scale, 1.0, Some(m.key.clone()));+ p.maximized = false;+ p+ }++ #[test]+ fn first_launch_uses_default_size_and_leaves_position_to_the_system() {+ let p = place(None, &[hd()], Some(0), &defaults());+ assert_eq!(p.normal.size, [1200.0, 800.0]);+ assert_eq!(p.position_px, None);+ assert!(!p.maximized);+ }++ #[test]+ fn first_launch_on_a_small_monitor_fits_and_maximizes() {+ let small = mon("S", [0, 0], [1280, 720], 1.0);+ let p = place(None, &[small], None, &defaults());+ assert_eq!(p.normal.size, [1200.0, 680.0]);+ assert!(p.maximized);+ }++ #[test]+ fn monitor_gone_moves_the_window_to_a_connected_one() {+ let gone = mon("Gone", [1920, 0], [1920, 1080], 1.0);+ let s = saved(Some([2500, 100]), [800.0, 600.0], 1.0, &gone);+ let p = place(Some(&s), &[hd()], Some(0), &defaults());+ assert_eq!(p.monitor, Some(hd().key));+ let [x, y] = p.position_px.unwrap();+ assert!((0..=1920 - 800).contains(&x) && (0..=1080 - 600).contains(&y));+ }++ #[test]+ fn window_dragged_off_screen_comes_back_but_a_visible_bar_stays() {+ let m = hd();+ let off = saved(Some([1900, 100]), [800.0, 600.0], 1.0, &m);+ let p = place(Some(&off), std::slice::from_ref(&m), Some(0), &defaults());+ assert_eq!(p.position_px, Some([1120, 100]));+ // 100 pt of the bar still on the monitor: untouched.+ let ok = saved(Some([1820, 100]), [800.0, 600.0], 1.0, &m);+ assert_eq!(place(Some(&ok), &[m], Some(0), &defaults()).position_px, Some([1820, 100]));+ }++ #[test]+ fn mixed_dpi_prefers_the_saved_monitor_and_keeps_its_scale() {+ let mac = mon("Mac", [0, 0], [3024, 1964], 2.0);+ let ext = mon("Ext", [1512, 0], [1920, 1080], 1.0);+ let s = saved(Some([2000, 100]), [800.0, 600.0], 2.0, &mac);+ let p = place(Some(&s), &[mac.clone(), ext], Some(0), &defaults());+ assert_eq!((p.monitor, p.scale, p.position_px), (Some(mac.key), 2.0, Some([2000, 100])));+ }++ #[test]+ fn bar_is_a_strip_not_a_corner() {+ let m = hd();+ // Left edge: 100 px of an 800 pt window show on the right of x = -700; the strip is wide enough.+ let s = saved(Some([-700, 100]), [800.0, 600.0], 1.0, &m);+ assert_eq!(place(Some(&s), std::slice::from_ref(&m), Some(0), &defaults()).position_px, Some([-700, 100]));+ // Only 30 px show: moved back.+ let s = saved(Some([-770, 100]), [800.0, 600.0], 1.0, &m);+ assert_eq!(place(Some(&s), std::slice::from_ref(&m), Some(0), &defaults()).position_px, Some([0, 100]));+ // Windows' snapped -7 px offset on the left or top is fine.+ let s = saved(Some([-7, -7]), [800.0, 600.0], 1.0, &m);+ assert_eq!(place(Some(&s), std::slice::from_ref(&m), Some(0), &defaults()).position_px, Some([-7, -7]));+ // Top edge: the strip is 64 pt tall; 40 pt of it visible is not enough.+ let s = saved(Some([100, -24]), [800.0, 600.0], 1.0, &m);+ assert_eq!(place(Some(&s), std::slice::from_ref(&m), Some(0), &defaults()).position_px, Some([100, 0]));+ }++ #[test]+ fn work_area_replaces_screen_room() {+ let m = hd().with_work_px([0, 0], [1920, 1000]);+ let p = place(None, &[m], Some(0), &Defaults::new([1200.0, 1100.0], [400.0, 300.0]));+ assert_eq!(p.normal.size, [1200.0, 1000.0]);+ assert!(p.maximized);+ }++ #[test]+ fn bar_above_the_top_edge_counts_as_off_screen() {+ let m = hd();+ let s = saved(Some([100, -50]), [800.0, 600.0], 1.0, &m);+ assert_eq!(place(Some(&s), &[m], Some(0), &defaults()).position_px, Some([100, 0]));+ }++ #[test]+ fn scale_change_keeps_points_and_takes_the_new_scale() {+ let old = hd();+ let hidpi = mon("B", [0, 0], [3840, 2160], 2.0);+ let s = saved(Some([200, 100]), [1000.0, 700.0], 1.0, &old);+ let p = place(Some(&s), std::slice::from_ref(&hidpi), Some(0), &defaults());+ assert_eq!(p.normal.size, [1000.0, 700.0]);+ assert_eq!(p.scale, 2.0);+ assert_eq!(p.monitor, Some(hidpi.key));+ assert_eq!(p.position_px, Some([200, 100]));+ assert_eq!(p.normal.pos, [100.0, 50.0]);+ }++ #[test]+ fn smaller_monitor_clamps_to_usable_area_and_min() {+ let big = mon("Big", [0, 0], [3840, 2160], 1.0);+ let s = saved(Some([100, 100]), [3000.0, 1800.0], 1.0, &big);+ let small = mon("S", [0, 0], [1280, 720], 1.0);+ let p = place(Some(&s), std::slice::from_ref(&small), Some(0), &defaults());+ assert_eq!(p.normal.size, [1280.0, 680.0]);+ assert_eq!(p.position_px, Some([0, 40]));+ // A monitor below `min`: the monitor wins.+ let tiny = mon("T", [0, 0], [320, 240], 1.0);+ let p = place(Some(&s), &[tiny], Some(0), &defaults());+ assert_eq!(p.normal.size, [320.0, 200.0]);+ // `min` raises a sliver saved size.+ let sliver = saved(None, [50.0, 50.0], 1.0, &small);+ assert_eq!(place(Some(&sliver), &[small], Some(0), &defaults()).normal.size, [400.0, 300.0]);+ }++ #[test]+ fn negative_origin_monitors_are_valid_places() {+ let left = mon("L", [-1920, 0], [1920, 1080], 1.0);+ let above = mon("U", [0, -1080], [1920, 1080], 1.0);+ let ms = [hd(), left.clone(), above.clone()];+ let s = saved(Some([-1500, 100]), [800.0, 600.0], 1.0, &left);+ assert_eq!(place(Some(&s), &ms, Some(0), &defaults()).position_px, Some([-1500, 100]));+ let s = saved(Some([300, -900]), [800.0, 600.0], 1.0, &above);+ assert_eq!(place(Some(&s), &ms, Some(0), &defaults()).position_px, Some([300, -900]));+ // The left monitor is unplugged: back onto the primary, not stuck at a negative x.+ let s = saved(Some([-1500, 100]), [800.0, 600.0], 1.0, &left);+ assert_eq!(place(Some(&s), &[hd()], Some(0), &defaults()).position_px, Some([0, 100]));+ }++ #[test]+ fn nearest_monitor_wins_when_the_saved_one_is_gone() {+ let right = mon("R", [1920, 0], [1920, 1080], 1.0);+ let far = mon("F", [5000, 0], [1920, 1080], 1.0);+ let gone = mon("Gone", [3840, 0], [1920, 1080], 1.0);+ let s = saved(Some([6000, 100]), [800.0, 600.0], 1.0, &gone);+ let p = place(Some(&s), &[hd(), right, far.clone()], Some(0), &defaults());+ assert_eq!(p.monitor, Some(far.key));+ }++ #[test]+ fn wayland_neither_reads_nor_returns_a_position() {+ let mut d = defaults();+ d.no_position = true;+ let m = hd();+ let s = saved(Some([5000, 5000]), [800.0, 600.0], 1.0, &m);+ let p = place(Some(&s), std::slice::from_ref(&m), Some(0), &d);+ assert_eq!(p.position_px, None);+ assert_eq!(p.normal.size, [800.0, 600.0]);+ assert_eq!(place(None, &[m], Some(0), &d).position_px, None);+ }++ #[test]+ fn size_saved_at_zoom_one_does_not_shrink_with_zoom() {+ let m = hd();+ let mut s = saved(Some([100, 100]), [1000.0, 700.0], 1.0, &m);+ s.zoom = 1.5;+ let p = place(Some(&s), &[m], Some(0), &defaults());+ assert_eq!(p.normal.size, [1000.0, 700.0]);+ assert_eq!(p.zoom, 1.5);+ }++ #[test]+ fn maximized_keeps_the_normal_rect() {+ let m = hd();+ let mut s = saved(Some([100, 100]), [900.0, 600.0], 1.0, &m);+ s.maximized = true;+ let p = place(Some(&s), &[m], Some(0), &defaults());+ assert!(p.maximized);+ assert_eq!((p.normal.size, p.position_px), ([900.0, 600.0], Some([100, 100])));+ }++ #[test]+ fn full_screen_needs_the_opt_in() {+ let m = hd();+ let mut s = saved(Some([100, 100]), [900.0, 600.0], 1.0, &m);+ s.fullscreen = true;+ let mut d = defaults();+ assert!(!place(Some(&s), std::slice::from_ref(&m), Some(0), &d).fullscreen);+ d.restore_fullscreen = true;+ assert!(place(Some(&s), &[m], Some(0), &d).fullscreen);+ }++ #[test]+ fn nonsense_saved_numbers_fall_back_to_a_first_launch() {+ let m = hd();+ let mut s = saved(Some([0, 0]), [f32::NAN, 600.0], 1.0, &m);+ assert_eq!(place(Some(&s), std::slice::from_ref(&m), Some(0), &defaults()).normal.size, [1200.0, 800.0]);+ s.normal.size = [800.0, 600.0];+ s.scale = 0.0;+ s.zoom = 99.0;+ let p = place(Some(&s), &[m], Some(0), &defaults());+ assert_eq!((p.scale, p.zoom), (1.0, 1.0));+ }++ #[test]+ fn no_monitors_keeps_the_saved_geometry() {+ let m = hd();+ let s = saved(Some([10, 20]), [800.0, 600.0], 1.0, &m);+ let p = place(Some(&s), &[], None, &defaults());+ assert_eq!((p.normal.size, p.position_px), ([800.0, 600.0], Some([10, 20])));+ }+}new file mode 100644@@ -0,0 +1,414 @@+//! `window.json`: the saved [`Placement`] of each window, beside the app's `config.toml`.+//!+//! ```json+//! { "schema_version": 1, "windows": { "main": { ... }, "<key>": { ... } } }+//! ```+//!+//! The folder is chosen by `craft_settings::paths`, so portable installs keep the file in the+//! portable folder and `<APP>_CONFIG_DIR` moves it with `config.toml`. Writes are atomic and+//! private. A file that does not parse is moved aside (`window.broken-<unix-ts>.json`, the+//! `craft_settings::recovery` convention) and the store starts empty. A file with a newer+//! `schema_version` is read but never written. Keys this version does not know are written back+//! unchanged. Two running instances of one app overwrite each other; the last writer wins.++use std::collections::BTreeMap;+use std::io;+use std::path::{Path, PathBuf};++use craft_atomic::{NewFilePermissions, WriteOptions};+use craft_settings::paths::{self, Location, Portable, Probe};+use craft_settings::recovery;+use serde_json::{Map, Value, json};++use super::place::{Placement, sane};++/// The file name beside `config.toml`.+pub const FILE_NAME: &str = "window.json";+/// The `schema_version` this code writes.+pub const SCHEMA_VERSION: u64 = 1;+/// The key of the app's first window.+pub const MAIN: &str = "main";++/// Why a store does not write.+#[derive(Clone, Debug, PartialEq, Eq)]+pub enum ReadOnly {+ /// The file has a newer `schema_version` than this code knows.+ NewerSchema(u64),+ /// The file exists but could not be read (permissions, say); it is left alone.+ Unreadable,+}++/// A write [`Store::take_pending`] prepared.+#[derive(Debug, Clone, PartialEq, Eq)]+pub struct Pending {+ pub path: PathBuf,+ pub bytes: Vec<u8>,+ /// Pass to [`Store::mark_saved`] after the write succeeded.+ pub generation: u64,+}++/// Writes `pending` atomically and privately, creating the folder. Needs no access to the store.+pub fn write_pending(pending: &Pending) -> io::Result<()> {+ if let Some(dir) = pending.path.parent() {+ std::fs::create_dir_all(dir)?;+ }+ WriteOptions::default().with_new_file_permissions(NewFilePermissions::Private).atomic_write(&pending.path, &pending.bytes)+}++/// The saved placements of an app's windows.+#[derive(Debug)]+pub struct Store {+ path: Option<PathBuf>,+ /// Entries stay raw so one that fails to parse is neither lost nor fatal to the others.+ windows: BTreeMap<String, Value>,+ /// Top-level keys other than `schema_version` and `windows`.+ extra: Map<String, Value>,+ read_only: Option<ReadOnly>,+ /// Counts changes; the store is dirty while it is ahead of `saved`.+ generation: u64,+ saved: u64,+ moved_aside: Option<PathBuf>,+}++impl Store {+ /// The store for `app_id` in the folder `craft_settings` picks for its `config.toml`, with+ /// the suite's default portable marker. In an isolated or in-memory configuration+ /// (`<APP>_CONFIG_DIR` empty) nothing is read or written.+ pub fn open(app_id: &str) -> Self {+ Self::open_with(app_id, &Portable::DEFAULT, &Location::Auto, &Probe::default())+ }++ /// [`open`](Self::open) with the app's own portable names, location override and probe: the+ /// same arguments its `craft_settings` options use.+ pub fn open_with(app_id: &str, portable: &Portable, location: &Location, probe: &Probe) -> Self {+ match paths::resolve(app_id, portable, location, probe).dir {+ Some(dir) => Self::at(&dir.join(FILE_NAME)),+ None => Self::in_memory(),+ }+ }++ /// A store backed by the file at `path`. A missing file is an empty store.+ pub fn at(path: &Path) -> Self {+ let mut store = Self { path: Some(path.to_path_buf()), ..Self::in_memory() };+ store.load();+ store+ }++ /// A store that reads and writes nothing: tests, agents, the web.+ pub fn in_memory() -> Self {+ Self { path: None, windows: BTreeMap::new(), extra: Map::new(), read_only: None, generation: 0, saved: 0, moved_aside: None }+ }++ /// The file this store writes, if it has one.+ pub fn path(&self) -> Option<&Path> {+ self.path.as_deref()+ }++ /// Where a damaged file was moved to while opening, if one was.+ pub fn moved_aside(&self) -> Option<&Path> {+ self.moved_aside.as_deref()+ }++ /// Why [`save`](Self::save) writes nothing, if so.+ pub fn read_only(&self) -> Option<&ReadOnly> {+ self.read_only.as_ref()+ }++ /// The placement saved under `key`; `None` when absent or not a placement.+ pub fn get(&self, key: &str) -> Option<Placement> {+ serde_json::from_value(self.windows.get(key)?.clone()).ok()+ }++ /// Replaces the placement under `key`. Fields of the old entry that this version does not know+ /// stay. A placement with a non-finite size is ignored. Nothing is written until+ /// [`save`](Self::save) or [`take_pending`](Self::take_pending).+ pub fn set(&mut self, key: &str, placement: &Placement) {+ let Some(placement) = sane(placement) else { return };+ let Ok(Value::Object(new)) = serde_json::to_value(&placement) else { return };+ let merged = match self.windows.get(key) {+ Some(Value::Object(old)) => {+ let mut m = old.clone();+ m.extend(new);+ m+ }+ _ => new,+ };+ let merged = Value::Object(merged);+ if self.windows.get(key) != Some(&merged) {+ self.windows.insert(key.to_owned(), merged);+ self.generation += 1;+ }+ }++ /// Forgets `key`.+ pub fn remove(&mut self, key: &str) {+ if self.windows.remove(key).is_some() {+ self.generation += 1;+ }+ }++ /// The keys with an entry, in order.+ pub fn keys(&self) -> impl Iterator<Item = &str> {+ self.windows.keys().map(String::as_str)+ }++ /// Whether something changed since the last successful write.+ pub fn is_dirty(&self) -> bool {+ self.generation != self.saved+ }++ /// The write that would bring the file up to date, or `None` when there is nothing to write+ /// (in memory, read-only, or unchanged). Write it with [`write_pending`], without holding any+ /// lock on the store, then report success with [`mark_saved`](Self::mark_saved).+ pub fn take_pending(&self) -> Option<Pending> {+ let path = self.path.clone().filter(|_| self.read_only.is_none() && self.is_dirty())?;+ let mut doc = self.extra.clone();+ doc.insert("schema_version".into(), json!(SCHEMA_VERSION));+ doc.insert("windows".into(), Value::Object(self.windows.clone().into_iter().collect()));+ let mut bytes = serde_json::to_vec_pretty(&Value::Object(doc)).ok()?;+ bytes.push(b'\n');+ Some(Pending { path, bytes, generation: self.generation })+ }++ /// Records that [`Pending::generation`] reached the disk. The store stays dirty if it changed+ /// again meanwhile, and after a failed write (do not call this then), so the next+ /// [`take_pending`](Self::take_pending) retries.+ pub fn mark_saved(&mut self, generation: u64) {+ self.saved = self.saved.max(generation.min(self.generation));+ }++ /// [`take_pending`](Self::take_pending), [`write_pending`] and [`mark_saved`](Self::mark_saved)+ /// in one call. `Ok(true)` when written; `Ok(false)` when there was nothing to write.+ pub fn save(&mut self) -> io::Result<bool> {+ let Some(pending) = self.take_pending() else { return Ok(false) };+ write_pending(&pending)?;+ self.mark_saved(pending.generation);+ Ok(true)+ }++ fn load(&mut self) {+ let Some(path) = self.path.clone() else { return };+ let text = match std::fs::read_to_string(&path) {+ Ok(t) => t,+ Err(e) if e.kind() == io::ErrorKind::NotFound => return,+ // Invalid UTF-8 is damage; anything else may be transient, so leave the file be.+ Err(e) if e.kind() == io::ErrorKind::InvalidData => return self.start_over(&path),+ Err(_) => {+ self.read_only = Some(ReadOnly::Unreadable);+ return;+ }+ };+ let Some(mut doc) = serde_json::from_str::<Value>(&text).ok().and_then(|v| match v {+ Value::Object(m) => Some(m),+ _ => None,+ }) else {+ return self.start_over(&path);+ };+ let Some(version) = doc.remove("schema_version").and_then(|v| v.as_u64()) else {+ return self.start_over(&path);+ };+ // A newer file is never judged by this version's shape rules, so never moved aside.+ if version > SCHEMA_VERSION {+ self.read_only = Some(ReadOnly::NewerSchema(version));+ if let Some(Value::Object(m)) = doc.remove("windows") {+ self.windows = m.into_iter().collect();+ }+ self.extra = doc;+ return;+ }+ let Some(Value::Object(windows)) = doc.remove("windows") else {+ return self.start_over(&path);+ };+ self.windows = windows.into_iter().collect();+ self.extra = doc;+ }++ fn start_over(&mut self, path: &Path) {+ match recovery::move_aside(path) {+ Ok(to) => self.moved_aside = Some(to),+ // Cannot rename it away: do not overwrite what we could not set aside.+ Err(_) => self.read_only = Some(ReadOnly::Unreadable),+ }+ }+}++#[cfg(test)]+#[allow(clippy::unwrap_used, clippy::expect_used)]+mod tests {+ use super::*;+ use crate::restore::{Monitor, MonitorKey};++ fn sample(x: i32) -> Placement {+ let m = Monitor::new(MonitorKey::new("DP-1", [1920, 1080]), [0, 0], [1920, 1080], 1.0);+ let mut p = crate::restore::place(None, &[m], Some(0), &crate::restore::Defaults::new([800.0, 600.0], [300.0, 200.0]));+ p.position_px = Some([x, 10]);+ p+ }++ fn siblings(dir: &Path) -> Vec<String> {+ let mut names: Vec<String> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();+ names.sort();+ names+ }++ #[test]+ fn round_trip_through_the_file() {+ let dir = tempfile::tempdir().unwrap();+ let path = dir.path().join("nested").join(FILE_NAME);+ let mut s = Store::at(&path);+ assert!(s.get(MAIN).is_none());+ s.set(MAIN, &sample(5));+ s.set("doc-2", &sample(6));+ assert!(s.save().unwrap());+ assert!(!s.save().unwrap(), "nothing changed");+ let again = Store::at(&path);+ assert_eq!(again.get(MAIN), Some(sample(5)));+ assert_eq!(again.get("doc-2"), Some(sample(6)));+ assert_eq!(again.keys().collect::<Vec<_>>(), ["doc-2", MAIN]);+ let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();+ assert_eq!(v["schema_version"], 1);+ }++ #[cfg(unix)]+ #[test]+ fn the_file_is_private() {+ use std::os::unix::fs::PermissionsExt;+ let dir = tempfile::tempdir().unwrap();+ let path = dir.path().join(FILE_NAME);+ let mut s = Store::at(&path);+ s.set(MAIN, &sample(1));+ s.save().unwrap();+ assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o077, 0);+ }++ #[test]+ fn corrupt_file_is_moved_aside_and_the_store_starts_empty() {+ let dir = tempfile::tempdir().unwrap();+ let path = dir.path().join(FILE_NAME);+ std::fs::write(&path, "{ not json").unwrap();+ let mut s = Store::at(&path);+ assert!(s.get(MAIN).is_none() && s.read_only().is_none());+ let aside = s.moved_aside().unwrap().to_path_buf();+ assert_eq!(std::fs::read_to_string(aside).unwrap(), "{ not json");+ assert!(!path.exists());+ s.set(MAIN, &sample(1));+ s.save().unwrap();+ assert!(Store::at(&path).get(MAIN).is_some());+ assert_eq!(siblings(dir.path()).len(), 2);+ }++ #[test]+ fn json_without_a_schema_version_counts_as_corrupt() {+ let dir = tempfile::tempdir().unwrap();+ let path = dir.path().join(FILE_NAME);+ std::fs::write(&path, r#"{"windows":{}}"#).unwrap();+ assert!(Store::at(&path).moved_aside().is_some());+ }++ #[test]+ fn newer_schema_is_read_but_never_written() {+ let dir = tempfile::tempdir().unwrap();+ let path = dir.path().join(FILE_NAME);+ let mut doc = json!({ "schema_version": 2, "windows": {}, "future": [1] });+ doc["windows"][MAIN] = serde_json::to_value(sample(7)).unwrap();+ let text = serde_json::to_string(&doc).unwrap();+ std::fs::write(&path, &text).unwrap();+ let mut s = Store::at(&path);+ assert_eq!(s.read_only(), Some(&ReadOnly::NewerSchema(2)));+ assert_eq!(s.get(MAIN), Some(sample(7)));+ s.set(MAIN, &sample(8));+ assert!(!s.save().unwrap());+ assert_eq!(std::fs::read_to_string(&path).unwrap(), text);+ assert_eq!(siblings(dir.path()), [FILE_NAME]);+ }++ #[test]+ fn unknown_keys_survive_a_write() {+ let dir = tempfile::tempdir().unwrap();+ let path = dir.path().join(FILE_NAME);+ let mut doc = json!({ "schema_version": 1, "note": {"a": 1}, "windows": { "odd": {"unexpected": true} } });+ doc["windows"][MAIN] = serde_json::to_value(sample(1)).unwrap();+ std::fs::write(&path, doc.to_string()).unwrap();+ let mut s = Store::at(&path);+ assert!(s.get("odd").is_none());+ s.set(MAIN, &sample(2));+ s.save().unwrap();+ let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();+ assert_eq!(v["note"], json!({"a": 1}));+ assert_eq!(v["windows"]["odd"], json!({"unexpected": true}));+ assert_eq!(Store::at(&path).get(MAIN), Some(sample(2)));+ }++ #[test]+ fn newer_schema_with_an_unexpected_shape_is_not_moved_aside() {+ let dir = tempfile::tempdir().unwrap();+ let path = dir.path().join(FILE_NAME);+ std::fs::write(&path, r#"{"schema_version":3,"windows":[1,2]}"#).unwrap();+ let s = Store::at(&path);+ assert_eq!(s.read_only(), Some(&ReadOnly::NewerSchema(3)));+ assert!(s.moved_aside().is_none() && path.exists());+ }++ #[test]+ fn non_finite_placements_are_never_stored() {+ let mut s = Store::in_memory();+ let mut p = sample(1);+ p.normal.size[0] = f32::NAN;+ s.set(MAIN, &p);+ assert!(s.get(MAIN).is_none() && !s.is_dirty());+ let mut p = sample(1);+ p.normal.pos[1] = f32::INFINITY;+ s.set(MAIN, &p);+ assert_eq!(s.get(MAIN).unwrap().normal.pos, [0.0, 0.0]);+ }++ #[test]+ fn set_keeps_unknown_fields_of_the_entry() {+ let dir = tempfile::tempdir().unwrap();+ let path = dir.path().join(FILE_NAME);+ let mut entry = serde_json::to_value(sample(1)).unwrap();+ entry["later"] = json!("x");+ std::fs::write(&path, json!({"schema_version": 1, "windows": {MAIN: entry}}).to_string()).unwrap();+ let mut s = Store::at(&path);+ s.set(MAIN, &sample(2));+ s.save().unwrap();+ let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();+ assert_eq!(v["windows"][MAIN]["later"], "x");+ assert_eq!(Store::at(&path).get(MAIN), Some(sample(2)));+ }++ #[test]+ fn dirty_clears_only_after_the_write_is_reported() {+ let dir = tempfile::tempdir().unwrap();+ let mut s = Store::at(&dir.path().join(FILE_NAME));+ s.set(MAIN, &sample(1));+ let first = s.take_pending().unwrap();+ s.set(MAIN, &sample(2));+ write_pending(&first).unwrap();+ s.mark_saved(first.generation);+ assert!(s.is_dirty(), "changed again while the first write was in flight");+ let second = s.take_pending().unwrap();+ assert!(s.is_dirty(), "taking a write does not clear it");+ write_pending(&second).unwrap();+ s.mark_saved(second.generation);+ assert!(!s.is_dirty() && s.take_pending().is_none());+ }++ #[test]+ fn in_memory_store_keeps_values_and_writes_nothing() {+ let mut s = Store::in_memory();+ s.set(MAIN, &sample(3));+ assert_eq!(s.get(MAIN), Some(sample(3)));+ assert!(!s.save().unwrap());+ assert!(s.path().is_none());+ }++ #[test]+ fn open_puts_the_file_beside_config_toml() {+ let dir = tempfile::tempdir().unwrap();+ let s = Store::open_with("app", &Portable::DEFAULT, &Location::Dir(dir.path().to_path_buf()), &Probe::default());+ assert_eq!(s.path(), Some(dir.path().join(FILE_NAME).as_path()));+ let mem = Store::open_with("app", &Portable::DEFAULT, &Location::Memory, &Probe::default());+ assert!(mem.path().is_none());+ }+}