diff --git a/solitaire_engine/src/settings_plugin.rs b/solitaire_engine/src/settings_plugin.rs index b79ce10..245a28f 100644 --- a/solitaire_engine/src/settings_plugin.rs +++ b/solitaire_engine/src/settings_plugin.rs @@ -13,6 +13,7 @@ use std::path::PathBuf; use bevy::input::mouse::{MouseScrollUnit, MouseWheel}; use bevy::prelude::*; +use bevy::ui::{ComputedNode, UiGlobalTransform}; use solitaire_core::game_state::DrawMode; use solitaire_data::{load_settings_from, save_settings_to, settings_file_path, settings::Theme, AnimSpeed, Settings}; @@ -20,12 +21,14 @@ 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_theme::{ - BG_BASE, BG_ELEVATED_HI, BORDER_SUBTLE, RADIUS_SM, STATE_SUCCESS, TEXT_PRIMARY, TEXT_SECONDARY, - TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL, + 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. @@ -123,6 +126,39 @@ enum SettingsButton { 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. @@ -178,6 +214,8 @@ impl Plugin for SettingsPlugin { update_background_text, update_anim_speed_text, update_color_blind_text, + attach_focusable_to_settings_buttons, + scroll_focus_into_view, ), ); } @@ -554,6 +592,148 @@ 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