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:
@@ -39,6 +39,21 @@ fn main() {
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let sync_provider = provider_for_backend(&settings.sync_backend);
|
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()
|
App::new()
|
||||||
.add_plugins(
|
.add_plugins(
|
||||||
DefaultPlugins
|
DefaultPlugins
|
||||||
@@ -48,8 +63,8 @@ fn main() {
|
|||||||
// X11/Wayland WM_CLASS so taskbar managers group
|
// X11/Wayland WM_CLASS so taskbar managers group
|
||||||
// multiple windows of this app correctly.
|
// multiple windows of this app correctly.
|
||||||
name: Some("solitaire-quest".into()),
|
name: Some("solitaire-quest".into()),
|
||||||
resolution: (1280u32, 800u32).into(),
|
resolution: window_resolution,
|
||||||
position: WindowPosition::Centered(MonitorSelection::Primary),
|
position: window_position,
|
||||||
// AutoNoVsync prefers Mailbox (triple-buffered) and
|
// AutoNoVsync prefers Mailbox (triple-buffered) and
|
||||||
// falls back to Immediate, eliminating the vsync stall
|
// falls back to Immediate, eliminating the vsync stall
|
||||||
// that AutoVsync produces during continuous window
|
// that AutoVsync produces during continuous window
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS};
|
|||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub use settings::{
|
pub use settings::{
|
||||||
load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend,
|
load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend,
|
||||||
Theme,
|
Theme, WindowGeometry,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub mod auth_tokens;
|
pub mod auth_tokens;
|
||||||
|
|||||||
@@ -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.
|
/// Persistent user settings.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
@@ -98,6 +117,13 @@ pub struct Settings {
|
|||||||
/// solely on colour.
|
/// solely on colour.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub color_blind_mode: bool,
|
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 {
|
fn default_draw_mode() -> DrawMode {
|
||||||
@@ -125,6 +151,7 @@ impl Default for Settings {
|
|||||||
selected_background: 0,
|
selected_background: 0,
|
||||||
first_run_complete: false,
|
first_run_complete: false,
|
||||||
color_blind_mode: false,
|
color_blind_mode: false,
|
||||||
|
window_geometry: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -276,6 +303,7 @@ mod tests {
|
|||||||
selected_background: 0,
|
selected_background: 0,
|
||||||
first_run_complete: true,
|
first_run_complete: true,
|
||||||
color_blind_mode: false,
|
color_blind_mode: false,
|
||||||
|
window_geometry: None,
|
||||||
};
|
};
|
||||||
save_settings_to(&path, &s).expect("save");
|
save_settings_to(&path, &s).expect("save");
|
||||||
let loaded = load_settings_from(&path);
|
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");
|
assert_eq!(loaded.selected_background, 3, "selected_background must survive serde round-trip");
|
||||||
let _ = fs::remove_file(&path);
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,8 @@ pub use onboarding_plugin::{OnboardingPlugin, OnboardingScreen};
|
|||||||
pub use pause_plugin::{ForfeitConfirmScreen, PausePlugin, PauseScreen, PausedResource};
|
pub use pause_plugin::{ForfeitConfirmScreen, PausePlugin, PauseScreen, PausedResource};
|
||||||
pub use profile_plugin::{ProfilePlugin, ProfileScreen};
|
pub use profile_plugin::{ProfilePlugin, ProfileScreen};
|
||||||
pub use settings_plugin::{
|
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 layout::{compute_layout, Layout, LayoutResource};
|
||||||
pub use resources::{DragState, GameStateResource, HintCycleIndex, SettingsScrollPos, SyncStatus, SyncStatusResource};
|
pub use resources::{DragState, GameStateResource, HintCycleIndex, SettingsScrollPos, SyncStatus, SyncStatusResource};
|
||||||
|
|||||||
@@ -14,8 +14,12 @@ use std::path::PathBuf;
|
|||||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::ui::{ComputedNode, UiGlobalTransform};
|
use bevy::ui::{ComputedNode, UiGlobalTransform};
|
||||||
|
use bevy::window::{WindowMoved, WindowResized};
|
||||||
use solitaire_core::game_state::DrawMode;
|
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::events::{ManualSyncRequestEvent, ToggleSettingsRequestEvent};
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
@@ -56,6 +60,24 @@ pub struct SettingsStoragePath(pub Option<PathBuf>);
|
|||||||
#[derive(Resource, Debug, Clone, Default)]
|
#[derive(Resource, Debug, Clone, Default)]
|
||||||
pub struct SettingsScreen(pub bool);
|
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<WindowGeometry>,
|
||||||
|
/// `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.
|
/// Fired whenever settings change so consumers (audio, UI) can react.
|
||||||
#[derive(Message, Debug, Clone)]
|
#[derive(Message, Debug, Clone)]
|
||||||
pub struct SettingsChangedEvent(pub Settings);
|
pub struct SettingsChangedEvent(pub Settings);
|
||||||
@@ -198,11 +220,27 @@ impl Plugin for SettingsPlugin {
|
|||||||
.insert_resource(SettingsStoragePath(self.storage_path.clone()))
|
.insert_resource(SettingsStoragePath(self.storage_path.clone()))
|
||||||
.init_resource::<SettingsScreen>()
|
.init_resource::<SettingsScreen>()
|
||||||
.init_resource::<SettingsScrollPos>()
|
.init_resource::<SettingsScrollPos>()
|
||||||
|
.init_resource::<PendingWindowGeometry>()
|
||||||
.add_message::<SettingsChangedEvent>()
|
.add_message::<SettingsChangedEvent>()
|
||||||
.add_message::<ManualSyncRequestEvent>()
|
.add_message::<ManualSyncRequestEvent>()
|
||||||
.add_message::<ToggleSettingsRequestEvent>()
|
.add_message::<ToggleSettingsRequestEvent>()
|
||||||
.add_message::<bevy::input::mouse::MouseWheel>()
|
.add_message::<bevy::input::mouse::MouseWheel>()
|
||||||
.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::<WindowResized>()
|
||||||
|
.add_message::<WindowMoved>()
|
||||||
|
.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 {
|
if self.ui_enabled {
|
||||||
app.add_systems(
|
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<WindowGeometry>,
|
||||||
|
new_size: Option<(u32, u32)>,
|
||||||
|
new_pos: Option<(i32, i32)>,
|
||||||
|
) -> Option<WindowGeometry> {
|
||||||
|
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
|
// 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<Time>,
|
||||||
|
mut resized: MessageReader<WindowResized>,
|
||||||
|
mut moved: MessageReader<WindowMoved>,
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut pending: ResMut<PendingWindowGeometry>,
|
||||||
|
) {
|
||||||
|
// Read .last() — only the final event matters for persistence; the
|
||||||
|
// intermediate sizes/positions are noise during a drag.
|
||||||
|
let new_size = resized
|
||||||
|
.read()
|
||||||
|
.last()
|
||||||
|
.map(|ev| (ev.width.round().max(0.0) as u32, ev.height.round().max(0.0) as u32));
|
||||||
|
let new_pos = moved.read().last().map(|ev| (ev.position.x, ev.position.y));
|
||||||
|
|
||||||
|
if new_size.is_none() && new_pos.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fold the new components into the existing pending value (if any),
|
||||||
|
// otherwise into the persisted geometry from settings.
|
||||||
|
let baseline = pending.geometry.or(settings.0.window_geometry);
|
||||||
|
let Some(geometry) = merge_geometry(baseline, new_size, new_pos) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
pending.geometry = Some(geometry);
|
||||||
|
pending.last_changed_secs = time.elapsed_secs();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After [`WINDOW_GEOMETRY_DEBOUNCE_SECS`] of quiet (no `WindowResized` or
|
||||||
|
/// `WindowMoved` events arriving), commits the pending geometry to
|
||||||
|
/// `SettingsResource` and writes `settings.json`. Skips the write when the
|
||||||
|
/// pending value already matches the settings (e.g. a resize that was
|
||||||
|
/// reverted, or a synthetic event with no geometry change).
|
||||||
|
fn persist_window_geometry_after_debounce(
|
||||||
|
time: Res<Time>,
|
||||||
|
mut pending: ResMut<PendingWindowGeometry>,
|
||||||
|
mut settings: ResMut<SettingsResource>,
|
||||||
|
path: Res<SettingsStoragePath>,
|
||||||
|
mut changed: MessageWriter<SettingsChangedEvent>,
|
||||||
|
) {
|
||||||
|
let Some(new_geom) = pending.geometry else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !should_persist_geometry(time.elapsed_secs(), pending.last_changed_secs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always clear the pending slot regardless of whether we end up
|
||||||
|
// writing — otherwise an idempotent change would re-trigger this
|
||||||
|
// system every tick.
|
||||||
|
pending.geometry = None;
|
||||||
|
|
||||||
|
if settings.0.window_geometry == Some(new_geom) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settings.0.window_geometry = Some(new_geom);
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// UI construction
|
// UI construction
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1512,6 +1649,181 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Window geometry persistence
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_persist_geometry_respects_debounce_window() {
|
||||||
|
// Within the debounce window: not yet.
|
||||||
|
assert!(!should_persist_geometry(10.0, 9.7));
|
||||||
|
assert!(!should_persist_geometry(
|
||||||
|
10.0,
|
||||||
|
10.0 - WINDOW_GEOMETRY_DEBOUNCE_SECS + 0.01
|
||||||
|
));
|
||||||
|
// Exactly the debounce window: allowed (>= comparison).
|
||||||
|
assert!(should_persist_geometry(
|
||||||
|
10.0,
|
||||||
|
10.0 - WINDOW_GEOMETRY_DEBOUNCE_SECS
|
||||||
|
));
|
||||||
|
// Well past the debounce window: allowed.
|
||||||
|
assert!(should_persist_geometry(20.0, 10.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_geometry_uses_existing_when_event_components_missing() {
|
||||||
|
let existing = WindowGeometry { width: 1280, height: 800, x: 100, y: 50 };
|
||||||
|
// Position-only event keeps existing size.
|
||||||
|
let merged = merge_geometry(Some(existing), None, Some((200, 75))).unwrap();
|
||||||
|
assert_eq!(merged.width, 1280);
|
||||||
|
assert_eq!(merged.height, 800);
|
||||||
|
assert_eq!(merged.x, 200);
|
||||||
|
assert_eq!(merged.y, 75);
|
||||||
|
// Size-only event keeps existing position.
|
||||||
|
let merged = merge_geometry(Some(existing), Some((1024, 768)), None).unwrap();
|
||||||
|
assert_eq!(merged.width, 1024);
|
||||||
|
assert_eq!(merged.height, 768);
|
||||||
|
assert_eq!(merged.x, 100);
|
||||||
|
assert_eq!(merged.y, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_geometry_returns_none_when_size_unknown() {
|
||||||
|
// No existing geometry, no size in the event → can't fabricate one.
|
||||||
|
assert!(merge_geometry(None, None, Some((10, 20))).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drives `app.update()` past [`WINDOW_GEOMETRY_DEBOUNCE_SECS`] using
|
||||||
|
/// `TimeUpdateStrategy::ManualDuration`. `Time<Virtual>` clamps each
|
||||||
|
/// frame's delta to `max_delta` (default 250 ms), so we step in 150 ms
|
||||||
|
/// slices and run enough ticks to comfortably exceed the debounce
|
||||||
|
/// window after the first record tick has set `last_changed_secs`.
|
||||||
|
fn advance_past_geometry_debounce(app: &mut App) {
|
||||||
|
use bevy::time::TimeUpdateStrategy;
|
||||||
|
use std::time::Duration;
|
||||||
|
app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32(
|
||||||
|
0.15,
|
||||||
|
)));
|
||||||
|
// Tick 1 sets last_changed_secs from any pending events. Each
|
||||||
|
// subsequent tick advances the clock by 150 ms; five ticks total
|
||||||
|
// buys 0.75 s of elapsed time relative to the record tick — well
|
||||||
|
// past the 0.5 s debounce window.
|
||||||
|
for _ in 0..5 {
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fire_resize(app: &mut App, width: f32, height: f32) {
|
||||||
|
app.world_mut().write_message(WindowResized {
|
||||||
|
window: bevy::ecs::entity::Entity::PLACEHOLDER,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fire_move(app: &mut App, x: i32, y: i32) {
|
||||||
|
app.world_mut().write_message(WindowMoved {
|
||||||
|
window: bevy::ecs::entity::Entity::PLACEHOLDER,
|
||||||
|
position: IVec2::new(x, y),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resize_event_then_quiet_persists_window_geometry() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Sanity: geometry starts unset (default).
|
||||||
|
assert!(
|
||||||
|
app.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.window_geometry
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fire a resize, then go quiet for past the debounce.
|
||||||
|
fire_resize(&mut app, 1500.0, 950.0);
|
||||||
|
advance_past_geometry_debounce(&mut app);
|
||||||
|
|
||||||
|
let geom = app
|
||||||
|
.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.window_geometry
|
||||||
|
.expect("geometry should be persisted after debounce");
|
||||||
|
assert_eq!(geom.width, 1500);
|
||||||
|
assert_eq!(geom.height, 950);
|
||||||
|
// Position not yet observed → defaults to 0, 0 since there was
|
||||||
|
// no existing geometry to fall back on.
|
||||||
|
assert_eq!(geom.x, 0);
|
||||||
|
assert_eq!(geom.y, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_event_after_resize_updates_position_only() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
|
||||||
|
// First, establish a baseline geometry via a resize event.
|
||||||
|
fire_resize(&mut app, 1280.0, 800.0);
|
||||||
|
advance_past_geometry_debounce(&mut app);
|
||||||
|
let baseline = app
|
||||||
|
.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.window_geometry
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(baseline.width, 1280);
|
||||||
|
|
||||||
|
// Now fire a move-only event — size must be preserved from the
|
||||||
|
// existing geometry.
|
||||||
|
fire_move(&mut app, 250, 175);
|
||||||
|
advance_past_geometry_debounce(&mut app);
|
||||||
|
|
||||||
|
let geom = app
|
||||||
|
.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.window_geometry
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(geom.width, 1280, "size must be preserved across a move-only update");
|
||||||
|
assert_eq!(geom.height, 800);
|
||||||
|
assert_eq!(geom.x, 250);
|
||||||
|
assert_eq!(geom.y, 175);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rapid_resize_storm_only_persists_final_size() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
|
||||||
|
// Burst of resize events on a single frame — only the last one
|
||||||
|
// should be the eventually-persisted size.
|
||||||
|
fire_resize(&mut app, 900.0, 600.0);
|
||||||
|
fire_resize(&mut app, 1100.0, 700.0);
|
||||||
|
fire_resize(&mut app, 1400.0, 850.0);
|
||||||
|
advance_past_geometry_debounce(&mut app);
|
||||||
|
|
||||||
|
let geom = app
|
||||||
|
.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.window_geometry
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!((geom.width, geom.height), (1400, 850));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_window_events_no_geometry_change() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Just advance time — without any events, settings must stay clean.
|
||||||
|
advance_past_geometry_debounce(&mut app);
|
||||||
|
assert!(
|
||||||
|
app.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.window_geometry
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scroll_clamps_offset_to_zero_at_top() {
|
fn scroll_clamps_offset_to_zero_at_top() {
|
||||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||||
|
|||||||
Reference in New Issue
Block a user