feat(app): persist window geometry across launches

Settings gains an optional window_geometry field (size + position)
serialized via #[serde(default)] so legacy settings.json files without
the field deserialize cleanly to None. On launch the app restores
the persisted dimensions and position; first run and pre-upgrade
saves keep the existing 1280x800 centered default.

settings_plugin records changes from WindowResized and WindowMoved
into a PendingWindowGeometry resource and writes them to disk through
the existing atomic .tmp+rename path once the events have stayed
quiet for WINDOW_GEOMETRY_DEBOUNCE_SECS (0.5s). A merge_geometry
helper preserves whichever component (size or position) the latest
event burst didn't carry, so a position-only WindowMoved never wipes
the recorded size.

Pure should_persist_geometry and merge_geometry helpers are unit
tested for the boundary cases. Headless integration tests cover the
full flow: a single resize event then a quiet window persists, a
move event after a resize updates only position, a rapid storm
collapses to the final size, and a quiet frame with no events
leaves the geometry untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-01 02:17:54 +00:00
parent 59316de1e9
commit 6e7705b256
5 changed files with 420 additions and 6 deletions
+1 -1
View File
@@ -126,7 +126,7 @@ pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS};
pub mod settings;
pub use settings::{
load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend,
Theme,
Theme, WindowGeometry,
};
pub mod auth_tokens;
+86
View File
@@ -61,6 +61,25 @@ pub enum SyncBackend {
}
/// Persisted window size (in logical pixels) and screen position
/// (top-left corner, in physical pixels) — restored on next launch.
///
/// Stored inside [`Settings::window_geometry`]. `None` on `Settings`
/// means "use platform defaults"; a populated value is written every
/// time the player resizes or moves the window so the next launch
/// reopens at the same geometry.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct WindowGeometry {
/// Logical width of the window in pixels.
pub width: u32,
/// Logical height of the window in pixels.
pub height: u32,
/// X coordinate of the window's top-left corner, in physical pixels.
pub x: i32,
/// Y coordinate of the window's top-left corner, in physical pixels.
pub y: i32,
}
/// Persistent user settings.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Settings {
@@ -98,6 +117,13 @@ pub struct Settings {
/// solely on colour.
#[serde(default)]
pub color_blind_mode: bool,
/// Window size and screen position to restore on next launch. `None`
/// means "use platform defaults" — set on first run, then populated
/// as the player resizes / moves the window. Older `settings.json`
/// files written before this field existed deserialize cleanly to
/// `None` thanks to `#[serde(default)]`.
#[serde(default)]
pub window_geometry: Option<WindowGeometry>,
}
fn default_draw_mode() -> DrawMode {
@@ -125,6 +151,7 @@ impl Default for Settings {
selected_background: 0,
first_run_complete: false,
color_blind_mode: false,
window_geometry: None,
}
}
}
@@ -276,6 +303,7 @@ mod tests {
selected_background: 0,
first_run_complete: true,
color_blind_mode: false,
window_geometry: None,
};
save_settings_to(&path, &s).expect("save");
let loaded = load_settings_from(&path);
@@ -406,4 +434,62 @@ mod tests {
assert_eq!(loaded.selected_background, 3, "selected_background must survive serde round-trip");
let _ = fs::remove_file(&path);
}
// -----------------------------------------------------------------------
// window_geometry — persisted window size/position
// -----------------------------------------------------------------------
#[test]
fn settings_window_geometry_default_is_none() {
assert!(
Settings::default().window_geometry.is_none(),
"default window_geometry must be None so first launch uses platform defaults"
);
}
#[test]
fn settings_with_window_geometry_round_trip() {
let path = tmp_path("window_geometry_round_trip");
let _ = fs::remove_file(&path);
let geom = WindowGeometry {
width: 1440,
height: 900,
x: 120,
y: 80,
};
let s = Settings {
window_geometry: Some(geom),
..Settings::default()
};
save_settings_to(&path, &s).expect("save");
let loaded = load_settings_from(&path);
assert_eq!(
loaded.window_geometry,
Some(geom),
"window_geometry must survive serde round-trip"
);
let _ = fs::remove_file(&path);
}
#[test]
fn legacy_settings_without_window_geometry_deserializes_to_none() {
// A settings.json written by an older version of the game will be
// missing this field entirely. `#[serde(default)]` on the field
// must yield `None` rather than failing the whole deserialise.
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
assert!(
s.window_geometry.is_none(),
"legacy settings.json missing window_geometry must deserialize to None"
);
}
#[test]
fn window_geometry_explicit_null_deserializes_to_none() {
// An explicit `"window_geometry": null` is also valid input that
// must yield None — keeps tooling that hand-edits the file safe.
let json = br#"{ "window_geometry": null }"#;
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
assert!(s.window_geometry.is_none());
}
}