feat(engine): keyboard focus on HUD action bar and Home mode cards (Phase 2)
The HUD action bar (Menu / Undo / Pause / Help / Modes / New Game) and the five Home mode-launcher cards now participate in keyboard focus, extending Phase 1's modal-only coverage. The HUD focus group activates only when no modal is open and the mouse is hovering an action-bar button — the design decision avoids stealing Tab from selection_plugin's card-selection nav for the common "playing on the board" case. Once engaged, Tab/Shift-Tab cycles the bar in spawn order and Enter activates. Moving the mouse off the bar clears focus so the ring doesn't linger. Home mode cards opt into FocusGroup::Modal(home_scrim) via an ancestry-walking system that mirrors the Phase 1 attach helper, so spawn_mode_card's signature is unchanged. Locked cards (Zen, Challenge, Time Attack at level <5) get the Disabled marker so Tab skips them and Enter is a no-op — mirroring the existing visual locked state with real keyboard semantics. handle_focus_keys gains a Hud-on-hover branch in its active-group resolver and a clear_hud_focus_on_unhover system. Together they implement the agreed UX: focus follows hover when the bar is active, Tab cycles within the hovered group, and the ring disappears the instant the mouse leaves. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,7 @@ use crate::events::{
|
|||||||
};
|
};
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
|
use crate::ui_focus::{Disabled, FocusGroup, Focusable};
|
||||||
use crate::ui_modal::{
|
use crate::ui_modal::{
|
||||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||||
};
|
};
|
||||||
@@ -138,6 +139,7 @@ impl Plugin for HomePlugin {
|
|||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
toggle_home_screen,
|
toggle_home_screen,
|
||||||
|
attach_focusable_to_home_mode_cards,
|
||||||
handle_home_card_click,
|
handle_home_card_click,
|
||||||
handle_home_cancel_button,
|
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<Focusable>>,
|
||||||
|
parents: Query<&ChildOf>,
|
||||||
|
scrims: Query<(), With<crate::ui_modal::ModalScrim>>,
|
||||||
|
progress: Option<Res<ProgressResource>>,
|
||||||
|
) {
|
||||||
|
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<Entity> = 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
|
/// Spawns one mode card — a `Button` whose children are a title row, a
|
||||||
/// description line, and (when locked) a "Reach level N" hint.
|
/// description line, and (when locked) a "Reach level N" hint.
|
||||||
///
|
///
|
||||||
@@ -707,4 +768,109 @@ mod tests {
|
|||||||
"Cancel must not fire NewGameRequestEvent"
|
"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::<ButtonInput<KeyCode>>();
|
||||||
|
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::<ProgressResource>().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<Disabled>)>()
|
||||||
|
.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<Disabled>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.next()
|
||||||
|
.is_some();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!any_disabled,
|
||||||
|
"no card may be Disabled when the player is at the unlock level"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ use crate::game_plugin::GameMutation;
|
|||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
use crate::selection_plugin::SelectionState;
|
use crate::selection_plugin::SelectionState;
|
||||||
use crate::time_attack_plugin::TimeAttackResource;
|
use crate::time_attack_plugin::TimeAttackResource;
|
||||||
|
use crate::ui_focus::{FocusGroup, Focusable};
|
||||||
|
|
||||||
/// Marker on the score text node.
|
/// Marker on the score text node.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
@@ -452,24 +453,34 @@ fn spawn_action_buttons(font_res: Option<Res<FontResource>>, mut commands: Comma
|
|||||||
// Menu and Modes don't have a single hotkey accelerator
|
// Menu and Modes don't have a single hotkey accelerator
|
||||||
// (each row inside their popover has its own); their button
|
// (each row inside their popover has its own); their button
|
||||||
// labels carry the dropdown chevron in lieu of a key chip.
|
// 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);
|
// The trailing `order` argument is the per-button index in
|
||||||
spawn_action_button(row, PauseButton, "Pause", Some("Esc"), &font);
|
// visual reading order (left → right). It feeds
|
||||||
spawn_action_button(row, HelpButton, "Help", Some("F1"), &font);
|
// `Focusable { group: Hud, order }` so Tab cycles the action
|
||||||
spawn_action_button(row, ModesButton, "Modes \u{25BE}", None, &font);
|
// bar in the same order the eye scans it.
|
||||||
spawn_action_button(row, NewGameButton, "New Game", Some("N"), &font);
|
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
|
/// Spawns a single action button as a child of `row`. Each button shares
|
||||||
/// the same node geometry, idle colour, and `ActionButton` marker so
|
/// the same node geometry, idle colour, and `ActionButton` marker so
|
||||||
/// `paint_action_buttons` can recolour all of them with one query.
|
/// `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<M: Component>(
|
fn spawn_action_button<M: Component>(
|
||||||
row: &mut ChildSpawnerCommands,
|
row: &mut ChildSpawnerCommands,
|
||||||
marker: M,
|
marker: M,
|
||||||
label: &str,
|
label: &str,
|
||||||
hotkey: Option<&'static str>,
|
hotkey: Option<&'static str>,
|
||||||
font: &TextFont,
|
font: &TextFont,
|
||||||
|
order: i32,
|
||||||
) {
|
) {
|
||||||
let hotkey_font = TextFont {
|
let hotkey_font = TextFont {
|
||||||
font: font.font.clone(),
|
font: font.font.clone(),
|
||||||
@@ -480,6 +491,15 @@ fn spawn_action_button<M: Component>(
|
|||||||
marker,
|
marker,
|
||||||
ActionButton,
|
ActionButton,
|
||||||
Button,
|
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 {
|
Node {
|
||||||
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
|
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
|
||||||
justify_content: JustifyContent::Center,
|
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(-0.2) - 1.0).abs() < 1e-6);
|
||||||
assert!((score_pulse_scale(2.0) - 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<M: Component>(app: &mut App) -> Focusable {
|
||||||
|
app.world_mut()
|
||||||
|
.query_filtered::<&Focusable, With<M>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.next()
|
||||||
|
.copied()
|
||||||
|
.unwrap_or_else(|| panic!("no Focusable on the {} button", std::any::type_name::<M>()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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::<MenuButton>(&mut app),
|
||||||
|
focusable_for::<UndoButton>(&mut app),
|
||||||
|
focusable_for::<PauseButton>(&mut app),
|
||||||
|
focusable_for::<HelpButton>(&mut app),
|
||||||
|
focusable_for::<ModesButton>(&mut app),
|
||||||
|
focusable_for::<NewGameButton>(&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::<MenuButton>(&mut app).order, 0);
|
||||||
|
assert_eq!(focusable_for::<UndoButton>(&mut app).order, 1);
|
||||||
|
assert_eq!(focusable_for::<PauseButton>(&mut app).order, 2);
|
||||||
|
assert_eq!(focusable_for::<HelpButton>(&mut app).order, 3);
|
||||||
|
assert_eq!(focusable_for::<ModesButton>(&mut app).order, 4);
|
||||||
|
assert_eq!(focusable_for::<NewGameButton>(&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::<ButtonInput<KeyCode>>();
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// (a) Sanity: HUD buttons exist and are focusable, but no
|
||||||
|
// modal open and no hover ⇒ FocusedButton stays None.
|
||||||
|
assert!(
|
||||||
|
app.world().resource::<FocusedButton>().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::<ButtonInput<KeyCode>>();
|
||||||
|
input.release_all();
|
||||||
|
input.clear();
|
||||||
|
input.press(KeyCode::Tab);
|
||||||
|
}
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
app.world().resource::<FocusedButton>().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::<Entity, With<MenuButton>>()
|
||||||
|
.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::<ButtonInput<KeyCode>>();
|
||||||
|
input.release_all();
|
||||||
|
input.clear();
|
||||||
|
input.press(KeyCode::Tab);
|
||||||
|
}
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let focused = app
|
||||||
|
.world()
|
||||||
|
.resource::<FocusedButton>()
|
||||||
|
.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::<Focusable>()
|
||||||
|
.expect("focused entity must carry Focusable");
|
||||||
|
assert_eq!(
|
||||||
|
focusable.group,
|
||||||
|
FocusGroup::Hud,
|
||||||
|
"Hud-engaged Tab must focus a Hud-grouped entity"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ impl Plugin for UiFocusPlugin {
|
|||||||
attach_focusable_to_modal_buttons,
|
attach_focusable_to_modal_buttons,
|
||||||
auto_focus_on_modal_open,
|
auto_focus_on_modal_open,
|
||||||
sync_focus_on_mouse_click,
|
sync_focus_on_mouse_click,
|
||||||
|
clear_hud_focus_on_unhover,
|
||||||
handle_focus_keys,
|
handle_focus_keys,
|
||||||
update_focus_overlay,
|
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
|
/// Clears [`FocusedButton`] when the focused entity is a Hud-grouped
|
||||||
/// only active focus group in Phase 1). Consumed keys are cleared from
|
/// button and the mouse has moved off the entire HUD bar (no Hud
|
||||||
/// `ButtonInput<KeyCode>` so [`crate::selection_plugin`] doesn't also
|
/// `Focusable` is currently `Interaction::Hovered`). Without this, the
|
||||||
/// treat them as card-selection input.
|
/// 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
|
/// Modal focus is unaffected: a focused modal button stays focused
|
||||||
/// keeps working exactly as it did before Phase 1.
|
/// while the modal is open, regardless of mouse position.
|
||||||
|
fn clear_hud_focus_on_unhover(
|
||||||
|
mut focused: ResMut<FocusedButton>,
|
||||||
|
focusables: Query<&Focusable>,
|
||||||
|
hud_interactions: Query<(&Interaction, &Focusable), Without<Disabled>>,
|
||||||
|
) {
|
||||||
|
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<KeyCode>` so the
|
||||||
|
/// selection plugin doesn't double-handle them.
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
fn handle_focus_keys(
|
fn handle_focus_keys(
|
||||||
mut keys: ResMut<ButtonInput<KeyCode>>,
|
mut keys: ResMut<ButtonInput<KeyCode>>,
|
||||||
scrims: Query<Entity, With<ModalScrim>>,
|
scrims: Query<Entity, With<ModalScrim>>,
|
||||||
children_q: Query<&Children>,
|
children_q: Query<&Children>,
|
||||||
focusables: Query<(&Focusable, Has<Disabled>)>,
|
focusables: Query<(&Focusable, Has<Disabled>)>,
|
||||||
|
hud_interactions: Query<(Entity, &Interaction, &Focusable), Without<Disabled>>,
|
||||||
mut focused: ResMut<FocusedButton>,
|
mut focused: ResMut<FocusedButton>,
|
||||||
mut writes: Commands,
|
mut writes: Commands,
|
||||||
) {
|
) {
|
||||||
if scrims.iter().next().is_none() {
|
// Resolve the active focus group:
|
||||||
// No modal open ⇒ Phase 1 stays out of the way. Phase 2 will
|
// 1. Any modal open ⇒ Modal(topmost scrim)
|
||||||
// extend this with a Hud-group active path.
|
// 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;
|
return;
|
||||||
}
|
};
|
||||||
|
|
||||||
let tab_pressed = keys.just_pressed(KeyCode::Tab);
|
let tab_pressed = keys.just_pressed(KeyCode::Tab);
|
||||||
let activate_pressed =
|
let activate_pressed =
|
||||||
@@ -289,42 +346,56 @@ fn handle_focus_keys(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pick the topmost modal as the active group. With multiple modals
|
// Build the cycle list for the active group.
|
||||||
// stacked (Pause + Forfeit confirm) the most-recently-spawned scrim
|
let mut group: Vec<Entity> = match active_group {
|
||||||
// has the highest entity index. Bevy entity indices grow on each
|
FocusGroup::Modal(scrim) => {
|
||||||
// spawn, so this is a stable proxy for "topmost modal" in Phase 1.
|
// Walk the scrim's hierarchy in `Children` order so the
|
||||||
let active_scrim = scrims
|
// cycle matches the visual document order (left → right
|
||||||
.iter()
|
// inside `spawn_modal_actions`). Using `Children`
|
||||||
.max_by_key(|e| e.index())
|
// traversal — not entity index — sidesteps the fact that
|
||||||
.expect("scrims iter was non-empty above");
|
// ECS entity indices don't track spawn order under
|
||||||
let active_group = FocusGroup::Modal(active_scrim);
|
// deferred command application.
|
||||||
|
let mut found: Vec<Entity> = Vec::new();
|
||||||
// Walk the scrim's hierarchy in `Children` order so the cycle
|
let mut stack: Vec<Entity> = vec![scrim];
|
||||||
// matches the visual document order (left → right inside
|
while let Some(entity) = stack.pop() {
|
||||||
// `spawn_modal_actions`). Using `Children` traversal — not entity
|
if let Ok(children) = children_q.get(entity) {
|
||||||
// index — sidesteps the fact that ECS entity indices don't track
|
// Push in reverse so the first child is popped
|
||||||
// spawn order under deferred command application.
|
// first — gives us a depth-first walk in Children
|
||||||
let mut group: Vec<Entity> = Vec::new();
|
// order.
|
||||||
let mut stack: Vec<Entity> = vec![active_scrim];
|
for child in children.iter().collect::<Vec<_>>().into_iter().rev() {
|
||||||
while let Some(entity) = stack.pop() {
|
stack.push(child);
|
||||||
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.
|
if let Ok((focusable, disabled)) = focusables.get(entity)
|
||||||
for child in children.iter().collect::<Vec<_>>().into_iter().rev() {
|
&& !disabled
|
||||||
stack.push(child);
|
&& focusable.group == active_group
|
||||||
|
{
|
||||||
|
found.push(entity);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
found
|
||||||
}
|
}
|
||||||
if let Ok((focusable, disabled)) = focusables.get(entity)
|
FocusGroup::Hud => {
|
||||||
&& !disabled
|
// The HUD action bar isn't a single subtree we can walk —
|
||||||
&& focusable.group == active_group
|
// each button is spawned independently — so collect every
|
||||||
{
|
// Hud-grouped, non-disabled focusable directly.
|
||||||
group.push(entity);
|
// `hud_interactions` already filters out `Disabled` and
|
||||||
|
// exposes the entity id we need.
|
||||||
|
let mut found: Vec<Entity> = 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
|
// Stable sort by `Focusable::order` so explicit priorities (e.g.
|
||||||
// 0 so this is effectively a no-op, but it lets future phases give
|
// HUD spawn-order: 0..5) drive the cycle. The pre-sort by entity
|
||||||
// explicit priorities — e.g. a "primary first" override — without
|
// index above is the tiebreaker for entries sharing an `order`.
|
||||||
// changing the tab walk).
|
|
||||||
group.sort_by_key(|e| {
|
group.sort_by_key(|e| {
|
||||||
focusables
|
focusables
|
||||||
.get(*e)
|
.get(*e)
|
||||||
@@ -763,4 +834,163 @@ mod tests {
|
|||||||
"handle_focus_keys must clear Tab so selection_plugin can't double-handle it"
|
"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::<FocusedButton>().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::<FocusedButton>().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::<FocusedButton>().0, Some(a));
|
||||||
|
|
||||||
|
// Subsequent Tabs cycle by `Focusable::order`.
|
||||||
|
press_key(&mut app, KeyCode::Tab);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(app.world().resource::<FocusedButton>().0, Some(b));
|
||||||
|
|
||||||
|
press_key(&mut app, KeyCode::Tab);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(app.world().resource::<FocusedButton>().0, Some(c));
|
||||||
|
|
||||||
|
// Wrap-around back to A.
|
||||||
|
press_key(&mut app, KeyCode::Tab);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(app.world().resource::<FocusedButton>().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::<FocusedButton>().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::<Interaction>()
|
||||||
|
.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::<FocusedButton>().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::<FocusedButton>().0.is_none(),
|
||||||
|
"FocusedButton must clear once no Hud button is hovered"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user