//! 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, }; use crate::events::{ManualSyncRequestEvent, ToggleSettingsRequestEvent}; use crate::font_plugin::FontResource; use crate::progress_plugin::ProgressResource; use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource}; 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_HI, BORDER_SUBTLE, 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 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; /// Tags interactive buttons inside the Settings panel. #[derive(Component, Debug)] enum SettingsButton { SfxDown, SfxUp, MusicDown, MusicUp, ToggleDrawMode, CycleAnimSpeed, ToggleTheme, ToggleColorBlind, SyncNow, 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), } 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::CycleAnimSpeed => 40, // Cosmetic section SettingsButton::ToggleTheme => 50, SettingsButton::ToggleColorBlind => 60, // 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, // Sync section SettingsButton::SyncNow => 90, // 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::() // `WindowResized` / `WindowMoved` are real Bevy window events // and emitted by the windowing backend under `DefaultPlugins`, // but we register them explicitly here so the geometry watcher // also runs cleanly under `MinimalPlugins` (tests). .add_message::() .add_message::() .add_systems( Update, ( handle_volume_keys, toggle_settings_screen, scroll_settings_panel, record_window_geometry_changes, persist_window_geometry_after_debounce, ), ); if self.ui_enabled { app.add_systems( Update, ( sync_settings_panel_visibility, handle_settings_buttons, update_sync_status_text, update_card_back_text, update_background_text, update_anim_speed_text, update_color_blind_text, attach_focusable_to_settings_buttons, 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, ) { 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())); } /// 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>, ) { 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()); spawn_settings_panel( &mut commands, &settings.0, &status_label, unlocked_backs, unlocked_bgs, scroll_pos.0, font_res.as_deref(), ); } } 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 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 manual_sync: MessageWriter, mut sfx_text: Query<&mut Text, (With, Without, Without, Without, Without, Without)>, mut music_text: Query<&mut Text, (With, Without, Without, Without, Without, Without)>, mut draw_text: Query<&mut Text, (With, Without, Without, Without, Without, Without)>, mut theme_text: Query<&mut Text, (With, Without, Without, Without, Without, Without)>, mut anim_speed_text: Query<&mut Text, (With, Without, Without, Without, Without, Without)>, mut color_blind_text: Query<&mut Text, (With, 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::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::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::SyncNow => { manual_sync.write(ManualSyncRequestEvent); } SettingsButton::Done => { screen.0 = false; } } } } 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() } } /// 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