//! Input handling for the Settings panel: button presses, keyboard //! accelerators, focus attachment, and scrolling. use super::*; use bevy::input::mouse::{MouseScrollUnit, MouseWheel}; use bevy::ui::{ComputedNode, UiGlobalTransform}; use solitaire_core::DrawStockConfig; use solitaire_data::{ AnimSpeed, REPLAY_MOVE_INTERVAL_STEP_SECS, TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_STEP_SECS, settings::Theme, }; use crate::events::{ DeleteAccountRequestEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent, SyncLogoutRequestEvent, ToggleSettingsRequestEvent, }; use crate::ui_focus::{FocusGroup, Focusable, FocusedButton}; use crate::ui_modal::{ModalButton, ModalScrim}; use crate::ui_theme::SPACE_2; pub(super) 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. Esc closes /// too (Phase C dismissal audit), but only when Settings is the /// topmost modal — with sync-setup or the theme store stacked on top, /// the stacked dialog owns Esc. pub(super) fn toggle_settings_screen( keys: Res>, mut requests: MessageReader, mut screen: ResMut, other_modal_scrims: Query<(), (With, Without)>, ) { let button_clicked = requests.read().count() > 0; if keys.just_pressed(KeyCode::KeyO) || button_clicked { screen.0 = !screen.0; } else if keys.just_pressed(KeyCode::Escape) && screen.0 && other_modal_scrims.is_empty() { screen.0 = false; } } /// Reacts to button presses inside the Settings panel. #[allow(clippy::too_many_arguments, clippy::type_complexity)] pub(super) 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 { DrawStockConfig::DrawOne => DrawStockConfig::DrawThree, DrawStockConfig::DrawThree => DrawStockConfig::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::ToggleReduceTransparency => { settings.0.reduce_transparency_mode = !settings.0.reduce_transparency_mode; persist(&path, &settings.0); changed.write(SettingsChangedEvent(settings.0.clone())); // Text refreshed by `update_reduce_transparency_text`; the // glass chrome itself follows via `update_glass_surfaces` // next frame. } SettingsButton::ToggleTouchInputMode => { use solitaire_data::settings::TouchInputMode; settings.0.touch_input_mode = match settings.0.touch_input_mode { TouchInputMode::OneTap => TouchInputMode::TapToSelect, TouchInputMode::TapToSelect => TouchInputMode::OneTap, }; persist(&path, &settings.0); changed.write(SettingsChangedEvent(settings.0.clone())); // Text refreshed by `update_touch_input_mode_text` next frame. } SettingsButton::CycleUiScale => { settings.0.ui_scale = next_ui_scale(settings.0.ui_scale); persist(&path, &settings.0); changed.write(SettingsChangedEvent(settings.0.clone())); // Text refreshed by `update_ui_scale_text`; the live // `bevy::ui::UiScale` resource follows via // `sync_ui_scale_resource` next frame. } 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())); } } #[cfg(not(target_arch = "wasm32"))] SettingsButton::ScanThemes => { // Handled by `handle_scan_themes`. } SettingsButton::SyncNow | SettingsButton::ConnectSync | SettingsButton::DisconnectSync | SettingsButton::DeleteAccount => { // Handled by `handle_sync_buttons`. } #[cfg(not(target_arch = "wasm32"))] SettingsButton::OpenThemeStore => { // Handled by `handle_sync_buttons`. } SettingsButton::ExportData | SettingsButton::ImportData => { // Handled by `handle_sync_buttons`. } SettingsButton::Done => { screen.0 = false; } } } } /// Switches the active Settings tab when a chip is pressed. The /// rebuild itself happens in `rebuild_panel_on_tab_change`, driven by /// change detection on [`ActiveSettingsTab`]. pub(super) fn handle_tab_buttons( interactions: Query<(&Interaction, &SettingsTabButton), Changed>, mut active: ResMut, ) { for (interaction, chip) in &interactions { if *interaction != Interaction::Pressed { continue; } // Compare before writing so re-clicking the active chip doesn't // trip change detection and rebuild the panel for nothing. if active.0 != chip.0 { active.0 = chip.0; } } } /// Handles sync-related settings buttons: Sync Now, Connect, Disconnect, /// Delete Account, and the Phase M Export/Import data pair. Split from /// `handle_settings_buttons` to stay within Bevy's 16-parameter system /// limit. #[allow(clippy::too_many_arguments)] pub(super) 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, mut export_data: MessageWriter, mut import_data: MessageWriter, #[cfg(not(target_arch = "wasm32"))] mut open_theme_store: MessageWriter< crate::events::ThemeStoreOpenRequestEvent, >, mut screen: ResMut, ) { for (interaction, button) in &interaction_query { if *interaction != Interaction::Pressed { continue; } match button { SettingsButton::SyncNow => { manual_sync.write(ManualSyncRequestEvent); } SettingsButton::ConnectSync => { // Close settings before the sync-setup modal opens so the // guard in open_sync_setup_modal doesn't block on our own scrim. screen.0 = false; configure_sync.write(SyncConfigureRequestEvent); } #[cfg(not(target_arch = "wasm32"))] SettingsButton::OpenThemeStore => { // Same shape as ConnectSync: close settings first so the // store modal's own-scrim guard doesn't block. screen.0 = false; open_theme_store.write(crate::events::ThemeStoreOpenRequestEvent); } SettingsButton::DisconnectSync => { logout_sync.write(SyncLogoutRequestEvent); } SettingsButton::DeleteAccount => { delete_account.write(DeleteAccountRequestEvent); } SettingsButton::ExportData => { export_data.write(crate::events::DataExportRequestEvent); } SettingsButton::ImportData => { import_data.write(crate::events::DataImportRequestEvent); } _ => {} } } } /// 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)] pub(super) fn attach_focusable_to_settings_buttons( mut commands: Commands, new_buttons: Query< (Entity, &SettingsButton), (With