//! Persists `solitaire_data::Settings`, exposes hotkeys for live tuning, //! and renders a Bevy UI Settings panel. //! //! Hotkeys (always active, no overlay required): //! - `[` — decrease SFX volume by `SFX_STEP` //! - `]` — increase SFX volume by `SFX_STEP` //! - `O` — open / close the Settings panel //! //! On change, the plugin persists `settings.json` and fires //! `SettingsChangedEvent` so dependents (e.g. `AudioPlugin`) can react. use std::path::PathBuf; use bevy::input::mouse::MouseWheel; use bevy::prelude::*; use bevy::window::{WindowMoved, WindowResized}; use solitaire_data::{ Settings, WindowGeometry, load_settings_from, save_settings_to, settings_file_path, }; use crate::events::{ DeleteAccountRequestEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent, SyncLogoutRequestEvent, ToggleSettingsRequestEvent, }; use crate::resources::SettingsScrollPos; use crate::theme::ThemeThumbnailPair; mod input; mod ui; mod updates; use input::*; use ui::*; use updates::*; /// Side length of a swatch button in the card-back / background pickers. /// Smaller than the smallest spacing rung so it stays a literal. const SWATCH_PX: f32 = 40.0; /// Side length of a small toggle / cycle button (e.g. the "⇄" affordances). /// Sub-rung sizing — kept as a literal, see SWATCH_PX. 32 px meets the /// minimum desktop hit-target threshold while staying smaller than `SWATCH_PX`. const ICON_BUTTON_PX: f32 = 32.0; /// Volume adjustment step applied by the `[` / `]` hotkeys. pub const SFX_STEP: f32 = 0.1; /// Bevy resource wrapping the current `Settings`. #[derive(Resource, Debug, Clone)] pub struct SettingsResource(pub Settings); /// Persistence path for `SettingsResource`. `None` disables I/O (used in tests). #[derive(Resource, Debug, Clone)] pub struct SettingsStoragePath(pub Option); /// Whether the Settings panel is currently visible. Toggle with `O`. #[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); /// System set for the systems that mutate [`SettingsResource`] every frame /// (hotkeys and window-geometry persistence). Ordered before /// [`crate::game_plugin::GameMutation`]; readers of settings should sit /// after this set (directly, or transitively via `.after(GameMutation)`) /// so they observe the current frame's settings deterministically (#143). #[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] pub struct SettingsMutation; /// Marker on the root Settings panel entity. #[derive(Component, Debug)] pub struct SettingsPanel; /// Marks the `Text` node showing the live SFX volume value. #[derive(Component, Debug)] struct SfxVolumeText; /// Marks the `Text` node showing the live music volume value. #[derive(Component, Debug)] struct MusicVolumeText; /// Marks the `Text` node showing the current draw mode. #[derive(Component, Debug)] struct DrawModeText; /// Marks the `Text` node showing the current theme. #[derive(Component, Debug)] struct ThemeText; /// Marks the `Text` node showing the live sync status. #[derive(Component, Debug)] struct SyncStatusText; /// Marks the `Text` node showing the active card-back index. #[derive(Component, Debug)] struct CardBackText; /// Marks the `Text` node showing the current animation speed. #[derive(Component, Debug)] struct AnimSpeedText; /// Marks the `Text` node showing the active background index. #[derive(Component, Debug)] struct BackgroundText; /// Marks the `Text` node showing the current color-blind mode state. #[derive(Component, Debug)] struct ColorBlindText; /// Marks the `Text` node showing the current high-contrast mode state. #[derive(Component, Debug)] struct HighContrastText; /// Marks the `Text` node showing the current reduce-motion mode state. #[derive(Component, Debug)] struct ReduceMotionText; /// Marks the `Text` node showing the current touch input mode state. #[derive(Component, Debug)] struct TouchInputModeText; /// Marks the `Text` node showing the live tooltip-delay value. #[derive(Component, Debug)] struct TooltipDelayText; /// Marks the `Text` node showing the live time-bonus-multiplier value. #[derive(Component, Debug)] struct TimeBonusMultiplierText; /// Marks the `Text` node showing the live replay-playback per-move /// interval value. The Gameplay-section row beside this label lets the /// player tune `Settings::replay_move_interval_secs`. #[derive(Component, Debug)] struct ReplayMoveIntervalText; /// Marks the `Text` node showing the current "Winnable deals only" /// state ("ON" / "OFF") in the Gameplay section. #[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 `Text` node showing the current "Share usage data" (analytics) /// state ("ON" / "OFF") in the Privacy section. #[derive(Component, Debug)] struct AnalyticsEnabledText; /// Which tab of the Settings panel is showing. Tabs replace the old /// single-scroll five-section layout (Phase A of /// `docs/ui-redesign-2026-07.md`) so each group fits on screen without /// scroll-hunting. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub(super) enum SettingsTab { #[default] Audio, Gameplay, Appearance, Accessibility, Account, } impl SettingsTab { /// Every tab, in display order. pub(super) const ALL: [Self; 5] = [ Self::Audio, Self::Gameplay, Self::Appearance, Self::Accessibility, Self::Account, ]; /// Chip label. pub(super) fn label(self) -> &'static str { match self { Self::Audio => "Audio", Self::Gameplay => "Gameplay", Self::Appearance => "Appearance", Self::Accessibility => "Access", Self::Account => "Account", } } } /// The active Settings tab. Session-only — deliberately not persisted; /// reopening Settings within a session returns to the last tab. #[derive(Resource, Debug, Default)] pub(super) struct ActiveSettingsTab(pub(super) SettingsTab); /// Marker on each tab chip button in the Settings header row. #[derive(Component, Debug)] pub(super) struct SettingsTabButton(pub(super) SettingsTab); /// Marks the scrollable inner card so the mouse-wheel system can target it. #[derive(Component, Debug)] struct SettingsPanelScrollable; /// Marks the scrollable inner card so its `ScrollPosition` can be read before despawn. #[derive(Component, Debug)] struct SettingsScrollNode; /// Snapshot row used by [`spawn_settings_panel`] to render the card-art /// theme picker. Carries the `ThemeRegistry` entry's display fields plus /// the (optional) thumbnail pair from [`ThemeThumbnailCache`]. A `None` /// thumbnail means the picker should render a placeholder swatch — used /// when the cache hasn't generated handles yet, or when a user theme /// is missing one of the required preview SVGs. #[derive(Debug, Clone)] struct ThemePickerEntry { /// Stable theme id (matches `ThemeMeta::id`). id: String, /// Player-facing label. display_name: String, /// Pre-generated picker preview pair, when ready. `None` collapses /// the chip to its plain-text fallback. thumbnails: Option, } /// Tags interactive buttons inside the Settings panel. #[derive(Component, Debug)] enum SettingsButton { SfxDown, SfxUp, MusicDown, MusicUp, ToggleDrawMode, CycleAnimSpeed, /// Decrement the tooltip-hover dwell delay by one step. TooltipDelayDown, /// Increment the tooltip-hover dwell delay by one step. TooltipDelayUp, /// Decrement the cosmetic time-bonus multiplier by one step. TimeBonusDown, /// Increment the cosmetic time-bonus multiplier by one step. TimeBonusUp, /// Decrement the replay-playback per-move interval by one step /// (i.e. speed playback up). ReplayMoveIntervalDown, /// Increment the replay-playback per-move interval by one step /// (i.e. slow playback down). ReplayMoveIntervalUp, ToggleTheme, ToggleColorBlind, /// Toggle the [`Settings::high_contrast_mode`] flag — boosts /// foreground / suit-red glyphs to higher-luminance variants per /// `design-system.md` §Accessibility (#2). ToggleHighContrast, /// Toggle the [`Settings::reduce_motion_mode`] flag — suppresses /// non-essential motion (card-slide animations become instant /// snaps) per `design-system.md` §Accessibility (#3). ToggleReduceMotion, /// Toggle [`Settings::touch_input_mode`] between `OneTap` /// (auto-move on tap, default) and `TapToSelect` (first tap selects /// a card/stack, second tap on a target pile moves it). ToggleTouchInputMode, /// Toggle the [`Settings::winnable_deals_only`] flag. When on, new /// random Classic-mode deals are filtered through /// [`solitaire_core::game_state::GameState::solve_fresh_deal`] 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, /// Toggle [`Settings::analytics_enabled`]. Only rendered when a /// Matomo URL is configured. ToggleAnalytics, /// Scan `user_theme_dir()` for new `.zip` files and import each one. #[cfg(not(target_arch = "wasm32"))] ScanThemes, /// Open the in-game theme-store modal (closes Settings first). #[cfg(not(target_arch = "wasm32"))] OpenThemeStore, SyncNow, /// Open the sync-server Connect modal (shown when backend = Local). ConnectSync, /// Disconnect from the sync server (shown when backend = SolitaireServer). DisconnectSync, /// Open the account-deletion confirmation modal. DeleteAccount, Done, /// Select a specific card-back by index from the picker row. SelectCardBack(usize), /// Select a specific background by index from the picker row. SelectBackground(usize), /// Select a specific card-art theme by `meta.id` from the /// `ThemeRegistry`. The string is owned so the click handler can /// hand it directly to `Settings::selected_theme_id`. SelectTheme(String), } impl SettingsButton { /// Tab-walk priority — lower numbers visited first. Visual reading /// order is top-to-bottom by section, left-to-right inside each row. /// Two buttons in the same picker row receive the same `order`; /// `handle_focus_keys` then breaks ties by entity index, which /// matches `Children` spawn order inside each row. fn focus_order(&self) -> i32 { match self { // Audio section SettingsButton::SfxDown => 10, SettingsButton::SfxUp => 11, SettingsButton::MusicDown => 20, SettingsButton::MusicUp => 21, // Gameplay section SettingsButton::ToggleDrawMode => 30, SettingsButton::ToggleWinnableDealsOnly => 35, SettingsButton::CycleAnimSpeed => 40, SettingsButton::TooltipDelayDown => 45, SettingsButton::TooltipDelayUp => 46, SettingsButton::TimeBonusDown => 47, SettingsButton::TimeBonusUp => 48, // Replay-speed slider — last Gameplay-section row, so it // 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, // Privacy section — just before Sync. SettingsButton::ToggleAnalytics => 89, // Cosmetic section SettingsButton::ToggleTheme => 55, SettingsButton::ToggleColorBlind => 60, // Accessibility-section toggles sit alongside Color-blind so // tab-walk visits all three a11y flags in the same vertical // run before continuing to the picker rows. SettingsButton::ToggleHighContrast => 61, SettingsButton::ToggleReduceMotion => 62, SettingsButton::ToggleTouchInputMode => 63, // Picker rows — every swatch in a row shares the row's // priority so entity-index tiebreaking yields left → right. SettingsButton::SelectCardBack(_) => 70, SettingsButton::SelectBackground(_) => 80, SettingsButton::SelectTheme(_) => 85, #[cfg(not(target_arch = "wasm32"))] SettingsButton::ScanThemes => 86, #[cfg(not(target_arch = "wasm32"))] SettingsButton::OpenThemeStore => 87, // Sync section SettingsButton::SyncNow => 90, SettingsButton::ConnectSync => 91, SettingsButton::DisconnectSync => 92, SettingsButton::DeleteAccount => 93, // Done is tagged by `attach_focusable_to_modal_buttons` and // never reaches `attach_focusable_to_settings_buttons`; the // value here is only a fallback for completeness. SettingsButton::Done => 100, } } } /// Plugin that owns the settings lifecycle. pub struct SettingsPlugin { /// Path to `settings.json`. `None` in headless/test mode. pub storage_path: Option, /// When `false`, panel spawn/despawn systems are not registered. /// Use [`SettingsPlugin::headless`] for tests running under `MinimalPlugins`. pub ui_enabled: bool, } impl Default for SettingsPlugin { fn default() -> Self { Self { storage_path: settings_file_path(), ui_enabled: true, } } } impl SettingsPlugin { /// No persistence, no UI — safe to use under `MinimalPlugins` in tests. pub fn headless() -> Self { Self { storage_path: None, ui_enabled: false, } } } impl Plugin for SettingsPlugin { fn build(&self, app: &mut App) { let loaded = match &self.storage_path { Some(path) => load_settings_from(path), None => Settings::default(), }; app.insert_resource(SettingsResource(loaded)) .insert_resource(SettingsStoragePath(self.storage_path.clone())) .init_resource::() .init_resource::() .init_resource::() .init_resource::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() // `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::() // Settings changes land before game logic runs: the mutator // chain (volume keys → geometry record → geometry persist) is a // deterministic spine, and the whole set precedes GameMutation so // every reader already ordered after GameMutation sees this // frame's settings transitively (ambiguity burn-down, #143). .configure_sets( Update, SettingsMutation .after(crate::layout::LayoutSystem::UpdateOnResize) .before(crate::game_plugin::GameMutation), ) .add_systems( Update, ( handle_volume_keys, record_window_geometry_changes, persist_window_geometry_after_debounce, ) .chain() .in_set(SettingsMutation), ) .add_systems( Update, ( toggle_settings_screen .before(crate::ui_focus::FocusKeys) .ambiguous_with(crate::hud_plugin::HudButtons), scroll_settings_panel, crate::ui_modal::touch_scroll_panel::, ) .chain(), ); if self.ui_enabled { app.add_systems( Update, ( // Chained: a tab click must observe this frame's // visibility state, and the rebuild must observe the // click — total order avoids panel double-spawns. ( sync_settings_panel_visibility, handle_tab_buttons, rebuild_panel_on_tab_change, ) .chain(), handle_settings_buttons, handle_sync_buttons, #[cfg(not(target_arch = "wasm32"))] handle_scan_themes, update_sync_status_text, update_card_back_text, update_background_text, update_anim_speed_text, update_color_blind_text, update_high_contrast_text, update_high_contrast_borders.run_if(resource_changed::), update_high_contrast_backgrounds.run_if(resource_changed::), update_reduce_motion_text, update_touch_input_mode_text, update_tooltip_delay_text, update_time_bonus_multiplier_text, update_replay_move_interval_text, update_winnable_deals_only_text, update_smart_default_size_text, ), ); app.add_systems( Update, ( update_analytics_enabled_text, attach_focusable_to_settings_buttons, ), ); app.add_systems(Update, scroll_focus_into_view); } } } // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- fn persist(path: &SettingsStoragePath, settings: &Settings) { let Some(target) = &path.0 else { return }; if let Err(e) = save_settings_to(target, settings) { warn!("failed to save settings: {e}"); } } /// 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 // --------------------------------------------------------------------------- /// Returns the next unlocked index after `current` in the sorted `unlocked` list. /// Wraps around. Falls back to `unlocked[0]` if `current` is not found. #[cfg(test)] fn cycle_unlocked(unlocked: &[usize], current: usize) -> usize { if unlocked.is_empty() { return 0; } let pos = unlocked.iter().position(|&i| i == current).unwrap_or(0); unlocked[(pos + 1) % unlocked.len()] } /// 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