48e412177c
Previously when a click-handler in Update spawned a modal, attach_focusable_to_modal_buttons and auto_focus_on_modal_open ran in the same Update — but with no ordering edge to the click handler the deferred Commands wouldn't materialise in time, so attach saw no entities, FocusedButton stayed empty, and the very next Tab/Enter press wasted itself moving focus from None to the primary instead of activating it. Moves attach_focusable_to_modal_buttons + auto_focus_on_modal_open from Update to PostUpdate. The schedule boundary itself supplies the sync point: every modal spawned anywhere in Update is materialised before PostUpdate runs, attach can find the new ModalButtons, and FocusedButton is populated before app.update() returns. handle_focus_keys stays in Update so it observes input on the frame it occurs, reading FocusedButton written by the previous tick's PostUpdate. Two new tests pin the contract: - primary_button_is_focused_on_modal_spawn_same_frame uses a production-shaped spawner system (no chain edge to UiFocusPlugin) and asserts FocusedButton.0 is Some after a single update — fails without the fix, passes with it. - first_tab_after_modal_open_advances_to_secondary guards against a regression where focus arrives but the very first Tab moves from None to primary instead of from primary to secondary. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1526 lines
57 KiB
Rust
1526 lines
57 KiB
Rust
//! Keyboard focus ring for modal buttons (Phase 1).
|
|
//!
|
|
//! Solitaire Quest'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<Entity>);
|
|
|
|
/// 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::<FocusedButton>()
|
|
.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<Time>,
|
|
settings: Option<Res<SettingsResource>>,
|
|
focused: Res<FocusedButton>,
|
|
mut overlay: Query<&mut BorderColor, With<FocusOverlay>>,
|
|
) {
|
|
let Ok(mut border) = overlay.single_mut() else {
|
|
return;
|
|
};
|
|
|
|
let instant = settings
|
|
.as_deref()
|
|
.is_some_and(|s| matches!(s.0.animation_speed, AnimSpeed::Instant));
|
|
|
|
let factor = if instant || focused.0.is_none() {
|
|
1.0
|
|
} else {
|
|
focus_ring_pulse_factor(time.elapsed_secs())
|
|
};
|
|
|
|
let mut colour = FOCUS_RING;
|
|
colour.set_alpha(FOCUS_RING.alpha() * factor);
|
|
*border = BorderColor::all(colour);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Private marker for the single overlay entity
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Marker on the singleton overlay entity. Spawned once at startup;
|
|
/// repositioned every frame to track the focused button.
|
|
#[derive(Component, Debug)]
|
|
struct FocusOverlay;
|
|
|
|
/// Padding (logical px) added around the focused button's bounding box.
|
|
/// 4 px on every edge — enough to clear the button's own border without
|
|
/// crowding adjacent content.
|
|
const FOCUS_OVERLAY_PADDING: f32 = 4.0;
|
|
|
|
/// Width of the focus ring border in logical pixels.
|
|
const FOCUS_OVERLAY_BORDER: f32 = 2.0;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Systems
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Spawns the singleton focus-overlay entity. Hidden until a button is
|
|
/// focused; its `Node` is repositioned each frame by
|
|
/// [`update_focus_overlay`].
|
|
fn spawn_focus_overlay(mut commands: Commands) {
|
|
commands.spawn((
|
|
FocusOverlay,
|
|
Node {
|
|
position_type: PositionType::Absolute,
|
|
left: Val::Px(0.0),
|
|
top: Val::Px(0.0),
|
|
width: Val::Px(0.0),
|
|
height: Val::Px(0.0),
|
|
border: UiRect::all(Val::Px(FOCUS_OVERLAY_BORDER)),
|
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
|
..default()
|
|
},
|
|
// No background — only the border is visible, so the focused
|
|
// button itself stays fully readable underneath.
|
|
BackgroundColor(Color::NONE),
|
|
BorderColor::all(FOCUS_RING),
|
|
Visibility::Hidden,
|
|
// Pin above every modal layer so the ring is never occluded by
|
|
// a card's hover / press recolour.
|
|
GlobalZIndex(Z_FOCUS_RING),
|
|
));
|
|
}
|
|
|
|
/// Attaches a [`Focusable`] to any [`ModalButton`] that doesn't yet
|
|
/// carry one. This is the **zero-churn path**: existing modal plugins
|
|
/// don't need any code changes — they get keyboard focus for free as
|
|
/// soon as `UiFocusPlugin` is registered. Walks ancestors via
|
|
/// [`ChildOf`] to find the [`ModalScrim`] that owns the button so the
|
|
/// new [`Focusable`]'s group is bound to that specific scrim.
|
|
fn attach_focusable_to_modal_buttons(
|
|
mut commands: Commands,
|
|
new_buttons: Query<Entity, (With<ModalButton>, Without<Focusable>)>,
|
|
parents: Query<&ChildOf>,
|
|
scrims: Query<(), With<ModalScrim>>,
|
|
) {
|
|
for button in &new_buttons {
|
|
// Walk ancestors until we find the ModalScrim. Bounded loop so
|
|
// a malformed hierarchy can't hang the system.
|
|
let mut current = button;
|
|
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,
|
|
}
|
|
}
|
|
if let Some(scrim) = scrim_entity {
|
|
commands.entity(button).insert(Focusable {
|
|
group: FocusGroup::Modal(scrim),
|
|
order: 0,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Auto-focuses a modal's Primary button (or the first focusable in
|
|
/// spawn order if no Primary exists) the frame it appears. Triggered
|
|
/// by `Added<ModalScrim>` so it fires exactly once per modal.
|
|
///
|
|
/// `Added<ModalScrim>` is preferred over `Added<ModalEntering>` because
|
|
/// the entering animation may already have been removed on the same
|
|
/// tick under `AnimSpeed::Instant`; the scrim itself lives for the
|
|
/// modal's full lifetime.
|
|
fn auto_focus_on_modal_open(
|
|
new_scrims: Query<Entity, Added<ModalScrim>>,
|
|
children_q: Query<&Children>,
|
|
buttons: Query<(&ModalButton, Has<Disabled>), With<Focusable>>,
|
|
mut focused: ResMut<FocusedButton>,
|
|
) {
|
|
for scrim in &new_scrims {
|
|
let mut primary: Option<Entity> = None;
|
|
let mut first: Option<Entity> = None;
|
|
|
|
// Recursive descendants walk — collected via a small stack so
|
|
// we don't need a depth-bounded recursive function.
|
|
let mut stack: Vec<Entity> = vec![scrim];
|
|
while let Some(entity) = stack.pop() {
|
|
if let Ok((button, disabled)) = buttons.get(entity)
|
|
&& !disabled
|
|
{
|
|
if first.is_none() {
|
|
first = Some(entity);
|
|
}
|
|
if button.0 == ButtonVariant::Primary && primary.is_none() {
|
|
primary = Some(entity);
|
|
}
|
|
}
|
|
if let Ok(children) = children_q.get(entity) {
|
|
for child in children.iter() {
|
|
stack.push(child);
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(target) = primary.or(first) {
|
|
focused.0 = Some(target);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Mouse click on a focusable transfers keyboard focus to it. The
|
|
/// existing click handler still fires; this just keeps the keyboard
|
|
/// ring in sync so a Tab afterwards advances from the clicked button.
|
|
#[allow(clippy::type_complexity)]
|
|
fn sync_focus_on_mouse_click(
|
|
interactions: Query<
|
|
(Entity, &Interaction, Has<Disabled>),
|
|
(Changed<Interaction>, With<Focusable>),
|
|
>,
|
|
mut focused: ResMut<FocusedButton>,
|
|
) {
|
|
for (entity, interaction, disabled) in &interactions {
|
|
if disabled {
|
|
continue;
|
|
}
|
|
if matches!(interaction, Interaction::Pressed) {
|
|
focused.0 = Some(entity);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// 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<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, clippy::too_many_arguments)]
|
|
fn handle_focus_keys(
|
|
mut keys: ResMut<ButtonInput<KeyCode>>,
|
|
scrims: Query<Entity, With<ModalScrim>>,
|
|
children_q: Query<&Children>,
|
|
parents_q: Query<&ChildOf>,
|
|
rows: Query<(), With<FocusRow>>,
|
|
focusables: Query<(&Focusable, Has<Disabled>)>,
|
|
hud_interactions: Query<(Entity, &Interaction, &Focusable), Without<Disabled>>,
|
|
mut focused: ResMut<FocusedButton>,
|
|
mut writes: Commands,
|
|
) {
|
|
// Arrow-key navigation inside a `FocusRow` is a separate, scoped
|
|
// path that must run before the Tab / activation logic so a focused
|
|
// swatch responds to Left / Right without falling through to the
|
|
// group cycle. Only acts when the currently-focused entity's direct
|
|
// parent carries `FocusRow`; otherwise the keys are a no-op
|
|
// (explicit semantics — we don't want Left/Right doubling as Tab).
|
|
let arrow_left = keys.just_pressed(KeyCode::ArrowLeft);
|
|
let arrow_right = keys.just_pressed(KeyCode::ArrowRight);
|
|
if (arrow_left || arrow_right)
|
|
&& let Some(target) = focused.0
|
|
&& let Ok(parent) = parents_q.get(target)
|
|
&& rows.get(parent.parent()).is_ok()
|
|
&& let Ok(siblings) = children_q.get(parent.parent())
|
|
{
|
|
// Build the row's enabled-focusable cycle in Children order so
|
|
// it matches the visual left → right layout.
|
|
let row_cycle: Vec<Entity> = siblings
|
|
.iter()
|
|
.filter(|e| {
|
|
focusables
|
|
.get(*e)
|
|
.is_ok_and(|(_, disabled)| !disabled)
|
|
})
|
|
.collect();
|
|
if !row_cycle.is_empty()
|
|
&& let Some(idx) = row_cycle.iter().position(|e| *e == target)
|
|
{
|
|
let n = row_cycle.len();
|
|
let next = if arrow_right {
|
|
(idx + 1) % n
|
|
} else {
|
|
(idx + n - 1) % n
|
|
};
|
|
focused.0 = Some(row_cycle[next]);
|
|
}
|
|
// Always consume the arrow key when we engage — even if the
|
|
// cycle was empty — so downstream systems don't double-handle.
|
|
if arrow_left {
|
|
keys.clear_just_pressed(KeyCode::ArrowLeft);
|
|
}
|
|
if arrow_right {
|
|
keys.clear_just_pressed(KeyCode::ArrowRight);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 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 =
|
|
keys.just_pressed(KeyCode::Enter) || keys.just_pressed(KeyCode::Space);
|
|
|
|
if !tab_pressed && !activate_pressed {
|
|
return;
|
|
}
|
|
|
|
// Build the cycle list for the active group.
|
|
let mut group: Vec<Entity> = 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<Entity> = Vec::new();
|
|
let mut stack: Vec<Entity> = 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::<Vec<_>>().into_iter().rev() {
|
|
stack.push(child);
|
|
}
|
|
}
|
|
if let Ok((focusable, disabled)) = focusables.get(entity)
|
|
&& !disabled
|
|
&& focusable.group == active_group
|
|
{
|
|
found.push(entity);
|
|
}
|
|
}
|
|
found
|
|
}
|
|
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<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` 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).map_or(i32::MAX, |(f, _)| f.order));
|
|
|
|
if group.is_empty() {
|
|
// Still consume the key so the card-selection plugin doesn't
|
|
// treat Tab as a pile cycle while a (button-less) modal is
|
|
// open. Without this guard, opening an information-only modal
|
|
// would accidentally let Tab navigate the table behind it.
|
|
if tab_pressed {
|
|
keys.clear_just_pressed(KeyCode::Tab);
|
|
}
|
|
if activate_pressed {
|
|
keys.clear_just_pressed(KeyCode::Enter);
|
|
keys.clear_just_pressed(KeyCode::Space);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if tab_pressed {
|
|
let backwards = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
|
|
let current_idx = focused.0.and_then(|e| group.iter().position(|g| *g == e));
|
|
let n = group.len();
|
|
let next_idx = match current_idx {
|
|
Some(i) if backwards => (i + n - 1) % n,
|
|
Some(i) => (i + 1) % n,
|
|
None if backwards => n - 1,
|
|
None => 0,
|
|
};
|
|
focused.0 = Some(group[next_idx]);
|
|
keys.clear_just_pressed(KeyCode::Tab);
|
|
}
|
|
|
|
if activate_pressed {
|
|
if let Some(target) = focused.0
|
|
&& focusables.get(target).is_ok()
|
|
{
|
|
// Write `Interaction::Pressed` so the existing click
|
|
// handlers (`Changed<Interaction>` queries on
|
|
// `Interaction::Pressed`) fire on the next system run.
|
|
writes.entity(target).insert(Interaction::Pressed);
|
|
}
|
|
keys.clear_just_pressed(KeyCode::Enter);
|
|
keys.clear_just_pressed(KeyCode::Space);
|
|
}
|
|
}
|
|
|
|
/// Repositions the focus-overlay entity each frame to wrap the
|
|
/// focused button. Reads the focused button's `UiGlobalTransform`
|
|
/// (window-space center, physical pixels) and `ComputedNode.size`
|
|
/// (physical pixels), converts to logical pixels via
|
|
/// `inverse_scale_factor`, and writes the result into the overlay's
|
|
/// absolute `Node` position + size.
|
|
///
|
|
/// If the focused entity has been despawned (e.g. its modal closed)
|
|
/// or no button is focused at all, the overlay is hidden and
|
|
/// `FocusedButton` is cleared — keeps the resource self-healing
|
|
/// without needing a `RemovedComponents` hook.
|
|
fn update_focus_overlay(
|
|
mut focused: ResMut<FocusedButton>,
|
|
targets: Query<(&UiGlobalTransform, &ComputedNode), With<Focusable>>,
|
|
mut overlay: Query<(&mut Node, &mut Visibility), With<FocusOverlay>>,
|
|
) {
|
|
let Ok((mut node, mut visibility)) = overlay.single_mut() else {
|
|
// Overlay entity not yet spawned (first frame before Startup
|
|
// ran, or running under a test harness that didn't include
|
|
// `Startup`). Nothing to do.
|
|
return;
|
|
};
|
|
|
|
let Some(target) = focused.0 else {
|
|
if !matches!(*visibility, Visibility::Hidden) {
|
|
*visibility = Visibility::Hidden;
|
|
}
|
|
return;
|
|
};
|
|
|
|
let Ok((transform, computed)) = targets.get(target) else {
|
|
// Focused entity disappeared (e.g. modal despawned). Clear
|
|
// the resource and hide the overlay so the next modal open
|
|
// gets a clean slate.
|
|
focused.0 = None;
|
|
if !matches!(*visibility, Visibility::Hidden) {
|
|
*visibility = Visibility::Hidden;
|
|
}
|
|
return;
|
|
};
|
|
|
|
// ComputedNode.size is in physical pixels; inverse_scale_factor
|
|
// multiplies physical → logical. The overlay's Val::Px values are
|
|
// logical pixels (matching every other Node in the engine), so we
|
|
// convert before writing.
|
|
let inv = computed.inverse_scale_factor;
|
|
let size_logical = computed.size() * inv;
|
|
let center_logical = transform.translation * inv;
|
|
|
|
let half = size_logical * 0.5;
|
|
let pad = FOCUS_OVERLAY_PADDING;
|
|
|
|
let left = center_logical.x - half.x - pad;
|
|
let top = center_logical.y - half.y - pad;
|
|
let width = size_logical.x + pad * 2.0;
|
|
let height = size_logical.y + pad * 2.0;
|
|
|
|
node.left = Val::Px(left);
|
|
node.top = Val::Px(top);
|
|
node.width = Val::Px(width);
|
|
node.height = Val::Px(height);
|
|
|
|
if !matches!(*visibility, Visibility::Visible) {
|
|
*visibility = Visibility::Visible;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::ui_modal::{
|
|
spawn_modal, spawn_modal_actions, spawn_modal_button, ButtonVariant, UiModalPlugin,
|
|
};
|
|
|
|
#[test]
|
|
fn focus_ring_pulse_factor_at_zero_is_mid_point() {
|
|
// sin(0) = 0 → factor = 0.825 (mid of [0.65, 1.0]).
|
|
let f = focus_ring_pulse_factor(0.0);
|
|
assert!((f - 0.825).abs() < 1e-5, "factor at t=0 should be 0.825, got {f}");
|
|
}
|
|
|
|
#[test]
|
|
fn focus_ring_pulse_factor_peaks_at_quarter_period() {
|
|
// sin(τ/4) = 1 → factor = 1.0.
|
|
let f = focus_ring_pulse_factor(MOTION_FOCUS_PULSE_SECS / 4.0);
|
|
assert!((f - 1.0).abs() < 1e-4, "factor at peak should be 1.0, got {f}");
|
|
}
|
|
|
|
#[test]
|
|
fn focus_ring_pulse_factor_troughs_at_three_quarter_period() {
|
|
// sin(3τ/4) = -1 → factor = 0.65.
|
|
let f = focus_ring_pulse_factor(MOTION_FOCUS_PULSE_SECS * 3.0 / 4.0);
|
|
assert!((f - 0.65).abs() < 1e-4, "factor at trough should be 0.65, got {f}");
|
|
}
|
|
|
|
#[test]
|
|
fn focus_ring_pulse_factor_stays_in_brightness_range() {
|
|
// Sweep across two full periods; factor must stay within [0.65, 1.0].
|
|
for i in 0..200 {
|
|
let t = i as f32 * MOTION_FOCUS_PULSE_SECS * 0.01;
|
|
let f = focus_ring_pulse_factor(t);
|
|
assert!(
|
|
(0.649..=1.001).contains(&f),
|
|
"factor at t={t} out of range: {f}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Plugin-marker for the synthetic test modal — `spawn_modal`
|
|
/// requires a `Component` on the scrim.
|
|
#[derive(Component, Debug)]
|
|
struct TestModal;
|
|
|
|
/// Marker on each test button so per-button assertions can target
|
|
/// the right entity.
|
|
#[derive(Component, Debug)]
|
|
struct TestButtonA;
|
|
#[derive(Component, Debug)]
|
|
struct TestButtonB;
|
|
#[derive(Component, Debug)]
|
|
struct TestButtonC;
|
|
|
|
fn headless_app() -> App {
|
|
let mut app = App::new();
|
|
app.add_plugins(MinimalPlugins)
|
|
.add_plugins(UiModalPlugin)
|
|
.add_plugins(UiFocusPlugin);
|
|
app.init_resource::<ButtonInput<KeyCode>>();
|
|
// Run Startup so `spawn_focus_overlay` has executed before the
|
|
// first asserting `update`.
|
|
app.update();
|
|
app
|
|
}
|
|
|
|
/// Spawns a 2-button modal (Primary "A" + Secondary "B"). Returns
|
|
/// (scrim, button_a, button_b). Buttons are looked up after the
|
|
/// `attach_focusable_to_modal_buttons` system has run by querying
|
|
/// the marker components.
|
|
fn spawn_two_button_modal(app: &mut App) -> (Entity, Entity, Entity) {
|
|
let scrim = {
|
|
let world = app.world_mut();
|
|
let mut commands = world.commands();
|
|
let id = spawn_modal(&mut commands, TestModal, 0, |card| {
|
|
spawn_modal_actions(card, |actions| {
|
|
spawn_modal_button(
|
|
actions,
|
|
TestButtonB,
|
|
"B",
|
|
None,
|
|
ButtonVariant::Secondary,
|
|
None,
|
|
);
|
|
spawn_modal_button(
|
|
actions,
|
|
TestButtonA,
|
|
"A",
|
|
None,
|
|
ButtonVariant::Primary,
|
|
None,
|
|
);
|
|
});
|
|
});
|
|
world.flush();
|
|
id
|
|
};
|
|
// Run one tick so `attach_focusable_to_modal_buttons` and
|
|
// `auto_focus_on_modal_open` execute.
|
|
app.update();
|
|
|
|
let mut a_query = app.world_mut().query_filtered::<Entity, With<TestButtonA>>();
|
|
let a = a_query
|
|
.iter(app.world())
|
|
.next()
|
|
.expect("button A should have been spawned");
|
|
let mut b_query = app.world_mut().query_filtered::<Entity, With<TestButtonB>>();
|
|
let b = b_query
|
|
.iter(app.world())
|
|
.next()
|
|
.expect("button B should have been spawned");
|
|
(scrim, a, b)
|
|
}
|
|
|
|
/// Spawns a 3-button modal (A primary, B secondary, C tertiary, in
|
|
/// that spawn order) so Tab cycle order can be observed.
|
|
fn spawn_three_button_modal(app: &mut App) -> (Entity, Entity, Entity, Entity) {
|
|
let scrim = {
|
|
let world = app.world_mut();
|
|
let mut commands = world.commands();
|
|
let id = spawn_modal(&mut commands, TestModal, 0, |card| {
|
|
spawn_modal_actions(card, |actions| {
|
|
spawn_modal_button(
|
|
actions,
|
|
TestButtonA,
|
|
"A",
|
|
None,
|
|
ButtonVariant::Primary,
|
|
None,
|
|
);
|
|
spawn_modal_button(
|
|
actions,
|
|
TestButtonB,
|
|
"B",
|
|
None,
|
|
ButtonVariant::Secondary,
|
|
None,
|
|
);
|
|
spawn_modal_button(
|
|
actions,
|
|
TestButtonC,
|
|
"C",
|
|
None,
|
|
ButtonVariant::Tertiary,
|
|
None,
|
|
);
|
|
});
|
|
});
|
|
world.flush();
|
|
id
|
|
};
|
|
app.update();
|
|
|
|
let mut q_a = app.world_mut().query_filtered::<Entity, With<TestButtonA>>();
|
|
let a = q_a.iter(app.world()).next().expect("A spawned");
|
|
let mut q_b = app.world_mut().query_filtered::<Entity, With<TestButtonB>>();
|
|
let b = q_b.iter(app.world()).next().expect("B spawned");
|
|
let mut q_c = app.world_mut().query_filtered::<Entity, With<TestButtonC>>();
|
|
let c = q_c.iter(app.world()).next().expect("C spawned");
|
|
(scrim, a, b, c)
|
|
}
|
|
|
|
/// Drives a fresh `just_pressed` event for `key`.
|
|
///
|
|
/// `ButtonInput::press` is a no-op for `just_pressed` if the key is
|
|
/// already in `pressed` — and `MinimalPlugins` doesn't run the
|
|
/// frame tick that drains `pressed`, so a second call would be
|
|
/// silent. Releasing first, clearing the just-pressed/released
|
|
/// flags, then pressing reproduces a real keystroke per frame.
|
|
fn press_key(app: &mut App, key: KeyCode) {
|
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
|
input.release_all();
|
|
input.clear();
|
|
input.press(key);
|
|
}
|
|
|
|
/// Same as [`press_key`] but for chord-style multi-key presses
|
|
/// (e.g. Shift+Tab). All keys land as `just_pressed` on the next
|
|
/// system tick.
|
|
fn press_keys(app: &mut App, keys: &[KeyCode]) {
|
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
|
input.release_all();
|
|
input.clear();
|
|
for k in keys {
|
|
input.press(*k);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn auto_focus_picks_primary_on_modal_open() {
|
|
let mut app = headless_app();
|
|
let (_scrim, a, _b) = spawn_two_button_modal(&mut app);
|
|
let focused = app.world().resource::<FocusedButton>().0;
|
|
assert_eq!(focused, Some(a), "Primary button A should auto-focus");
|
|
}
|
|
|
|
/// One-shot trigger resource consumed by the production-shaped test
|
|
/// system [`spawn_modal_via_system`]. When set to `true`, the system
|
|
/// queues a `spawn_modal` call on the next `Update` and clears the
|
|
/// flag. Mirrors the real production flow where a click-handler
|
|
/// system queues the modal spawn via `Commands` rather than the
|
|
/// test fixture using `world.flush()` ahead of time.
|
|
#[derive(Resource, Default)]
|
|
struct SpawnModalTrigger(bool);
|
|
|
|
/// Production-shaped modal spawner: a regular Bevy `System` that
|
|
/// reads a trigger flag and queues a 2-button modal via `Commands`.
|
|
/// Crucially this system has **no** ordering relationship with
|
|
/// `UiFocusPlugin`'s chain — exactly the situation that surfaces the
|
|
/// "focus arrives one frame late" bug in production.
|
|
fn spawn_modal_via_system(
|
|
mut commands: Commands,
|
|
mut trigger: ResMut<SpawnModalTrigger>,
|
|
) {
|
|
if !trigger.0 {
|
|
return;
|
|
}
|
|
trigger.0 = false;
|
|
spawn_modal(&mut commands, TestModal, 0, |card| {
|
|
spawn_modal_actions(card, |actions| {
|
|
spawn_modal_button(
|
|
actions,
|
|
TestButtonB,
|
|
"B",
|
|
None,
|
|
ButtonVariant::Secondary,
|
|
None,
|
|
);
|
|
spawn_modal_button(
|
|
actions,
|
|
TestButtonA,
|
|
"A",
|
|
None,
|
|
ButtonVariant::Primary,
|
|
None,
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
/// Same-frame-focus contract: when a modal is spawned by an
|
|
/// independent system during the same `Update` as the focus chain,
|
|
/// `FocusedButton` must be populated with the primary button by the
|
|
/// time `handle_focus_keys` runs in that **same** update — so a Tab
|
|
/// pressed in the very next tick advances focus rather than
|
|
/// landing on "nothing focused → primary".
|
|
#[test]
|
|
fn primary_button_is_focused_on_modal_spawn_same_frame() {
|
|
let mut app = App::new();
|
|
app.add_plugins(MinimalPlugins)
|
|
.add_plugins(UiModalPlugin)
|
|
.add_plugins(UiFocusPlugin);
|
|
app.init_resource::<ButtonInput<KeyCode>>();
|
|
app.init_resource::<SpawnModalTrigger>();
|
|
// Register the production-shaped spawn system in `Update` with
|
|
// no chain relationship to `UiFocusPlugin`.
|
|
app.add_systems(Update, spawn_modal_via_system);
|
|
// Initial Startup pass.
|
|
app.update();
|
|
|
|
// Trigger the spawn and run exactly ONE update — the same
|
|
// `Update` cycle that the focus chain runs in. By the end of
|
|
// this update, `FocusedButton` must already point at the
|
|
// primary button.
|
|
app.world_mut().resource_mut::<SpawnModalTrigger>().0 = true;
|
|
app.update();
|
|
|
|
let primary = app
|
|
.world_mut()
|
|
.query_filtered::<Entity, With<TestButtonA>>()
|
|
.iter(app.world())
|
|
.next()
|
|
.expect("Primary button should exist after the spawn update");
|
|
|
|
assert_eq!(
|
|
app.world().resource::<FocusedButton>().0,
|
|
Some(primary),
|
|
"FocusedButton must be populated with the primary on the same frame the modal spawns"
|
|
);
|
|
}
|
|
|
|
/// Tab pressed on the very next tick after a modal opens must
|
|
/// advance focus from the primary to the secondary — not from
|
|
/// "nothing focused" to the primary. The latter would mean focus
|
|
/// arrived a frame late and Tab was wasted on first-focus instead
|
|
/// of advancing.
|
|
#[test]
|
|
fn first_tab_after_modal_open_advances_to_secondary() {
|
|
let mut app = App::new();
|
|
app.add_plugins(MinimalPlugins)
|
|
.add_plugins(UiModalPlugin)
|
|
.add_plugins(UiFocusPlugin);
|
|
app.init_resource::<ButtonInput<KeyCode>>();
|
|
app.init_resource::<SpawnModalTrigger>();
|
|
app.add_systems(Update, spawn_modal_via_system);
|
|
app.update();
|
|
|
|
// Spawn the modal in update N.
|
|
app.world_mut().resource_mut::<SpawnModalTrigger>().0 = true;
|
|
app.update();
|
|
|
|
// Press Tab on update N+1. If focus arrived correctly in N,
|
|
// Tab advances primary → secondary. If focus arrived late,
|
|
// Tab promotes "no focus" to primary (the bug).
|
|
let primary = app
|
|
.world_mut()
|
|
.query_filtered::<Entity, With<TestButtonA>>()
|
|
.iter(app.world())
|
|
.next()
|
|
.expect("primary spawned");
|
|
let secondary = app
|
|
.world_mut()
|
|
.query_filtered::<Entity, With<TestButtonB>>()
|
|
.iter(app.world())
|
|
.next()
|
|
.expect("secondary spawned");
|
|
|
|
press_key(&mut app, KeyCode::Tab);
|
|
app.update();
|
|
|
|
let focused_after_tab = app.world().resource::<FocusedButton>().0;
|
|
assert_ne!(
|
|
focused_after_tab,
|
|
Some(primary),
|
|
"first Tab after modal open should advance off the primary, not land on it (focus arrived late)"
|
|
);
|
|
assert_eq!(
|
|
focused_after_tab,
|
|
Some(secondary),
|
|
"first Tab from primary should land on the secondary"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn tab_advances_focus_in_spawn_order() {
|
|
let mut app = headless_app();
|
|
let (_scrim, a, b, c) = spawn_three_button_modal(&mut app);
|
|
|
|
// Auto-focused on A (primary).
|
|
assert_eq!(app.world().resource::<FocusedButton>().0, Some(a));
|
|
|
|
// Tab → B (next in spawn order after A).
|
|
press_key(&mut app, KeyCode::Tab);
|
|
app.update();
|
|
assert_eq!(app.world().resource::<FocusedButton>().0, Some(b));
|
|
|
|
// Tab → C.
|
|
press_key(&mut app, KeyCode::Tab);
|
|
app.update();
|
|
assert_eq!(app.world().resource::<FocusedButton>().0, Some(c));
|
|
|
|
// Tab wraps back to A.
|
|
press_key(&mut app, KeyCode::Tab);
|
|
app.update();
|
|
assert_eq!(app.world().resource::<FocusedButton>().0, Some(a));
|
|
}
|
|
|
|
#[test]
|
|
fn shift_tab_reverses() {
|
|
let mut app = headless_app();
|
|
let (_scrim, a, b, c) = spawn_three_button_modal(&mut app);
|
|
assert_eq!(app.world().resource::<FocusedButton>().0, Some(a));
|
|
|
|
// Shift+Tab from A wraps backward to C.
|
|
press_keys(&mut app, &[KeyCode::ShiftLeft, KeyCode::Tab]);
|
|
app.update();
|
|
assert_eq!(app.world().resource::<FocusedButton>().0, Some(c));
|
|
|
|
// Shift+Tab from C → B.
|
|
press_keys(&mut app, &[KeyCode::ShiftLeft, KeyCode::Tab]);
|
|
app.update();
|
|
assert_eq!(app.world().resource::<FocusedButton>().0, Some(b));
|
|
}
|
|
|
|
#[test]
|
|
fn enter_activates_focused_button() {
|
|
let mut app = headless_app();
|
|
let (_scrim, a, _b) = spawn_two_button_modal(&mut app);
|
|
assert_eq!(app.world().resource::<FocusedButton>().0, Some(a));
|
|
|
|
// Confirm the focused button is NOT pressed before Enter — the
|
|
// baseline state matters because we're going to assert the
|
|
// post-Enter component value, not a count delta.
|
|
let pre = app.world().entity(a).get::<Interaction>().copied();
|
|
assert_ne!(
|
|
pre,
|
|
Some(Interaction::Pressed),
|
|
"focused button should not be pressed before Enter"
|
|
);
|
|
|
|
press_key(&mut app, KeyCode::Enter);
|
|
app.update();
|
|
|
|
// After Enter, `handle_focus_keys` inserts `Interaction::Pressed`
|
|
// on the focused button so existing click handlers
|
|
// (`Changed<Interaction>` queries matching `Pressed`) fire on
|
|
// the next system tick — exactly the same signal a real mouse
|
|
// click produces.
|
|
let post = app
|
|
.world()
|
|
.entity(a)
|
|
.get::<Interaction>()
|
|
.copied()
|
|
.expect("focused button should carry an Interaction after activation");
|
|
assert_eq!(
|
|
post,
|
|
Interaction::Pressed,
|
|
"Enter on focused button A should leave its Interaction at Pressed"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn focus_clears_when_modal_despawns() {
|
|
let mut app = headless_app();
|
|
let (scrim, a, _b) = spawn_two_button_modal(&mut app);
|
|
assert_eq!(app.world().resource::<FocusedButton>().0, Some(a));
|
|
|
|
// Despawn the scrim — Bevy's hierarchy cascade despawns the
|
|
// card and every button under it.
|
|
app.world_mut().entity_mut(scrim).despawn();
|
|
app.update();
|
|
|
|
assert!(
|
|
app.world().resource::<FocusedButton>().0.is_none(),
|
|
"FocusedButton should clear once the focused entity is despawned"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn focus_overlay_visible_when_focus_set() {
|
|
let mut app = headless_app();
|
|
let (_scrim, _a, _b) = spawn_two_button_modal(&mut app);
|
|
// One more update so `update_focus_overlay` runs *after* the
|
|
// auto-focus side-effect and writes a non-Hidden Visibility.
|
|
app.update();
|
|
|
|
let mut q = app
|
|
.world_mut()
|
|
.query_filtered::<&Visibility, With<FocusOverlay>>();
|
|
let v = q
|
|
.iter(app.world())
|
|
.next()
|
|
.expect("FocusOverlay singleton should exist");
|
|
assert!(
|
|
matches!(v, Visibility::Visible),
|
|
"overlay should be visible while a button has focus, got {v:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mouse_click_transfers_focus() {
|
|
let mut app = headless_app();
|
|
let (_scrim, a, b) = spawn_two_button_modal(&mut app);
|
|
assert_eq!(app.world().resource::<FocusedButton>().0, Some(a));
|
|
|
|
// Simulate a real click landing on B by directly inserting
|
|
// `Interaction::Pressed` — the same write `bevy_ui::focus`
|
|
// would emit on a real mouse press.
|
|
app.world_mut().entity_mut(b).insert(Interaction::Pressed);
|
|
app.update();
|
|
|
|
assert_eq!(
|
|
app.world().resource::<FocusedButton>().0,
|
|
Some(b),
|
|
"mouse-pressed focusable should take focus"
|
|
);
|
|
}
|
|
|
|
/// Tab is consumed by `handle_focus_keys` while a modal is open,
|
|
/// so a downstream system reading `ButtonInput<KeyCode>` (e.g.
|
|
/// `selection_plugin::handle_selection_keys`) sees no Tab press.
|
|
/// Verifies the simpler invariant from the brief: the key is no
|
|
/// longer `just_pressed` after the focus system runs.
|
|
#[test]
|
|
fn selection_plugin_does_not_see_tab_when_modal_is_open() {
|
|
let mut app = headless_app();
|
|
let (_scrim, _a, _b) = spawn_two_button_modal(&mut app);
|
|
|
|
press_key(&mut app, KeyCode::Tab);
|
|
app.update();
|
|
|
|
let keys = app.world().resource::<ButtonInput<KeyCode>>();
|
|
assert!(
|
|
!keys.just_pressed(KeyCode::Tab),
|
|
"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"
|
|
);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Phase 3 — FocusRow arrow-key navigation
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// Spawns a synthetic modal scrim with a single [`FocusRow`] parent
|
|
/// containing three focusable swatches (A, B, C) bound to the scrim.
|
|
/// Returns `(scrim, row, a, b, c)`. No real `ModalScrim` ancestry —
|
|
/// just a `ModalScrim` marker on the scrim entity so the active-group
|
|
/// resolver in `handle_focus_keys` picks it up.
|
|
fn spawn_modal_with_focus_row(app: &mut App) -> (Entity, Entity, Entity, Entity, Entity) {
|
|
let world = app.world_mut();
|
|
let scrim = world.spawn((ModalScrim, Node::default())).id();
|
|
let row = world.spawn((FocusRow, Node::default())).id();
|
|
world.entity_mut(scrim).add_child(row);
|
|
|
|
let make_swatch = |w: &mut World, marker: fn(&mut bevy::ecs::world::EntityWorldMut)| {
|
|
let mut e = w.spawn((
|
|
Button,
|
|
Node::default(),
|
|
Interaction::default(),
|
|
Focusable {
|
|
group: FocusGroup::Modal(scrim),
|
|
order: 0,
|
|
},
|
|
));
|
|
marker(&mut e);
|
|
e.id()
|
|
};
|
|
let a = make_swatch(world, |e| {
|
|
e.insert(TestButtonA);
|
|
});
|
|
let b = make_swatch(world, |e| {
|
|
e.insert(TestButtonB);
|
|
});
|
|
let c = make_swatch(world, |e| {
|
|
e.insert(TestButtonC);
|
|
});
|
|
for child in [a, b, c] {
|
|
world.entity_mut(row).add_child(child);
|
|
}
|
|
// One tick so the focus systems observe the new hierarchy.
|
|
app.update();
|
|
(scrim, row, a, b, c)
|
|
}
|
|
|
|
#[test]
|
|
fn arrow_right_advances_focus_within_focus_row() {
|
|
let mut app = headless_app();
|
|
let (_scrim, _row, a, b, _c) = spawn_modal_with_focus_row(&mut app);
|
|
|
|
// Focus child A explicitly so we know the starting state.
|
|
app.world_mut().resource_mut::<FocusedButton>().0 = Some(a);
|
|
|
|
press_key(&mut app, KeyCode::ArrowRight);
|
|
app.update();
|
|
|
|
assert_eq!(
|
|
app.world().resource::<FocusedButton>().0,
|
|
Some(b),
|
|
"ArrowRight should advance focus from A → B inside the row"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn arrow_left_at_first_wraps_to_last() {
|
|
let mut app = headless_app();
|
|
let (_scrim, _row, a, _b, c) = spawn_modal_with_focus_row(&mut app);
|
|
|
|
app.world_mut().resource_mut::<FocusedButton>().0 = Some(a);
|
|
|
|
press_key(&mut app, KeyCode::ArrowLeft);
|
|
app.update();
|
|
|
|
assert_eq!(
|
|
app.world().resource::<FocusedButton>().0,
|
|
Some(c),
|
|
"ArrowLeft from the first child must wrap to the last"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn arrow_keys_outside_focus_row_are_noop() {
|
|
let mut app = headless_app();
|
|
// Modal with two buttons, but no FocusRow — the standard 2-button
|
|
// modal fixture is exactly this shape.
|
|
let (_scrim, a, _b) = spawn_two_button_modal(&mut app);
|
|
// Auto-focus picked Primary (A). Arrow keys must not change it.
|
|
assert_eq!(app.world().resource::<FocusedButton>().0, Some(a));
|
|
|
|
press_key(&mut app, KeyCode::ArrowRight);
|
|
app.update();
|
|
|
|
assert_eq!(
|
|
app.world().resource::<FocusedButton>().0,
|
|
Some(a),
|
|
"ArrowRight outside a FocusRow must leave focus unchanged"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn tab_escapes_focus_row_to_next_section() {
|
|
// Build a synthetic modal with two FocusRows of two children
|
|
// each — first row with order=0 children, second row with
|
|
// order=10 — then focus the last child of row 1 and press Tab.
|
|
// The cycle must advance into row 2 rather than wrap back inside
|
|
// row 1.
|
|
let mut app = headless_app();
|
|
|
|
let (scrim, _row1_a, row1_b, _row2_a, _row2_b) = {
|
|
let world = app.world_mut();
|
|
let scrim = world.spawn((ModalScrim, Node::default())).id();
|
|
let row1 = world.spawn((FocusRow, Node::default())).id();
|
|
let row2 = world.spawn((FocusRow, Node::default())).id();
|
|
world.entity_mut(scrim).add_child(row1);
|
|
world.entity_mut(scrim).add_child(row2);
|
|
|
|
let r1a = world
|
|
.spawn((
|
|
Button,
|
|
Node::default(),
|
|
Interaction::default(),
|
|
Focusable {
|
|
group: FocusGroup::Modal(scrim),
|
|
order: 0,
|
|
},
|
|
))
|
|
.id();
|
|
let r1b = world
|
|
.spawn((
|
|
Button,
|
|
Node::default(),
|
|
Interaction::default(),
|
|
Focusable {
|
|
group: FocusGroup::Modal(scrim),
|
|
order: 0,
|
|
},
|
|
))
|
|
.id();
|
|
let r2a = world
|
|
.spawn((
|
|
Button,
|
|
Node::default(),
|
|
Interaction::default(),
|
|
Focusable {
|
|
group: FocusGroup::Modal(scrim),
|
|
order: 10,
|
|
},
|
|
))
|
|
.id();
|
|
let r2b = world
|
|
.spawn((
|
|
Button,
|
|
Node::default(),
|
|
Interaction::default(),
|
|
Focusable {
|
|
group: FocusGroup::Modal(scrim),
|
|
order: 10,
|
|
},
|
|
))
|
|
.id();
|
|
world.entity_mut(row1).add_child(r1a);
|
|
world.entity_mut(row1).add_child(r1b);
|
|
world.entity_mut(row2).add_child(r2a);
|
|
world.entity_mut(row2).add_child(r2b);
|
|
(scrim, r1a, r1b, r2a, r2b)
|
|
};
|
|
app.update();
|
|
|
|
// Focus the last child of row 1.
|
|
app.world_mut().resource_mut::<FocusedButton>().0 = Some(row1_b);
|
|
|
|
press_key(&mut app, KeyCode::Tab);
|
|
app.update();
|
|
|
|
// After Tab the cycle must move out of row 1 — either to a child
|
|
// of row 2 (preferred behaviour) or, in a wrap, to the first
|
|
// child of row 1. The test enforces the stronger contract:
|
|
// Tab must escape the row so the next focusable is in row 2.
|
|
let focused = app
|
|
.world()
|
|
.resource::<FocusedButton>()
|
|
.0
|
|
.expect("Tab should leave focus on some entity");
|
|
// `focused` must NOT be `row1_b` itself (Tab clearly should advance)
|
|
assert_ne!(focused, row1_b, "Tab must advance off the current focus");
|
|
// And it must be a descendant of row 2's parent (i.e. a Focusable
|
|
// with order >= 10) — our row 1 children all have order 0.
|
|
let order = app
|
|
.world()
|
|
.entity(focused)
|
|
.get::<Focusable>()
|
|
.expect("focused entity must carry Focusable")
|
|
.order;
|
|
assert_eq!(
|
|
order, 10,
|
|
"Tab from the end of row 1 should land in row 2 (order=10), not wrap inside row 1 (order=0); landed on order={order}"
|
|
);
|
|
// Sanity: scrim entity isn't the focus.
|
|
assert_ne!(focused, scrim);
|
|
}
|
|
|
|
#[test]
|
|
fn disabled_swatch_skipped_by_arrow_keys() {
|
|
let mut app = headless_app();
|
|
let (_scrim, _row, a, b, c) = spawn_modal_with_focus_row(&mut app);
|
|
|
|
// Disable the middle swatch.
|
|
app.world_mut().entity_mut(b).insert(Disabled);
|
|
// Focus the first swatch and press Right — should jump over B
|
|
// straight to C.
|
|
app.world_mut().resource_mut::<FocusedButton>().0 = Some(a);
|
|
|
|
press_key(&mut app, KeyCode::ArrowRight);
|
|
app.update();
|
|
|
|
assert_eq!(
|
|
app.world().resource::<FocusedButton>().0,
|
|
Some(c),
|
|
"ArrowRight should skip the Disabled middle swatch and land on C"
|
|
);
|
|
}
|
|
}
|