diff --git a/solitaire_engine/src/home_plugin.rs b/solitaire_engine/src/home_plugin.rs index bbe9b6b..c02f8f8 100644 --- a/solitaire_engine/src/home_plugin.rs +++ b/solitaire_engine/src/home_plugin.rs @@ -23,6 +23,7 @@ use crate::events::{ }; use crate::font_plugin::FontResource; use crate::progress_plugin::ProgressResource; +use crate::ui_focus::{Disabled, FocusGroup, Focusable}; use crate::ui_modal::{ spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant, }; @@ -138,6 +139,7 @@ impl Plugin for HomePlugin { Update, ( toggle_home_screen, + attach_focusable_to_home_mode_cards, handle_home_card_click, handle_home_cancel_button, ), @@ -281,6 +283,65 @@ fn spawn_home_screen(commands: &mut Commands, level: u32, font_res: Option<&Font }); } +/// Tab-walk order for each mode card, matching the visual top-to-bottom +/// stack inside the Home modal. Lower numbers receive focus first under +/// `Focusable`'s sort. +fn home_mode_focus_order(mode: HomeMode) -> i32 { + match mode { + HomeMode::Classic => 0, + HomeMode::Daily => 1, + HomeMode::Zen => 2, + HomeMode::Challenge => 3, + HomeMode::TimeAttack => 4, + } +} + +/// Auto-attaches [`Focusable`] (and [`Disabled`] when locked) to every +/// newly-spawned [`HomeModeCard`]. Walks ancestors to find the +/// [`crate::ui_modal::ModalScrim`] so each card's focus group is bound +/// to its parent modal — mirrors the convention that +/// `attach_focusable_to_modal_buttons` uses for `ModalButton`s. +/// +/// Doing this in a system (instead of inline at spawn time) lets +/// `spawn_home_screen` keep using the existing `spawn_modal`'s +/// build-closure shape; the scrim entity isn't visible inside that +/// closure, only after the call returns. The system runs every frame +/// and is a no-op once every card has been tagged. +fn attach_focusable_to_home_mode_cards( + mut commands: Commands, + new_cards: Query<(Entity, &HomeModeCard), Without>, + parents: Query<&ChildOf>, + scrims: Query<(), With>, + progress: Option>, +) { + let level = progress.as_ref().map_or(0, |p| p.0.level); + for (card_entity, card) in &new_cards { + // Walk ancestors until we find the ModalScrim. Bounded loop so a + // malformed hierarchy can't hang the system — same defensive + // shape as `attach_focusable_to_modal_buttons`. + let mut current = card_entity; + let mut scrim_entity: Option = None; + for _ in 0..32 { + if scrims.get(current).is_ok() { + scrim_entity = Some(current); + break; + } + match parents.get(current) { + Ok(parent) => current = parent.parent(), + Err(_) => break, + } + } + let Some(scrim) = scrim_entity else { continue }; + commands.entity(card_entity).insert(Focusable { + group: FocusGroup::Modal(scrim), + order: home_mode_focus_order(card.0), + }); + if !card.0.is_unlocked(level) { + commands.entity(card_entity).insert(Disabled); + } + } +} + /// Spawns one mode card — a `Button` whose children are a title row, a /// description line, and (when locked) a "Reach level N" hint. /// @@ -707,4 +768,109 @@ mod tests { "Cancel must not fire NewGameRequestEvent" ); } + + // ----------------------------------------------------------------------- + // Phase 2: keyboard focus ring — Home mode cards + // ----------------------------------------------------------------------- + + /// Headless app variant that also installs the focus and modal + /// plugins so `attach_focusable_to_modal_buttons` and Phase 2's + /// `attach_focusable_to_home_mode_cards` can run. + fn headless_app_with_focus() -> App { + use crate::ui_focus::UiFocusPlugin; + use crate::ui_modal::UiModalPlugin; + + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .add_plugins(UiModalPlugin) + .add_plugins(UiFocusPlugin) + .add_plugins(GamePlugin) + .add_plugins(TablePlugin) + .add_plugins(ProgressPlugin::headless()) + .add_plugins(HomePlugin); + app.init_resource::>(); + app.update(); + app + } + + /// Open the Home modal at the given player level. Tags the cards + /// with `Focusable` (and, when locked, `Disabled`) by running an + /// extra tick after the M press so the focus-attach system fires. + fn open_home_at_level(app: &mut App, level: u32) -> Entity { + app.world_mut().resource_mut::().0.level = level; + let entity = open_home(app); + // One more tick so `attach_focusable_to_home_mode_cards` runs + // on the freshly-spawned cards. + app.update(); + entity + } + + #[test] + fn home_mode_cards_get_focusable_marker() { + let mut app = headless_app_with_focus(); + let scrim = open_home_at_level(&mut app, CHALLENGE_UNLOCK_LEVEL); + + // Every card carries `Focusable` in `FocusGroup::Modal(scrim)`. + let cards: Vec<(HomeMode, Focusable)> = app + .world_mut() + .query::<(&HomeModeCard, &Focusable)>() + .iter(app.world()) + .map(|(c, f)| (c.0, *f)) + .collect(); + + assert_eq!(cards.len(), 5, "all five cards must carry a Focusable"); + for (mode, focusable) in &cards { + assert_eq!( + focusable.group, + FocusGroup::Modal(scrim), + "{mode:?} card must be in the Home scrim's focus group" + ); + } + } + + #[test] + fn home_locked_cards_get_disabled_marker() { + let mut app = headless_app_with_focus(); + // Level 0: Zen, Challenge, Time Attack are locked; Classic and + // Daily are not. + let _ = open_home_at_level(&mut app, 0); + + let states: Vec<(HomeMode, bool)> = app + .world_mut() + .query::<(&HomeModeCard, bevy::ecs::query::Has)>() + .iter(app.world()) + .map(|(c, d)| (c.0, d)) + .collect(); + + for (mode, disabled) in states { + match mode { + HomeMode::Classic | HomeMode::Daily => assert!( + !disabled, + "{mode:?} must not be Disabled at level 0 (it's never locked)" + ), + HomeMode::Zen | HomeMode::Challenge | HomeMode::TimeAttack => assert!( + disabled, + "{mode:?} must carry the Disabled marker at level 0 so Tab skips it" + ), + } + } + } + + #[test] + fn home_unlocked_cards_no_disabled_marker() { + let mut app = headless_app_with_focus(); + let _ = open_home_at_level(&mut app, CHALLENGE_UNLOCK_LEVEL); + + let any_disabled = app + .world_mut() + .query_filtered::<&HomeModeCard, With>() + .iter(app.world()) + .next() + .is_some(); + + assert!( + !any_disabled, + "no card may be Disabled when the player is at the unlock level" + ); + } } diff --git a/solitaire_engine/src/hud_plugin.rs b/solitaire_engine/src/hud_plugin.rs index 115d744..3ee911a 100644 --- a/solitaire_engine/src/hud_plugin.rs +++ b/solitaire_engine/src/hud_plugin.rs @@ -34,6 +34,7 @@ use crate::game_plugin::GameMutation; use crate::resources::GameStateResource; use crate::selection_plugin::SelectionState; use crate::time_attack_plugin::TimeAttackResource; +use crate::ui_focus::{FocusGroup, Focusable}; /// Marker on the score text node. #[derive(Component, Debug)] @@ -452,24 +453,34 @@ fn spawn_action_buttons(font_res: Option>, mut commands: Comma // Menu and Modes don't have a single hotkey accelerator // (each row inside their popover has its own); their button // labels carry the dropdown chevron in lieu of a key chip. - spawn_action_button(row, MenuButton, "Menu \u{25BE}", None, &font); - spawn_action_button(row, UndoButton, "Undo", Some("U"), &font); - spawn_action_button(row, PauseButton, "Pause", Some("Esc"), &font); - spawn_action_button(row, HelpButton, "Help", Some("F1"), &font); - spawn_action_button(row, ModesButton, "Modes \u{25BE}", None, &font); - spawn_action_button(row, NewGameButton, "New Game", Some("N"), &font); + // + // The trailing `order` argument is the per-button index in + // visual reading order (left → right). It feeds + // `Focusable { group: Hud, order }` so Tab cycles the action + // bar in the same order the eye scans it. + spawn_action_button(row, MenuButton, "Menu \u{25BE}", None, &font, 0); + spawn_action_button(row, UndoButton, "Undo", Some("U"), &font, 1); + spawn_action_button(row, PauseButton, "Pause", Some("Esc"), &font, 2); + spawn_action_button(row, HelpButton, "Help", Some("F1"), &font, 3); + spawn_action_button(row, ModesButton, "Modes \u{25BE}", None, &font, 4); + spawn_action_button(row, NewGameButton, "New Game", Some("N"), &font, 5); }); } /// Spawns a single action button as a child of `row`. Each button shares /// the same node geometry, idle colour, and `ActionButton` marker so /// `paint_action_buttons` can recolour all of them with one query. +/// +/// `order` is the button's index inside the action bar (0 for the +/// leftmost). It propagates into the [`Focusable`] this function inserts +/// so Phase 2's keyboard focus ring cycles the HUD in visual order. fn spawn_action_button( row: &mut ChildSpawnerCommands, marker: M, label: &str, hotkey: Option<&'static str>, font: &TextFont, + order: i32, ) { let hotkey_font = TextFont { font: font.font.clone(), @@ -480,6 +491,15 @@ fn spawn_action_button( marker, ActionButton, Button, + // Joins the `Hud` focus group at the supplied order so Tab + // cycles HUD buttons left-to-right under Phase 2. The HUD focus + // ring still only engages when a HUD button is hovered (or in + // future phases, when the player explicitly switches groups); + // the marker just declares membership. + Focusable { + group: FocusGroup::Hud, + order, + }, Node { padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2), justify_content: JustifyContent::Center, @@ -1804,4 +1824,137 @@ mod tests { assert!((score_pulse_scale(-0.2) - 1.0).abs() < 1e-6); assert!((score_pulse_scale(2.0) - 1.0).abs() < 1e-6); } + + // ----------------------------------------------------------------------- + // Phase 2: keyboard focus ring — HUD action bar + // ----------------------------------------------------------------------- + + /// Returns the `Focusable` carried by the unique entity matching + /// marker `M`. Helper for the HUD focus tests. + fn focusable_for(app: &mut App) -> Focusable { + app.world_mut() + .query_filtered::<&Focusable, With>() + .iter(app.world()) + .next() + .copied() + .unwrap_or_else(|| panic!("no Focusable on the {} button", std::any::type_name::())) + } + + #[test] + fn hud_buttons_get_focusable_marker() { + let mut app = headless_app(); + // Every action-bar button is in `FocusGroup::Hud`. + for f in [ + focusable_for::(&mut app), + focusable_for::(&mut app), + focusable_for::(&mut app), + focusable_for::(&mut app), + focusable_for::(&mut app), + focusable_for::(&mut app), + ] { + assert_eq!( + f.group, + FocusGroup::Hud, + "every HUD action button must be in FocusGroup::Hud" + ); + } + } + + #[test] + fn hud_button_order_matches_spawn_order() { + let mut app = headless_app(); + // Visual reading order (left → right): Menu, Undo, Pause, Help, + // Modes, New Game. Their `order` fields must be 0..=5 in that + // order so Tab cycles them as the player reads them. + assert_eq!(focusable_for::(&mut app).order, 0); + assert_eq!(focusable_for::(&mut app).order, 1); + assert_eq!(focusable_for::(&mut app).order, 2); + assert_eq!(focusable_for::(&mut app).order, 3); + assert_eq!(focusable_for::(&mut app).order, 4); + assert_eq!(focusable_for::(&mut app).order, 5); + } + + #[test] + fn hud_focus_only_engages_when_button_hovered() { + // Phase 2 declares membership in `FocusGroup::Hud`; the + // engagement rule lives in `handle_focus_keys`. Two halves to + // this test: + // (a) no modal + no hover ⇒ Tab is a no-op (Phase 1 contract + // still holds when nothing is hovered). + // (b) no modal + a HUD button hovered ⇒ Tab advances + // `FocusedButton` to a Hud-grouped entity. + use crate::ui_focus::{FocusedButton, UiFocusPlugin}; + use crate::ui_modal::UiModalPlugin; + + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .add_plugins(UiModalPlugin) + .add_plugins(UiFocusPlugin) + .add_plugins(GamePlugin) + .add_plugins(TablePlugin) + .add_plugins(HudPlugin); + app.init_resource::>(); + app.update(); + + // (a) Sanity: HUD buttons exist and are focusable, but no + // modal open and no hover ⇒ FocusedButton stays None. + assert!( + app.world().resource::().0.is_none(), + "no modal open, no auto-focus" + ); + + // Press Tab. With no modal and no hover, `handle_focus_keys` + // resolves no active group and returns early — Tab must not + // advance the HUD focus ring on its own. + { + let mut input = app.world_mut().resource_mut::>(); + input.release_all(); + input.clear(); + input.press(KeyCode::Tab); + } + app.update(); + + assert!( + app.world().resource::().0.is_none(), + "Tab with no modal and no Hud hover must not engage the HUD focus ring" + ); + + // (b) Hover the Menu button — the leftmost HUD action — and + // Tab. The Hud-group cycle should pick a Hud-tagged entity. + let menu_entity = app + .world_mut() + .query_filtered::>() + .iter(app.world()) + .next() + .expect("MenuButton entity should exist"); + app.world_mut() + .entity_mut(menu_entity) + .insert(Interaction::Hovered); + + { + let mut input = app.world_mut().resource_mut::>(); + input.release_all(); + input.clear(); + input.press(KeyCode::Tab); + } + app.update(); + + let focused = app + .world() + .resource::() + .0 + .expect("Tab with a HUD button hovered must engage the HUD focus ring"); + // The focused entity must itself be Hud-grouped (i.e. one of + // the action-bar buttons), not anything else in the world. + let focusable = app + .world() + .entity(focused) + .get::() + .expect("focused entity must carry Focusable"); + assert_eq!( + focusable.group, + FocusGroup::Hud, + "Hud-engaged Tab must focus a Hud-grouped entity" + ); + } } diff --git a/solitaire_engine/src/ui_focus.rs b/solitaire_engine/src/ui_focus.rs index c98470b..7199ce0 100644 --- a/solitaire_engine/src/ui_focus.rs +++ b/solitaire_engine/src/ui_focus.rs @@ -102,6 +102,7 @@ impl Plugin for UiFocusPlugin { attach_focusable_to_modal_buttons, auto_focus_on_modal_open, sync_focus_on_mouse_click, + clear_hud_focus_on_unhover, handle_focus_keys, update_focus_overlay, ) @@ -260,26 +261,82 @@ fn sync_focus_on_mouse_click( } } -/// Handles Tab / Shift+Tab / Enter / Space when a modal is open (the -/// only active focus group in Phase 1). Consumed keys are cleared from -/// `ButtonInput` so [`crate::selection_plugin`] doesn't also -/// treat them as card-selection input. +/// Clears [`FocusedButton`] when the focused entity is a Hud-grouped +/// button and the mouse has moved off the entire HUD bar (no Hud +/// `Focusable` is currently `Interaction::Hovered`). Without this, the +/// focus ring would persist around a HUD button after the cursor +/// leaves — visually confusing because the player has nothing to +/// activate at that point. /// -/// When no modal is open this system is a no-op — card-selection Tab -/// keeps working exactly as it did before Phase 1. +/// Modal focus is unaffected: a focused modal button stays focused +/// while the modal is open, regardless of mouse position. +fn clear_hud_focus_on_unhover( + mut focused: ResMut, + focusables: Query<&Focusable>, + hud_interactions: Query<(&Interaction, &Focusable), Without>, +) { + let Some(target) = focused.0 else { + return; + }; + // Only act when the current focus is a Hud focusable. Modal focus + // is sticky. + let Ok(target_focusable) = focusables.get(target) else { + return; + }; + if target_focusable.group != FocusGroup::Hud { + return; + } + let any_hud_hovered = hud_interactions.iter().any(|(interaction, focusable)| { + matches!(interaction, Interaction::Hovered) && focusable.group == FocusGroup::Hud + }); + if !any_hud_hovered { + focused.0 = None; + } +} + +/// Handles Tab / Shift+Tab / Enter / Space when a focus group is +/// active. Two activation paths exist: +/// +/// 1. **Modal** — if any [`ModalScrim`] entity exists, the topmost +/// scrim's group becomes active. Tab cycles only buttons inside that +/// scrim's hierarchy (matches Phase 1). +/// 2. **Hud** — else, if at least one `Focusable { group: Hud }` +/// entity is currently `Interaction::Hovered`, the HUD bar engages. +/// Tab cycles through every Hud-grouped focusable, sorted by +/// `(order, spawn_index)`. +/// +/// When neither path is active this system is a no-op — card-selection +/// Tab in [`crate::selection_plugin`] keeps working exactly as before. +/// +/// Consumed keys are cleared from `ButtonInput` so the +/// selection plugin doesn't double-handle them. +#[allow(clippy::type_complexity)] fn handle_focus_keys( mut keys: ResMut>, scrims: Query>, children_q: Query<&Children>, focusables: Query<(&Focusable, Has)>, + hud_interactions: Query<(Entity, &Interaction, &Focusable), Without>, mut focused: ResMut, mut writes: Commands, ) { - if scrims.iter().next().is_none() { - // No modal open ⇒ Phase 1 stays out of the way. Phase 2 will - // extend this with a Hud-group active path. + // Resolve the active focus group: + // 1. Any modal open ⇒ Modal(topmost scrim) + // 2. Any Hud-grouped focusable hovered ⇒ Hud + // 3. Otherwise ⇒ no-op + let active_group: FocusGroup = if let Some(active_scrim) = scrims.iter().max_by_key(|e| e.index()) { + // Pick the topmost modal as the active group. With multiple + // modals stacked (Pause + Forfeit confirm) the most-recently- + // spawned scrim has the highest entity index — same heuristic + // Phase 1 used. + FocusGroup::Modal(active_scrim) + } else if hud_interactions.iter().any(|(_, interaction, focusable)| { + matches!(interaction, Interaction::Hovered) && focusable.group == FocusGroup::Hud + }) { + FocusGroup::Hud + } else { return; - } + }; let tab_pressed = keys.just_pressed(KeyCode::Tab); let activate_pressed = @@ -289,42 +346,56 @@ fn handle_focus_keys( return; } - // Pick the topmost modal as the active group. With multiple modals - // stacked (Pause + Forfeit confirm) the most-recently-spawned scrim - // has the highest entity index. Bevy entity indices grow on each - // spawn, so this is a stable proxy for "topmost modal" in Phase 1. - let active_scrim = scrims - .iter() - .max_by_key(|e| e.index()) - .expect("scrims iter was non-empty above"); - let active_group = FocusGroup::Modal(active_scrim); - - // Walk the scrim's hierarchy in `Children` order so the cycle - // matches the visual document order (left → right inside - // `spawn_modal_actions`). Using `Children` traversal — not entity - // index — sidesteps the fact that ECS entity indices don't track - // spawn order under deferred command application. - let mut group: Vec = Vec::new(); - let mut stack: Vec = vec![active_scrim]; - while let Some(entity) = stack.pop() { - if let Ok(children) = children_q.get(entity) { - // Push in reverse so the first child is popped first — - // gives us a depth-first walk in Children order. - for child in children.iter().collect::>().into_iter().rev() { - stack.push(child); + // Build the cycle list for the active group. + let mut group: Vec = match active_group { + FocusGroup::Modal(scrim) => { + // Walk the scrim's hierarchy in `Children` order so the + // cycle matches the visual document order (left → right + // inside `spawn_modal_actions`). Using `Children` + // traversal — not entity index — sidesteps the fact that + // ECS entity indices don't track spawn order under + // deferred command application. + let mut found: Vec = Vec::new(); + let mut stack: Vec = vec![scrim]; + while let Some(entity) = stack.pop() { + if let Ok(children) = children_q.get(entity) { + // Push in reverse so the first child is popped + // first — gives us a depth-first walk in Children + // order. + for child in children.iter().collect::>().into_iter().rev() { + stack.push(child); + } + } + if let Ok((focusable, disabled)) = focusables.get(entity) + && !disabled + && focusable.group == active_group + { + found.push(entity); + } } + found } - if let Ok((focusable, disabled)) = focusables.get(entity) - && !disabled - && focusable.group == active_group - { - group.push(entity); + FocusGroup::Hud => { + // The HUD action bar isn't a single subtree we can walk — + // each button is spawned independently — so collect every + // Hud-grouped, non-disabled focusable directly. + // `hud_interactions` already filters out `Disabled` and + // exposes the entity id we need. + let mut found: Vec = hud_interactions + .iter() + .filter_map(|(entity, _interaction, focusable)| { + (focusable.group == FocusGroup::Hud).then_some(entity) + }) + .collect(); + // Tiebreak by entity index so a deterministic spawn-order + // sort falls out of the secondary key. + found.sort_by_key(|e| e.index()); + found } - } - // Stable sort by `Focusable::order` (Phase 1 keeps every value at - // 0 so this is effectively a no-op, but it lets future phases give - // explicit priorities — e.g. a "primary first" override — without - // changing the tab walk). + }; + // Stable sort by `Focusable::order` so explicit priorities (e.g. + // HUD spawn-order: 0..5) drive the cycle. The pre-sort by entity + // index above is the tiebreaker for entries sharing an `order`. group.sort_by_key(|e| { focusables .get(*e) @@ -763,4 +834,163 @@ mod tests { "handle_focus_keys must clear Tab so selection_plugin can't double-handle it" ); } + + // ----------------------------------------------------------------------- + // Phase 2 — HUD-on-hover focus path + // ----------------------------------------------------------------------- + + /// Spawns three synthetic Hud-tagged focusable buttons (orders + /// 0, 1, 2) without involving the real HUD bar — keeps the test + /// independent of `HudPlugin`'s layout. Every button gets a + /// `Button` widget (so `Interaction` is present) and `Node` so the + /// query in `handle_focus_keys` matches. + fn spawn_three_hud_buttons(app: &mut App) -> (Entity, Entity, Entity) { + let world = app.world_mut(); + let a = world + .spawn(( + Button, + Node::default(), + Interaction::default(), + Focusable { + group: FocusGroup::Hud, + order: 0, + }, + TestButtonA, + )) + .id(); + let b = world + .spawn(( + Button, + Node::default(), + Interaction::default(), + Focusable { + group: FocusGroup::Hud, + order: 1, + }, + TestButtonB, + )) + .id(); + let c = world + .spawn(( + Button, + Node::default(), + Interaction::default(), + Focusable { + group: FocusGroup::Hud, + order: 2, + }, + TestButtonC, + )) + .id(); + app.update(); + (a, b, c) + } + + #[test] + fn hud_tab_engages_only_when_a_hud_button_is_hovered() { + let mut app = headless_app(); + let (a, _b, _c) = spawn_three_hud_buttons(&mut app); + + // No hover, no modal ⇒ Tab is a no-op. (Phase 1 contract still + // holds when nothing is hovered.) + press_key(&mut app, KeyCode::Tab); + app.update(); + assert!( + app.world().resource::().0.is_none(), + "Tab without hover must not engage the HUD focus ring" + ); + + // Hover button A → Tab must engage and focus a Hud entity. + // With no current focus, the cycle starts at index 0 (order + // 0), which is button A. + app.world_mut().entity_mut(a).insert(Interaction::Hovered); + press_key(&mut app, KeyCode::Tab); + app.update(); + + assert_eq!( + app.world().resource::().0, + Some(a), + "first Tab on Hud-engaged group should focus the order=0 button" + ); + } + + #[test] + fn hud_tab_advances_within_hud_group() { + let mut app = headless_app(); + let (a, b, c) = spawn_three_hud_buttons(&mut app); + + // Engage by hovering A, then Tab to land on A. + app.world_mut().entity_mut(a).insert(Interaction::Hovered); + press_key(&mut app, KeyCode::Tab); + app.update(); + assert_eq!(app.world().resource::().0, Some(a)); + + // Subsequent Tabs cycle by `Focusable::order`. + press_key(&mut app, KeyCode::Tab); + app.update(); + assert_eq!(app.world().resource::().0, Some(b)); + + press_key(&mut app, KeyCode::Tab); + app.update(); + assert_eq!(app.world().resource::().0, Some(c)); + + // Wrap-around back to A. + press_key(&mut app, KeyCode::Tab); + app.update(); + assert_eq!(app.world().resource::().0, Some(a)); + } + + #[test] + fn hud_enter_activates_focused_hud_button() { + let mut app = headless_app(); + let (a, _b, _c) = spawn_three_hud_buttons(&mut app); + + app.world_mut().entity_mut(a).insert(Interaction::Hovered); + press_key(&mut app, KeyCode::Tab); + app.update(); + assert_eq!(app.world().resource::().0, Some(a)); + + // Enter while A is focused inserts `Interaction::Pressed`. + // Note: A also still has `Interaction::Hovered` from earlier; + // the activation system overwrites it with `Pressed`. + press_key(&mut app, KeyCode::Enter); + app.update(); + + let post = app + .world() + .entity(a) + .get::() + .copied() + .expect("focused HUD button should carry an Interaction after activation"); + assert_eq!( + post, + Interaction::Pressed, + "Enter on focused HUD button A should leave its Interaction at Pressed" + ); + } + + #[test] + fn hud_focus_clears_when_mouse_leaves_bar() { + let mut app = headless_app(); + let (a, b, c) = spawn_three_hud_buttons(&mut app); + + // Engage by hovering A, then Tab to focus A. + app.world_mut().entity_mut(a).insert(Interaction::Hovered); + press_key(&mut app, KeyCode::Tab); + app.update(); + assert_eq!(app.world().resource::().0, Some(a)); + + // Mouse leaves the bar entirely — every Hud button drops back + // to `Interaction::None`. After the next update, + // `clear_hud_focus_on_unhover` must clear `FocusedButton`. + for entity in [a, b, c] { + app.world_mut().entity_mut(entity).insert(Interaction::None); + } + app.update(); + + assert!( + app.world().resource::().0.is_none(), + "FocusedButton must clear once no Hud button is hovered" + ); + } }