//! Keyboard focus ring for modal buttons (Phase 1). //! //! Ferrous Solitaire's 11 modals (Help, Stats, Achievements, Settings, //! Profile, Leaderboard, Pause, Forfeit confirm, GameOver, Confirm new //! game, Onboarding) ship without any keyboard focus support. Phase 1 //! gives every button spawned via [`crate::ui_modal::spawn_modal_button`] //! a real, visible focus state: //! //! - **Tab / Shift+Tab** cycles forward / backward through buttons //! - **Enter / Space** activates the focused button (writes //! `Interaction::Pressed` so existing click handlers fire) //! - **Mouse click** on a focusable transfers keyboard focus to it //! - **Modal open** auto-focuses the Primary button (or the first //! focusable in spawn order if no Primary exists) //! //! ## Architecture: sibling overlay entity //! //! Rather than attach a `BorderColor` / `Outline` to the focused button — //! which would inherit the modal card's open-animation scale and clip to //! any scroll container — Phase 1 uses a single overlay entity that is //! never a descendant of any modal. Each frame, [`update_focus_overlay`] //! reads the focused button's [`bevy::ui::UiGlobalTransform`] and //! [`bevy::ui::ComputedNode`] and positions the overlay's absolute //! `Node` to wrap the button with a 4 px halo. //! //! This sidesteps: //! - Modal card scale-in (the overlay is a sibling, not a child) //! - `Overflow::scroll_y()` clipping (no ancestor enforces a clip rect) //! //! ## Phase scope //! //! Phase 1 is modal buttons only. Phase 2 extended the same component //! to the HUD action bar (on hover) and Home mode cards. Phase 3 closes //! out the engine: Settings bespoke buttons opt-in via the same //! ancestry-walk pattern, picker rows inside Settings get [`FocusRow`] //! so Left/Right cycle within the row, and the focused button is //! auto-scrolled into the visible Settings viewport (see the //! `scroll_focus_into_view` system in `settings_plugin`). //! //! When no modal is open and no HUD button is hovered, every system //! here no-ops so [`crate::selection_plugin`]'s Tab/Enter //! card-selection still works. use std::f32::consts::TAU; use bevy::ecs::query::Has; use bevy::input::ButtonInput; use bevy::prelude::*; use bevy::ui::{ComputedNode, UiGlobalTransform}; use solitaire_data::AnimSpeed; use crate::settings_plugin::SettingsResource; use crate::ui_modal::{ButtonVariant, ModalButton, ModalScrim}; use crate::ui_theme::{FOCUS_RING, MOTION_FOCUS_PULSE_SECS, RADIUS_MD, Z_FOCUS_RING}; // --------------------------------------------------------------------------- // Public component / resource API // --------------------------------------------------------------------------- /// Marker on every interactive entity that participates in keyboard /// focus. Phase 1 inserts this on every [`ModalButton`]; future phases /// will extend the same component to HUD buttons and Home mode cards. #[derive(Component, Debug, Clone, Copy)] pub struct Focusable { /// Group this focusable belongs to. Tab cycles inside a single /// group at a time — buttons in different modals don't interleave. pub group: FocusGroup, /// Lower numbers visited first within a group. Phase 1 keeps every /// modal button at `0` and uses spawn-order (entity index) as the /// tiebreaker, which matches `spawn_modal_actions`'s document order. pub order: i32, } /// Logical grouping for keyboard focus. Tab cycles only within the /// active group. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum FocusGroup { /// Bound to a specific scrim entity — modal-scoped. Two stacked /// modals (e.g. Pause + Forfeit confirm) maintain independent /// focus rings; Tab cycles inside the topmost one. Modal(Entity), /// Top-right action bar. Phase 2 will populate this — Phase 1 /// declares the variant so the surface is stable. Hud, } /// Marker that suppresses Tab navigation and Enter / Space activation /// for an otherwise-focusable entity. Public so callers can opt buttons /// in or out at runtime without removing [`Focusable`] (which would /// also break the spawn-order ordering). #[derive(Component, Debug, Clone, Copy)] pub struct Disabled; /// Marker on a parent container whose direct [`Focusable`] children /// form a horizontal row navigable by Left / Right arrow keys. /// /// Tab / Shift+Tab still escape the row to the next focusable outside /// it (the row's children participate in their group's normal cycle /// just like any other focusable). Arrow keys are scoped to the row: /// pressing Left/Right wraps within the row's children only, skipping /// any child marked [`Disabled`]. /// /// Used by Settings picker rows (card-back swatches, background /// swatches) to give players a familiar "select-from-options" feel /// without leaving the keyboard. #[derive(Component, Debug, Clone, Copy)] pub struct FocusRow; /// Globally-focused button entity, or `None` if nothing is focused. /// Read-only in steady state; written by the focus systems on Tab, /// mouse click, and modal open / close. #[derive(Resource, Debug, Default)] pub struct FocusedButton(pub Option); /// Registers the keyboard-focus ring system. Add this plugin once, /// after [`crate::ui_modal::UiModalPlugin`], so every modal button /// gains keyboard navigation without per-plugin wiring. pub struct UiFocusPlugin; impl Plugin for UiFocusPlugin { fn build(&self, app: &mut App) { app.init_resource::() .add_systems(Startup, spawn_focus_overlay) // Attach + auto-focus run in `PostUpdate` so they see entities // a click-handler in `Update` queued via `Commands` earlier in // the same frame. If they ran in `Update` they'd race the // click handler: there's no ordering edge between an arbitrary // modal-spawning system and the focus chain, so Bevy's // `auto_insert_apply_deferred` pass cannot synchronise them. // Pushing the attach / auto-focus pair into `PostUpdate` puts // the natural schedule-boundary sync point between every // modal spawn and focus arrival — `FocusedButton` is always // populated before the same `app.update()` returns. // // The remaining systems stay in `Update` so they keep // observing input on the frame it occurs. They read // `FocusedButton` written during the *previous* tick's // `PostUpdate`, which is exactly what we want: the very next // user keypress after a modal opens lands on a populated // resource. .add_systems( PostUpdate, ( attach_focusable_to_modal_buttons, auto_focus_on_modal_open, ) .chain(), ) .add_systems( Update, ( sync_focus_on_mouse_click, clear_hud_focus_on_unhover, handle_focus_keys, update_focus_overlay, pulse_focus_overlay, ) .chain(), ); } } /// Computes the focus-ring breathing factor for a given elapsed time. /// /// Returns a value in `[0.65, 1.0]` following a sin curve over /// [`MOTION_FOCUS_PULSE_SECS`]. Multiply [`FOCUS_RING`]'s native alpha by /// this factor each frame to produce the breathing effect. /// /// Pure helper so the curve can be unit-tested without a Bevy app. pub fn focus_ring_pulse_factor(elapsed_secs: f32) -> f32 { let phase = (elapsed_secs * TAU / MOTION_FOCUS_PULSE_SECS).sin(); // 0.825 mid-point ± 0.175 amplitude → range [0.65, 1.0]. Multiplicative // factor against FOCUS_RING's static alpha so the brightest tick is // exactly the original colour, not a brighter one. 0.825 + 0.175 * phase } /// Modulates the focus overlay's border alpha with a slow sin-curve /// breathing pulse so the indicator catches the eye without competing /// with gameplay motion. Skipped under `AnimSpeed::Instant` — the static /// border colour is restored so reduced-motion users see no animation. fn pulse_focus_overlay( time: Res