//! 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::{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, WindowGeometry, REPLAY_MOVE_INTERVAL_STEP_SECS, TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_STEP_SECS, }; use solitaire_data::settings::SyncBackend; use crate::events::{ DeleteAccountRequestEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent, SyncLogoutRequestEvent, ToggleSettingsRequestEvent, }; use crate::font_plugin::FontResource; use crate::progress_plugin::ProgressResource; use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource}; use crate::assets::user_theme_dir; use crate::theme::{import_theme, ImportError, ThemeThumbnailCache, ThemeThumbnailPair, refresh_registry}; use crate::ui_focus::{FocusGroup, FocusRow, Focusable, FocusedButton}; use crate::ui_modal::{ spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant, ModalButton, ModalScrim, }; use crate::ui_tooltip::Tooltip; use crate::ui_theme::{ BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, BORDER_SUBTLE_HC, HighContrastBackground, HighContrastBorder, RADIUS_SM, SPACE_2, STATE_SUCCESS, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL, }; /// 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); /// Marker on the root Settings panel entity. #[derive(Component, Debug)] 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 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; /// 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 the [`Settings::winnable_deals_only`] flag. When on, new /// random Classic-mode deals are filtered through /// [`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, /// Toggle [`Settings::analytics_enabled`]. Only rendered when a /// sync server is configured — there is no server to send to in /// local-only mode. ToggleAnalytics, /// Scan `user_theme_dir()` for new `.zip` files and import each one. ScanThemes, 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, // 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, SettingsButton::ScanThemes => 86, // 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::() .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::() .add_systems( Update, ( handle_volume_keys, toggle_settings_screen, scroll_settings_panel, crate::ui_modal::touch_scroll_panel::, record_window_geometry_changes, persist_window_geometry_after_debounce, ), ); if self.ui_enabled { app.add_systems( Update, ( sync_settings_panel_visibility, handle_settings_buttons, handle_sync_buttons, 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_tooltip_delay_text, update_time_bonus_multiplier_text, update_replay_move_interval_text, update_winnable_deals_only_text, update_smart_default_size_text, 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 // --------------------------------------------------------------------------- fn handle_volume_keys( keys: Res>, mut settings: ResMut, path: Res, mut changed: MessageWriter, mut toast: MessageWriter, ) { let mut delta = 0.0_f32; if keys.just_pressed(KeyCode::BracketLeft) { delta -= SFX_STEP; } if keys.just_pressed(KeyCode::BracketRight) { delta += SFX_STEP; } if delta == 0.0 { return; } let before = settings.0.sfx_volume; let after = settings.0.adjust_sfx_volume(delta); if (before - after).abs() < f32::EPSILON { return; } persist(&path, &settings.0); changed.write(SettingsChangedEvent(settings.0.clone())); toast.write(InfoToastEvent(format!( "SFX volume: {}%", (after * 100.0).round() as i32 ))); } /// Opens or closes the Settings panel — `O` keyboard accelerator or /// `ToggleSettingsRequestEvent` from the HUD Menu popover. fn toggle_settings_screen( keys: Res>, mut requests: MessageReader, mut screen: ResMut, ) { let button_clicked = requests.read().count() > 0; if keys.just_pressed(KeyCode::KeyO) || button_clicked { screen.0 = !screen.0; } } /// Spawns the Settings panel when `SettingsScreen` becomes `true`; /// despawns it when it becomes `false`. #[allow(clippy::too_many_arguments)] fn sync_settings_panel_visibility( screen: Res, panels: Query>, scroll_nodes: Query<&ScrollPosition, With>, mut scroll_pos: ResMut, mut commands: Commands, settings: Res, sync_status: Option>, progress: Option>, font_res: Option>, theme_registry: Option>, theme_thumbs: Option>, card_images: Option>, ) { if !screen.is_changed() { return; } if screen.0 { if panels.is_empty() { let status_label = sync_status .map_or_else(|| "Status: local only".to_string(), |s| sync_status_label(&s.0)); let unlocked_backs = progress .as_ref() .map_or(&[0][..], |p| p.0.unlocked_card_backs.as_slice()); let unlocked_bgs = progress .as_ref() .map_or(&[0][..], |p| p.0.unlocked_backgrounds.as_slice()); // Snapshot themes by id, display_name and (optional) // thumbnail pair so spawn_settings_panel doesn't have to // know about the registry / cache shapes. Empty when // ThemeRegistryPlugin isn't installed (tests under // MinimalPlugins) — the picker row simply won't render. // Missing thumbnails (cache not ready, or partial user // theme) leave `thumbnails: None` so the chip renders its // plain-text fallback instead of a broken sprite. let themes: Vec = theme_registry .as_deref() .map(|r| { r.iter() .map(|e| ThemePickerEntry { id: e.id.clone(), display_name: e.display_name.clone(), thumbnails: theme_thumbs .as_deref() .and_then(|c| c.get(&e.id)) .filter(|p| p.is_fully_populated()) .cloned(), }) .collect() }) .unwrap_or_default(); // The active card-art theme can supply its own back image — // see `card_plugin::CardImageSet::theme_back`. When that is // populated the legacy "Card Back" picker has no visible // effect, so we render it muted with an explanatory caption // rather than letting the player click swatches that do // nothing. Absent under `MinimalPlugins`; treated as // "no override" in that case. let theme_overrides_back = card_images .as_ref() .is_some_and(|cs| cs.theme_back.is_some()); spawn_settings_panel( &mut commands, &settings.0, &status_label, unlocked_backs, unlocked_bgs, &themes, scroll_pos.0, font_res.as_deref(), theme_overrides_back, ); } } else { // Save the current scroll offset before despawning the panel. if let Ok(sp) = scroll_nodes.single() { scroll_pos.0 = sp.0.y; } for entity in &panels { commands.entity(entity).despawn(); } } } /// 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()] } /// Keeps the sync-status text node current while the panel is open. fn update_sync_status_text( sync_status: Option>, mut text_nodes: Query<&mut Text, With>, ) { let Some(status) = sync_status else { return; }; if !status.is_changed() { return; } let label = sync_status_label(&status.0); for mut text in &mut text_nodes { **text = label.clone(); } } fn update_card_back_text( settings: Res, mut text_nodes: Query<&mut Text, With>, ) { if !settings.is_changed() { return; } for mut text in &mut text_nodes { **text = card_back_label(settings.0.selected_card_back); } } fn update_background_text( settings: Res, mut text_nodes: Query<&mut Text, With>, ) { if !settings.is_changed() { return; } for mut text in &mut text_nodes { **text = background_label(settings.0.selected_background); } } fn update_anim_speed_text( settings: Res, mut text_nodes: Query<&mut Text, With>, ) { if !settings.is_changed() { return; } for mut text in &mut text_nodes { **text = anim_speed_label(&settings.0.animation_speed); } } fn update_color_blind_text( settings: Res, mut text_nodes: Query<&mut Text, With>, ) { if !settings.is_changed() { return; } for mut text in &mut text_nodes { **text = color_blind_label(settings.0.color_blind_mode); } } fn update_high_contrast_text( settings: Res, mut text_nodes: Query<&mut Text, With>, ) { if !settings.is_changed() { return; } for mut text in &mut text_nodes { **text = on_off_label(settings.0.high_contrast_mode); } } /// Repaints `BorderColor` on every entity tagged with /// [`HighContrastBorder`] based on `Settings::high_contrast_mode`. /// Off → the marker's `default_color`; on → `BORDER_SUBTLE_HC` /// (`#a0a0a0`). Compares against the current border colour and /// only mutates when different so Bevy's change-detection /// doesn't trigger repaints every frame. /// /// Spec at `design-system.md` §Accessibility (#2): under HC, /// outlines boost from `#505050` (BORDER_STRONG) to `#a0a0a0` so /// modal panels, popover edges, and focus-ring carriers stay /// legible on low-quality displays / for low-vision users. /// /// Tagged sites in v0.21.x: the modal scaffold's card border /// (`ui_modal::spawn_modal`). More sites can be tagged in /// follow-ups by adding `HighContrastBorder::with_default(...)` /// to their spawn tuple. fn update_high_contrast_borders( settings: Res, mut borders: Query<(&HighContrastBorder, &mut BorderColor)>, ) { let high_contrast = settings.0.high_contrast_mode; for (marker, mut border) in borders.iter_mut() { let target = if high_contrast { BORDER_SUBTLE_HC } else { marker.default_color }; // Only mutate when actually different — avoids per-frame // change-detection churn. `border.left` is representative // because every tagged site uses `BorderColor::all(...)`. if border.left != target { *border = BorderColor::all(target); } } } /// Repaints `BackgroundColor` on every entity tagged with /// [`HighContrastBackground`] based on `Settings::high_contrast_mode`. /// Off → the marker's `default_color`; on → `BORDER_SUBTLE_HC` /// (`#a0a0a0`). Compares against the current background and only /// mutates when different so Bevy's change-detection doesn't trigger /// repaints every frame. /// /// Parallel to [`update_high_contrast_borders`]. Same on/off rule, /// same change-suppression idiom, different colour channel — /// `BackgroundColor` for tick marks, decorative strips, fine /// separators that paint their shape directly rather than via a /// `BorderColor` on a wider Node. /// /// Tagged sites in v0.21.x: the replay overlay's 1 px scrub track /// + 5 quarter-mark notch ticks (`replay_overlay::spawn_overlay`). /// /// More sites can be tagged in follow-ups by adding /// `HighContrastBackground::with_default(...)` to their spawn tuple. pub(crate) fn update_high_contrast_backgrounds( settings: Res, mut backgrounds: Query<(&HighContrastBackground, &mut BackgroundColor)>, ) { let high_contrast = settings.0.high_contrast_mode; for (marker, mut bg) in backgrounds.iter_mut() { let target = if high_contrast { marker.hc_color } else { marker.default_color }; if bg.0 != target { *bg = BackgroundColor(target); } } } fn update_reduce_motion_text( settings: Res, mut text_nodes: Query<&mut Text, With>, ) { if !settings.is_changed() { return; } for mut text in &mut text_nodes { **text = on_off_label(settings.0.reduce_motion_mode); } } /// Refreshes the live "Winnable deals only" toggle value in the /// Gameplay section whenever `SettingsResource` changes (button click, /// hand-edited `settings.json` reload, etc.). fn update_winnable_deals_only_text( settings: Res, mut text_nodes: Query<&mut Text, With>, ) { if !settings.is_changed() { return; } for mut text in &mut text_nodes { **text = winnable_deals_only_label(settings.0.winnable_deals_only); } } /// Refreshes the live "Share usage data" toggle value in the Privacy section /// whenever `SettingsResource` changes. fn update_analytics_enabled_text( settings: Res, mut text_nodes: Query<&mut Text, With>, ) { if !settings.is_changed() { return; } for mut text in &mut text_nodes { **text = on_off_label(settings.0.analytics_enabled); } } /// 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.). fn update_tooltip_delay_text( settings: Res, mut text_nodes: Query<&mut Text, With>, ) { if !settings.is_changed() { return; } for mut text in &mut text_nodes { **text = tooltip_delay_label(settings.0.tooltip_delay_secs); } } /// Refreshes the live time-bonus-multiplier value in the Gameplay /// section whenever `SettingsResource` changes. fn update_time_bonus_multiplier_text( settings: Res, mut text_nodes: Query<&mut Text, With>, ) { if !settings.is_changed() { return; } for mut text in &mut text_nodes { **text = time_bonus_label(settings.0.time_bonus_multiplier); } } /// Refreshes the live replay-playback per-move-interval value in the /// Gameplay section whenever `SettingsResource` changes (slider buttons, /// hand-edited settings.json reload, etc.). fn update_replay_move_interval_text( settings: Res, mut text_nodes: Query<&mut Text, With>, ) { if !settings.is_changed() { return; } for mut text in &mut text_nodes { **text = replay_move_interval_label(settings.0.replay_move_interval_secs); } } fn card_back_label(idx: usize) -> String { if idx == 0 { "Default".to_string() } else { format!("Style {idx}") } } fn background_label(idx: usize) -> String { if idx == 0 { "Default".to_string() } else { format!("Style {idx}") } } fn sync_status_label(status: &SyncStatus) -> String { match status { SyncStatus::Idle => "Status: idle".to_string(), SyncStatus::Syncing => "Status: syncing…".to_string(), SyncStatus::LastSynced(t) => { let secs = chrono::Utc::now() .signed_duration_since(*t) .num_seconds() .max(0); if secs < 60 { format!("Last synced: {secs}s ago") } else { format!("Last synced: {}m ago", secs / 60) } } SyncStatus::Error(e) => format!("Sync error: {e}"), } } /// Reacts to button presses inside the Settings panel. #[allow(clippy::too_many_arguments, clippy::type_complexity)] fn handle_settings_buttons( interaction_query: Query<(&Interaction, &SettingsButton), Changed>, mut settings: ResMut, mut screen: ResMut, path: Res, mut changed: MessageWriter, mut sfx_text: Query<&mut Text, (With, Without, Without, Without, Without, Without, Without, Without)>, mut music_text: Query<&mut Text, (With, Without, Without, Without, Without, Without, Without, Without)>, mut draw_text: Query<&mut Text, (With, Without, Without, Without, Without, Without, Without, Without)>, mut theme_text: Query<&mut Text, (With, Without, Without, Without, Without, Without, Without, Without)>, mut anim_speed_text: Query<&mut Text, (With, Without, Without, Without, Without, Without, Without, Without)>, mut color_blind_text: Query<&mut Text, (With, Without, Without, Without, Without, Without, Without, Without)>, mut high_contrast_text: Query<&mut Text, (With, Without, Without, Without, Without, Without, Without, Without)>, mut reduce_motion_text: Query<&mut Text, (With, Without, Without, Without, Without, Without, Without, Without)>, ) { for (interaction, button) in &interaction_query { if *interaction != Interaction::Pressed { continue; } match button { SettingsButton::SfxDown => { let before = settings.0.sfx_volume; let after = settings.0.adjust_sfx_volume(-SFX_STEP); if (before - after).abs() > f32::EPSILON { persist(&path, &settings.0); changed.write(SettingsChangedEvent(settings.0.clone())); if let Ok(mut t) = sfx_text.single_mut() { **t = format!("{after:.2}"); } } } SettingsButton::SfxUp => { let before = settings.0.sfx_volume; let after = settings.0.adjust_sfx_volume(SFX_STEP); if (before - after).abs() > f32::EPSILON { persist(&path, &settings.0); changed.write(SettingsChangedEvent(settings.0.clone())); if let Ok(mut t) = sfx_text.single_mut() { **t = format!("{after:.2}"); } } } SettingsButton::MusicDown => { let before = settings.0.music_volume; let after = settings.0.adjust_music_volume(-SFX_STEP); if (before - after).abs() > f32::EPSILON { persist(&path, &settings.0); changed.write(SettingsChangedEvent(settings.0.clone())); if let Ok(mut t) = music_text.single_mut() { **t = format!("{after:.2}"); } } } SettingsButton::MusicUp => { let before = settings.0.music_volume; let after = settings.0.adjust_music_volume(SFX_STEP); if (before - after).abs() > f32::EPSILON { persist(&path, &settings.0); changed.write(SettingsChangedEvent(settings.0.clone())); if let Ok(mut t) = music_text.single_mut() { **t = format!("{after:.2}"); } } } SettingsButton::ToggleDrawMode => { settings.0.draw_mode = match settings.0.draw_mode { DrawMode::DrawOne => DrawMode::DrawThree, DrawMode::DrawThree => DrawMode::DrawOne, }; persist(&path, &settings.0); changed.write(SettingsChangedEvent(settings.0.clone())); if let Ok(mut t) = draw_text.single_mut() { **t = draw_mode_label(&settings.0.draw_mode); } } SettingsButton::CycleAnimSpeed => { settings.0.animation_speed = match settings.0.animation_speed { AnimSpeed::Normal => AnimSpeed::Fast, AnimSpeed::Fast => AnimSpeed::Instant, AnimSpeed::Instant => AnimSpeed::Normal, }; persist(&path, &settings.0); changed.write(SettingsChangedEvent(settings.0.clone())); if let Ok(mut t) = anim_speed_text.single_mut() { **t = anim_speed_label(&settings.0.animation_speed); } } SettingsButton::TooltipDelayDown => { let before = settings.0.tooltip_delay_secs; let after = settings.0.adjust_tooltip_delay(-TOOLTIP_DELAY_STEP_SECS); if (before - after).abs() > f32::EPSILON { persist(&path, &settings.0); changed.write(SettingsChangedEvent(settings.0.clone())); // The Text node is refreshed by `update_tooltip_delay_text` // on the next frame via `settings.is_changed()`. } } SettingsButton::TooltipDelayUp => { let before = settings.0.tooltip_delay_secs; let after = settings.0.adjust_tooltip_delay(TOOLTIP_DELAY_STEP_SECS); if (before - after).abs() > f32::EPSILON { persist(&path, &settings.0); changed.write(SettingsChangedEvent(settings.0.clone())); } } SettingsButton::TimeBonusDown => { let before = settings.0.time_bonus_multiplier; let after = settings.0.adjust_time_bonus_multiplier(-TIME_BONUS_MULTIPLIER_STEP); if (before - after).abs() > f32::EPSILON { persist(&path, &settings.0); changed.write(SettingsChangedEvent(settings.0.clone())); // The Text node is refreshed by // `update_time_bonus_multiplier_text` on the next // frame via `settings.is_changed()`. } } SettingsButton::TimeBonusUp => { let before = settings.0.time_bonus_multiplier; let after = settings.0.adjust_time_bonus_multiplier(TIME_BONUS_MULTIPLIER_STEP); if (before - after).abs() > f32::EPSILON { persist(&path, &settings.0); changed.write(SettingsChangedEvent(settings.0.clone())); } } SettingsButton::ReplayMoveIntervalDown => { let before = settings.0.replay_move_interval_secs; let after = settings .0 .adjust_replay_move_interval(-REPLAY_MOVE_INTERVAL_STEP_SECS); if (before - after).abs() > f32::EPSILON { persist(&path, &settings.0); changed.write(SettingsChangedEvent(settings.0.clone())); // The Text node is refreshed by // `update_replay_move_interval_text` on the next // frame via `settings.is_changed()`. } } SettingsButton::ReplayMoveIntervalUp => { let before = settings.0.replay_move_interval_secs; let after = settings .0 .adjust_replay_move_interval(REPLAY_MOVE_INTERVAL_STEP_SECS); if (before - after).abs() > f32::EPSILON { persist(&path, &settings.0); changed.write(SettingsChangedEvent(settings.0.clone())); } } SettingsButton::ToggleTheme => { settings.0.theme = match settings.0.theme { Theme::Green => Theme::Blue, Theme::Blue => Theme::Dark, Theme::Dark => Theme::Green, }; persist(&path, &settings.0); changed.write(SettingsChangedEvent(settings.0.clone())); if let Ok(mut t) = theme_text.single_mut() { **t = theme_label(&settings.0.theme); } } SettingsButton::ToggleColorBlind => { settings.0.color_blind_mode = !settings.0.color_blind_mode; persist(&path, &settings.0); changed.write(SettingsChangedEvent(settings.0.clone())); if let Ok(mut t) = color_blind_text.single_mut() { **t = color_blind_label(settings.0.color_blind_mode); } } SettingsButton::ToggleHighContrast => { settings.0.high_contrast_mode = !settings.0.high_contrast_mode; persist(&path, &settings.0); changed.write(SettingsChangedEvent(settings.0.clone())); if let Ok(mut t) = high_contrast_text.single_mut() { **t = on_off_label(settings.0.high_contrast_mode); } } SettingsButton::ToggleReduceMotion => { settings.0.reduce_motion_mode = !settings.0.reduce_motion_mode; persist(&path, &settings.0); changed.write(SettingsChangedEvent(settings.0.clone())); if let Ok(mut t) = reduce_motion_text.single_mut() { **t = on_off_label(settings.0.reduce_motion_mode); } } SettingsButton::ToggleWinnableDealsOnly => { settings.0.winnable_deals_only = !settings.0.winnable_deals_only; persist(&path, &settings.0); changed.write(SettingsChangedEvent(settings.0.clone())); // The Text node is refreshed by `update_winnable_deals_only_text` // on the next frame via `settings.is_changed()`. } SettingsButton::ToggleAnalytics => { settings.0.analytics_enabled = !settings.0.analytics_enabled; persist(&path, &settings.0); changed.write(SettingsChangedEvent(settings.0.clone())); // Text refreshed by `update_analytics_enabled_text` next frame. } 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); changed.write(SettingsChangedEvent(settings.0.clone())); } SettingsButton::SelectBackground(idx) => { settings.0.selected_background = *idx; persist(&path, &settings.0); changed.write(SettingsChangedEvent(settings.0.clone())); } SettingsButton::SelectTheme(theme_id) => { if settings.0.selected_theme_id != *theme_id { settings.0.selected_theme_id = theme_id.clone(); persist(&path, &settings.0); changed.write(SettingsChangedEvent(settings.0.clone())); } } SettingsButton::ScanThemes => { // Handled by `handle_scan_themes`. } SettingsButton::SyncNow | SettingsButton::ConnectSync | SettingsButton::DisconnectSync | SettingsButton::DeleteAccount => { // Handled by `handle_sync_buttons`. } SettingsButton::Done => { screen.0 = false; } } } } /// Handles sync-related settings buttons: Sync Now, Connect, Disconnect, /// and Delete Account. Split from `handle_settings_buttons` to stay within /// Bevy's 16-parameter system limit. fn handle_sync_buttons( interaction_query: Query<(&Interaction, &SettingsButton), Changed>, mut manual_sync: MessageWriter, mut configure_sync: MessageWriter, mut logout_sync: MessageWriter, mut delete_account: MessageWriter, ) { for (interaction, button) in &interaction_query { if *interaction != Interaction::Pressed { continue; } match button { SettingsButton::SyncNow => { manual_sync.write(ManualSyncRequestEvent); } SettingsButton::ConnectSync => { configure_sync.write(SyncConfigureRequestEvent); } SettingsButton::DisconnectSync => { logout_sync.write(SyncLogoutRequestEvent); } SettingsButton::DeleteAccount => { delete_account.write(DeleteAccountRequestEvent); } _ => {} } } } fn draw_mode_label(mode: &DrawMode) -> String { match mode { DrawMode::DrawOne => "Draw 1".into(), DrawMode::DrawThree => "Draw 3".into(), } } fn anim_speed_label(speed: &AnimSpeed) -> String { match speed { AnimSpeed::Normal => "Normal".into(), AnimSpeed::Fast => "Fast".into(), AnimSpeed::Instant => "Instant".into(), } } fn theme_label(theme: &Theme) -> String { match theme { Theme::Green => "Green".into(), Theme::Blue => "Blue".into(), Theme::Dark => "Dark".into(), } } fn color_blind_label(enabled: bool) -> String { if enabled { "ON".into() } else { "OFF".into() } } /// Generic ON/OFF label shared by the high-contrast and reduce- /// motion accessibility toggles. Same format as /// [`color_blind_label`] / [`winnable_deals_only_label`] — /// keeping all simple boolean toggle rows visually uniform. fn on_off_label(enabled: bool) -> String { if enabled { "ON".into() } else { "OFF".into() } } /// Display string for the "Winnable deals only" toggle. Mirrors /// [`color_blind_label`] — "ON" / "OFF" — so the layout is uniform /// with the rest of the Gameplay-section toggles. 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"`). fn tooltip_delay_label(secs: f32) -> String { if secs <= 0.0 { "Instant".into() } else { format!("{secs:.1} s") } } /// Formats the cosmetic time-bonus multiplier for display in the /// Settings panel. `0.0` reads as `"Off"` so the player understands the /// time-bonus row will be hidden; any other value prints as /// `"{n:.1}×"` (e.g. `"1.0×"`, `"1.5×"`). fn time_bonus_label(value: f32) -> String { if value <= 0.0 { "Off".into() } else { format!("{value:.1}×") } } /// Formats the replay-playback per-move interval for display in the /// Settings panel. Mirrors [`tooltip_delay_label`] for parity — the /// readout is `"{n:.2} s/move"` (e.g. `"0.45 s/move"`, `"0.10 s/move"`), /// using two decimal places because the step is 0.05 s. fn replay_move_interval_label(secs: f32) -> String { format!("{secs:.2} s/move") } /// Auto-attaches [`Focusable`] to every bespoke Settings button — icon /// buttons (volume +/−, toggle, cycle), swatch buttons (card-back, /// background pickers), and the "Sync Now" button. The "Done" button is /// already tagged by `attach_focusable_to_modal_buttons` (it carries /// [`ModalButton`]) and is filtered out here. /// /// Walks ancestors via [`ChildOf`] to find the [`ModalScrim`] that owns /// the panel so the new [`Focusable`]'s group is bound to that scrim — /// same defensive shape as the Phase 1 / 2 attach systems. #[allow(clippy::type_complexity)] fn attach_focusable_to_settings_buttons( mut commands: Commands, new_buttons: Query< (Entity, &SettingsButton), (With