diff --git a/solitaire_app/src/main.rs b/solitaire_app/src/main.rs index ef9bee4..1ce1dad 100644 --- a/solitaire_app/src/main.rs +++ b/solitaire_app/src/main.rs @@ -153,7 +153,12 @@ fn main() { // monitor opens the same 1280×800 window that a 1080p monitor // does — visually tiny relative to screen. Skipped entirely when // saved geometry was applied; the player's preference always wins. - if !had_saved_geometry { + // + // Players who specifically want the literal 1280×800 baseline on + // every fresh launch can flip `disable_smart_default_size` in + // Settings to opt out. The flag is checked once at startup; a + // mid-session change applies on the next launch. + if !had_saved_geometry && !settings.disable_smart_default_size { app.add_systems(Update, apply_smart_default_window_size); } diff --git a/solitaire_data/src/settings.rs b/solitaire_data/src/settings.rs index cc3965b..83cc454 100644 --- a/solitaire_data/src/settings.rs +++ b/solitaire_data/src/settings.rs @@ -181,6 +181,20 @@ pub struct Settings { /// solver retry loop — see `solitaire_engine::handle_new_game`. #[serde(default)] pub winnable_deals_only: bool, + /// When `true`, suppresses the launch-time + /// `apply_smart_default_window_size` system: the window opens at + /// the literal `(1280, 800)` default instead of resizing to ~70 % + /// of the primary monitor's logical size on the first frame. For + /// players who specifically prefer the 1280×800 baseline on every + /// fresh launch (i.e. installs without saved geometry). + /// + /// Older `settings.json` files written before this field existed + /// deserialize cleanly to `false` via `#[serde(default)]`, which + /// preserves the smart-default behaviour shipped in v0.19.0. + /// Saved-geometry launches are unaffected by this flag — the + /// player's last window size always wins. + #[serde(default)] + pub disable_smart_default_size: bool, /// Per-move duration during replay playback, in seconds. Range /// `[REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS]`; /// default mirrors `solitaire_engine::replay_playback::REPLAY_MOVE_INTERVAL_SECS` @@ -306,6 +320,7 @@ impl Default for Settings { tooltip_delay_secs: default_tooltip_delay(), time_bonus_multiplier: default_time_bonus_multiplier(), winnable_deals_only: false, + disable_smart_default_size: false, replay_move_interval_secs: default_replay_move_interval_secs(), } } diff --git a/solitaire_engine/src/settings_plugin.rs b/solitaire_engine/src/settings_plugin.rs index 16ddb83..f15261f 100644 --- a/solitaire_engine/src/settings_plugin.rs +++ b/solitaire_engine/src/settings_plugin.rs @@ -144,6 +144,13 @@ struct ReplayMoveIntervalText; #[derive(Component, Debug)] struct WinnableDealsOnlyText; +/// Marks the `Text` node showing the current "Smart window size" +/// state ("ON" / "OFF") in the Gameplay section. The flag is stored +/// negatively in `Settings::disable_smart_default_size`, so the +/// label inverts: "ON" = smart sizing enabled (the default). +#[derive(Component, Debug)] +struct SmartDefaultSizeText; + /// Marks the scrollable inner card so the mouse-wheel system can target it. #[derive(Component, Debug)] struct SettingsPanelScrollable; @@ -199,6 +206,13 @@ enum SettingsButton { /// [`solitaire_core::solver::try_solve`] until one is provably /// winnable (or the retry cap is hit). Off by default. ToggleWinnableDealsOnly, + /// Toggle the inverse of [`Settings::disable_smart_default_size`]. + /// When the visible label reads "ON", the launch-time window + /// sizer scales the window to ~70 % of the primary monitor on a + /// fresh install; "OFF" pins the literal 1280×800 baseline. The + /// flag only affects launches without saved geometry — the + /// player's last window size always wins. + ToggleSmartDefaultSize, SyncNow, Done, /// Select a specific card-back by index from the picker row. @@ -236,6 +250,8 @@ impl SettingsButton { // sits between TimeBonusUp (48) and the Cosmetic section. SettingsButton::ReplayMoveIntervalDown => 49, SettingsButton::ReplayMoveIntervalUp => 49, + // Smart-default-size toggle — sits at the end of Gameplay. + SettingsButton::ToggleSmartDefaultSize => 50, // Cosmetic section SettingsButton::ToggleTheme => 55, SettingsButton::ToggleColorBlind => 60, @@ -330,6 +346,7 @@ impl Plugin for SettingsPlugin { update_time_bonus_multiplier_text, update_replay_move_interval_text, update_winnable_deals_only_text, + update_smart_default_size_text, attach_focusable_to_settings_buttons, scroll_focus_into_view, ), @@ -600,6 +617,21 @@ fn update_winnable_deals_only_text( } } +/// Refreshes the live "Smart window size" toggle value whenever +/// `SettingsResource` changes. The flag is stored negatively as +/// `disable_smart_default_size`, so the label inverts. +fn update_smart_default_size_text( + settings: Res, + mut text_nodes: Query<&mut Text, With>, +) { + if !settings.is_changed() { + return; + } + for mut text in &mut text_nodes { + **text = smart_default_size_label(!settings.0.disable_smart_default_size); + } +} + /// Refreshes the live tooltip-delay value in the Gameplay section /// whenever `SettingsResource` changes (slider buttons, hand-edited /// settings.json reload, etc.). @@ -854,6 +886,17 @@ fn handle_settings_buttons( // The Text node is refreshed by `update_winnable_deals_only_text` // on the next frame via `settings.is_changed()`. } + SettingsButton::ToggleSmartDefaultSize => { + settings.0.disable_smart_default_size = + !settings.0.disable_smart_default_size; + persist(&path, &settings.0); + changed.write(SettingsChangedEvent(settings.0.clone())); + // The Text node is refreshed by + // `update_smart_default_size_text` next frame. The + // sizer system is gated only at startup, so flipping + // this mid-session takes effect on the next launch — + // documented on the field in `solitaire_data::Settings`. + } SettingsButton::SelectCardBack(idx) => { settings.0.selected_card_back = *idx; persist(&path, &settings.0); @@ -915,6 +958,14 @@ fn winnable_deals_only_label(enabled: bool) -> String { if enabled { "ON".into() } else { "OFF".into() } } +/// Display string for the "Smart window size" toggle. The argument +/// is the *enabled* state (i.e. the inverse of the underlying +/// `disable_smart_default_size` field) so reading the label gives +/// the player intuitive ON/OFF semantics. +fn smart_default_size_label(enabled: bool) -> String { + if enabled { "ON".into() } else { "OFF".into() } +} + /// Formats the tooltip-hover delay for display in the Settings panel. /// `0.0` reads as `"Instant"` so the zero-delay case has a name; any /// other value prints as `"{n:.1} s"` (e.g. `"0.5 s"`, `"1.2 s"`). @@ -1303,6 +1354,17 @@ fn spawn_settings_panel( settings.replay_move_interval_secs, font_res, ); + toggle_row( + body, + "Smart window size", + SmartDefaultSizeText, + smart_default_size_label(!settings.disable_smart_default_size), + SettingsButton::ToggleSmartDefaultSize, + "When ON, fresh launches resize the window to ~70 % of the \ + monitor. OFF pins the 1280\u{00D7}800 baseline. Saved \ + window size always wins.", + font_res, + ); // --- Cosmetic --- section_label(body, "Cosmetic", font_res);