diff --git a/solitaire_engine/src/settings_plugin/input.rs b/solitaire_engine/src/settings_plugin/input.rs new file mode 100644 index 0000000..c0c67e4 --- /dev/null +++ b/solitaire_engine/src/settings_plugin/input.rs @@ -0,0 +1,627 @@ +//! 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. +pub(super) 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; + } +} + +/// 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::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::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`. + } + 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. +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 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); + } + SettingsButton::DisconnectSync => { + logout_sync.write(SyncLogoutRequestEvent); + } + SettingsButton::DeleteAccount => { + delete_account.write(DeleteAccountRequestEvent); + } + _ => {} + } + } +} + +/// 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