diff --git a/solitaire_app/src/main.rs b/solitaire_app/src/main.rs index ab92ec2..358ace6 100644 --- a/solitaire_app/src/main.rs +++ b/solitaire_app/src/main.rs @@ -39,6 +39,21 @@ fn main() { .unwrap_or_default(); let sync_provider = provider_for_backend(&settings.sync_backend); + // Restore the previous window geometry if the player has one saved. + // Otherwise open at the platform default (1280×800, centred on the + // primary monitor). The window_geometry field is None on first run + // and after upgrading from a build that didn't persist geometry. + let (window_resolution, window_position) = match settings.window_geometry { + Some(geom) => ( + (geom.width, geom.height).into(), + WindowPosition::At(IVec2::new(geom.x, geom.y)), + ), + None => ( + (1280u32, 800u32).into(), + WindowPosition::Centered(MonitorSelection::Primary), + ), + }; + App::new() .add_plugins( DefaultPlugins @@ -48,8 +63,8 @@ fn main() { // X11/Wayland WM_CLASS so taskbar managers group // multiple windows of this app correctly. name: Some("solitaire-quest".into()), - resolution: (1280u32, 800u32).into(), - position: WindowPosition::Centered(MonitorSelection::Primary), + resolution: window_resolution, + position: window_position, // AutoNoVsync prefers Mailbox (triple-buffered) and // falls back to Immediate, eliminating the vsync stall // that AutoVsync produces during continuous window diff --git a/solitaire_data/src/lib.rs b/solitaire_data/src/lib.rs index 604f6c7..eea23ee 100644 --- a/solitaire_data/src/lib.rs +++ b/solitaire_data/src/lib.rs @@ -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; diff --git a/solitaire_data/src/settings.rs b/solitaire_data/src/settings.rs index 8476f73..2ac329f 100644 --- a/solitaire_data/src/settings.rs +++ b/solitaire_data/src/settings.rs @@ -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, } 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()); + } } diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index eaab5e3..01d7b98 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -93,7 +93,8 @@ pub use onboarding_plugin::{OnboardingPlugin, OnboardingScreen}; pub use pause_plugin::{ForfeitConfirmScreen, PausePlugin, PauseScreen, PausedResource}; pub use profile_plugin::{ProfilePlugin, ProfileScreen}; pub use settings_plugin::{ - SettingsChangedEvent, SettingsPlugin, SettingsResource, SettingsScreen, SFX_STEP, + PendingWindowGeometry, SettingsChangedEvent, SettingsPlugin, SettingsResource, SettingsScreen, + SFX_STEP, WINDOW_GEOMETRY_DEBOUNCE_SECS, }; pub use layout::{compute_layout, Layout, LayoutResource}; pub use resources::{DragState, GameStateResource, HintCycleIndex, SettingsScrollPos, SyncStatus, SyncStatusResource}; diff --git a/solitaire_engine/src/settings_plugin.rs b/solitaire_engine/src/settings_plugin.rs index 8252d7f..b98f3d1 100644 --- a/solitaire_engine/src/settings_plugin.rs +++ b/solitaire_engine/src/settings_plugin.rs @@ -14,8 +14,12 @@ use std::path::PathBuf; use bevy::input::mouse::{MouseScrollUnit, MouseWheel}; use bevy::prelude::*; use bevy::ui::{ComputedNode, UiGlobalTransform}; +use bevy::window::{WindowMoved, WindowResized}; use solitaire_core::game_state::DrawMode; -use solitaire_data::{load_settings_from, save_settings_to, settings_file_path, settings::Theme, AnimSpeed, Settings}; +use solitaire_data::{ + load_settings_from, save_settings_to, settings_file_path, settings::Theme, AnimSpeed, Settings, + WindowGeometry, +}; use crate::events::{ManualSyncRequestEvent, ToggleSettingsRequestEvent}; use crate::font_plugin::FontResource; @@ -56,6 +60,24 @@ pub struct SettingsStoragePath(pub Option); #[derive(Resource, Debug, Clone, Default)] pub struct SettingsScreen(pub bool); +/// Debounce window for persisting window-geometry changes, in seconds. +/// +/// `WindowResized` and `WindowMoved` fire continuously during a resize/ +/// move drag, so writing to disk on every event would thrash the file +/// system. Instead the geometry-watch system records the pending value +/// and waits this long after the *last* event before saving. +pub const WINDOW_GEOMETRY_DEBOUNCE_SECS: f32 = 0.5; + +/// Tracks a pending window-geometry change so the saver can debounce +/// `WindowResized` / `WindowMoved` storms during a resize / move drag. +#[derive(Resource, Debug, Default, Clone, Copy)] +pub struct PendingWindowGeometry { + /// Most recent observed geometry. `None` when nothing is pending. + pub geometry: Option, + /// `Time::elapsed_secs()` value at which `geometry` was last updated. + pub last_changed_secs: f32, +} + /// Fired whenever settings change so consumers (audio, UI) can react. #[derive(Message, Debug, Clone)] pub struct SettingsChangedEvent(pub Settings); @@ -198,11 +220,27 @@ impl Plugin for SettingsPlugin { .insert_resource(SettingsStoragePath(self.storage_path.clone())) .init_resource::() .init_resource::() + .init_resource::() .add_message::() .add_message::() .add_message::() .add_message::() - .add_systems(Update, (handle_volume_keys, toggle_settings_screen, scroll_settings_panel)); + // `WindowResized` / `WindowMoved` are real Bevy window events + // and emitted by the windowing backend under `DefaultPlugins`, + // but we register them explicitly here so the geometry watcher + // also runs cleanly under `MinimalPlugins` (tests). + .add_message::() + .add_message::() + .add_systems( + Update, + ( + handle_volume_keys, + toggle_settings_screen, + scroll_settings_panel, + record_window_geometry_changes, + persist_window_geometry_after_debounce, + ), + ); if self.ui_enabled { app.add_systems( @@ -234,6 +272,32 @@ fn persist(path: &SettingsStoragePath, settings: &Settings) { } } +/// Pure helper: returns `true` when a pending geometry change has sat +/// quietly long enough to flush to disk. +/// +/// Extracted so the debounce condition can be unit-tested without +/// spinning up a Bevy app. +fn should_persist_geometry(now_secs: f32, last_changed_secs: f32) -> bool { + (now_secs - last_changed_secs) >= WINDOW_GEOMETRY_DEBOUNCE_SECS +} + +/// Returns the geometry implied by an event pair `(width, height, x, y)`, +/// using each component from `existing` when the corresponding event-derived +/// value is `None`. Returns `None` when neither side supplies width/height. +/// +/// Pure helper so the merge logic can be unit-tested without an `App`. +fn merge_geometry( + existing: Option, + new_size: Option<(u32, u32)>, + new_pos: Option<(i32, i32)>, +) -> Option { + let (width, height) = new_size.or_else(|| existing.map(|g| (g.width, g.height)))?; + let (x, y) = new_pos + .or_else(|| existing.map(|g| (g.x, g.y))) + .unwrap_or((0, 0)); + Some(WindowGeometry { width, height, x, y }) +} + // --------------------------------------------------------------------------- // Systems // --------------------------------------------------------------------------- @@ -764,6 +828,79 @@ fn scroll_settings_panel( } } +// --------------------------------------------------------------------------- +// Window geometry persistence +// --------------------------------------------------------------------------- + +/// Records `WindowResized` and `WindowMoved` events into +/// [`PendingWindowGeometry`], coalescing every event arriving this frame +/// into the latest pending geometry. +/// +/// The actual disk write is debounced — see +/// [`persist_window_geometry_after_debounce`] — so the file system isn't +/// hit on every pixel of a resize / move drag. +fn record_window_geometry_changes( + time: Res