feat(engine): keyboard focus on Settings panel with arrow-key pickers (Phase 3)
Settings was the last mouse-only surface in the engine. Phase 3 closes that gap and finishes the keyboard-focus rollout. Every interactive button in the settings panel — icon buttons (32px volume, draw mode, color blind, sync now), swatch pickers (5 card backs, 5 backgrounds), and toggle pills — now opts into Focusable via a single ancestry-walking system that mirrors the Phase 1/2 pattern. The Done button continues to be auto-tagged through the modal path. The two picker rows gain a new FocusRow marker. Inside a FocusRow, Left/Right arrow keys cycle the swatches (skipping Disabled, wrapping at endpoints) while Tab/Shift-Tab still escape to the next section's focusable. Outside a FocusRow, arrow keys are explicit no-ops. scroll_focus_into_view runs after the focus overlay updates and adjusts the SettingsPanelScrollable container's ScrollPosition when the focused button sits outside the visible viewport, with a SPACE_2 padding so the focus ring never gets clipped at the viewport edge. The system is a no-op when layout hasn't computed yet, so headless tests are unaffected. After Phase 3 every interactive UI element in the engine is keyboard-navigable: modals (Phase 1), HUD action bar and Home mode cards (Phase 2), Settings bespoke controls and picker rows (Phase 3). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ use std::path::PathBuf;
|
||||
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
use bevy::prelude::*;
|
||||
use bevy::ui::{ComputedNode, UiGlobalTransform};
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use solitaire_data::{load_settings_from, save_settings_to, settings_file_path, settings::Theme, AnimSpeed, Settings};
|
||||
|
||||
@@ -20,12 +21,14 @@ use crate::events::{ManualSyncRequestEvent, ToggleSettingsRequestEvent};
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
|
||||
use crate::ui_focus::{FocusGroup, FocusRow, Focusable, FocusedButton};
|
||||
use crate::ui_modal::{
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||
ModalButton, ModalScrim,
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
BG_BASE, BG_ELEVATED_HI, BORDER_SUBTLE, RADIUS_SM, STATE_SUCCESS, TEXT_PRIMARY, TEXT_SECONDARY,
|
||||
TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
|
||||
BG_BASE, BG_ELEVATED_HI, BORDER_SUBTLE, RADIUS_SM, SPACE_2, STATE_SUCCESS, TEXT_PRIMARY,
|
||||
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
|
||||
};
|
||||
|
||||
/// Side length of a swatch button in the card-back / background pickers.
|
||||
@@ -123,6 +126,39 @@ enum SettingsButton {
|
||||
SelectBackground(usize),
|
||||
}
|
||||
|
||||
impl SettingsButton {
|
||||
/// Tab-walk priority — lower numbers visited first. Visual reading
|
||||
/// order is top-to-bottom by section, left-to-right inside each row.
|
||||
/// Two buttons in the same picker row receive the same `order`;
|
||||
/// `handle_focus_keys` then breaks ties by entity index, which
|
||||
/// matches `Children` spawn order inside each row.
|
||||
fn focus_order(&self) -> i32 {
|
||||
match self {
|
||||
// Audio section
|
||||
SettingsButton::SfxDown => 10,
|
||||
SettingsButton::SfxUp => 11,
|
||||
SettingsButton::MusicDown => 20,
|
||||
SettingsButton::MusicUp => 21,
|
||||
// Gameplay section
|
||||
SettingsButton::ToggleDrawMode => 30,
|
||||
SettingsButton::CycleAnimSpeed => 40,
|
||||
// Cosmetic section
|
||||
SettingsButton::ToggleTheme => 50,
|
||||
SettingsButton::ToggleColorBlind => 60,
|
||||
// Picker rows — every swatch in a row shares the row's
|
||||
// priority so entity-index tiebreaking yields left → right.
|
||||
SettingsButton::SelectCardBack(_) => 70,
|
||||
SettingsButton::SelectBackground(_) => 80,
|
||||
// Sync section
|
||||
SettingsButton::SyncNow => 90,
|
||||
// Done is tagged by `attach_focusable_to_modal_buttons` and
|
||||
// never reaches `attach_focusable_to_settings_buttons`; the
|
||||
// value here is only a fallback for completeness.
|
||||
SettingsButton::Done => 100,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Plugin that owns the settings lifecycle.
|
||||
pub struct SettingsPlugin {
|
||||
/// Path to `settings.json`. `None` in headless/test mode.
|
||||
@@ -178,6 +214,8 @@ impl Plugin for SettingsPlugin {
|
||||
update_background_text,
|
||||
update_anim_speed_text,
|
||||
update_color_blind_text,
|
||||
attach_focusable_to_settings_buttons,
|
||||
scroll_focus_into_view,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -554,6 +592,148 @@ fn color_blind_label(enabled: bool) -> String {
|
||||
if enabled { "ON".into() } else { "OFF".into() }
|
||||
}
|
||||
|
||||
/// Auto-attaches [`Focusable`] to every bespoke Settings button — icon
|
||||
/// buttons (volume +/−, toggle, cycle), swatch buttons (card-back,
|
||||
/// background pickers), and the "Sync Now" button. The "Done" button is
|
||||
/// already tagged by `attach_focusable_to_modal_buttons` (it carries
|
||||
/// [`ModalButton`]) and is filtered out here.
|
||||
///
|
||||
/// Walks ancestors via [`ChildOf`] to find the [`ModalScrim`] that owns
|
||||
/// the panel so the new [`Focusable`]'s group is bound to that scrim —
|
||||
/// same defensive shape as the Phase 1 / 2 attach systems.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn attach_focusable_to_settings_buttons(
|
||||
mut commands: Commands,
|
||||
new_buttons: Query<
|
||||
(Entity, &SettingsButton),
|
||||
(With<Button>, Without<Focusable>, Without<ModalButton>),
|
||||
>,
|
||||
parents: Query<&ChildOf>,
|
||||
scrims: Query<(), With<ModalScrim>>,
|
||||
) {
|
||||
for (button, settings_button) in &new_buttons {
|
||||
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: settings_button.focus_order(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Vertical padding (logical px) added around the focused button when
|
||||
/// scrolling it into view. Keeps the focus ring's halo visible above /
|
||||
/// below the viewport edge.
|
||||
const FOCUS_SCROLL_PADDING: f32 = SPACE_2;
|
||||
|
||||
/// When the focused entity sits outside the visible Settings scroll
|
||||
/// viewport, adjust the viewport's [`ScrollPosition`] so the button is
|
||||
/// fully visible. No-op when:
|
||||
///
|
||||
/// - `FocusedButton` is `None`
|
||||
/// - the focused entity has no [`UiGlobalTransform`] / [`ComputedNode`]
|
||||
/// (e.g. a freshly-spawned modal hasn't laid out yet)
|
||||
/// - the focused entity is not a descendant of the
|
||||
/// [`SettingsPanelScrollable`] container
|
||||
///
|
||||
/// The viewport's visible Y range is `[scroll_y, scroll_y +
|
||||
/// viewport_height]` in physical pixels (matching `ComputedNode.size`).
|
||||
/// The focused button's vertical extent is computed from its
|
||||
/// `UiGlobalTransform.translation.y` (centre, physical) ± half its
|
||||
/// `ComputedNode.size.y`. Because the scroll container's local
|
||||
/// coordinates run [0, content_height] and the visible window is
|
||||
/// [scroll_y, scroll_y + viewport], we convert the button's window-
|
||||
/// space Y to container-local Y by subtracting the container's window-
|
||||
/// space top and adding the current scroll offset.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn scroll_focus_into_view(
|
||||
focused: Res<FocusedButton>,
|
||||
parents: Query<&ChildOf>,
|
||||
nodes: Query<(&UiGlobalTransform, &ComputedNode)>,
|
||||
mut containers: Query<
|
||||
(&mut ScrollPosition, &UiGlobalTransform, &ComputedNode),
|
||||
With<SettingsPanelScrollable>,
|
||||
>,
|
||||
) {
|
||||
let Some(target) = focused.0 else { return };
|
||||
// Gather button geometry.
|
||||
let Ok((target_transform, target_node)) = nodes.get(target) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Walk ancestors looking for the scroll container. Bounded to keep
|
||||
// a malformed hierarchy from hanging the system.
|
||||
let mut current = target;
|
||||
let mut container_entity: Option<Entity> = None;
|
||||
for _ in 0..32 {
|
||||
if containers.get(current).is_ok() {
|
||||
container_entity = Some(current);
|
||||
break;
|
||||
}
|
||||
match parents.get(current) {
|
||||
Ok(parent) => current = parent.parent(),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
let Some(container) = container_entity else { return };
|
||||
|
||||
let Ok((mut scroll, container_transform, container_node)) =
|
||||
containers.get_mut(container)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Geometry is reported in physical pixels by `ComputedNode.size` and
|
||||
// `UiGlobalTransform.translation`. `ScrollPosition` is in logical px,
|
||||
// so convert via `inverse_scale_factor` before we write.
|
||||
let inv = target_node.inverse_scale_factor;
|
||||
let target_height = target_node.size().y;
|
||||
let target_centre_y = target_transform.translation.y;
|
||||
let target_top = target_centre_y - target_height * 0.5;
|
||||
let target_bottom = target_centre_y + target_height * 0.5;
|
||||
|
||||
let container_height = container_node.size().y;
|
||||
let container_top = container_transform.translation.y - container_height * 0.5;
|
||||
|
||||
// Convert button window-space Y to container-local Y. The container
|
||||
// is currently scrolled by `scroll.0.y` *logical* pixels — multiply
|
||||
// by physical-per-logical to compare with physical pixel extents.
|
||||
let scroll_phys = scroll.0.y / inv.max(f32::EPSILON);
|
||||
let viewport_top = container_top + scroll_phys;
|
||||
let viewport_bottom = viewport_top + container_height;
|
||||
|
||||
// Layout may not have run yet (zero size on first frame) — no
|
||||
// sensible scroll target until the container has dimensions.
|
||||
if container_height <= 0.0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let pad_phys = FOCUS_SCROLL_PADDING / inv.max(f32::EPSILON);
|
||||
if target_top < viewport_top {
|
||||
// Button extends above the viewport — scroll up.
|
||||
let new_top = target_top - pad_phys;
|
||||
let delta = new_top - viewport_top;
|
||||
scroll.0.y = ((scroll_phys + delta) * inv).max(0.0);
|
||||
} else if target_bottom > viewport_bottom {
|
||||
// Button extends below the viewport — scroll down.
|
||||
let new_bottom = target_bottom + pad_phys;
|
||||
let delta = new_bottom - viewport_bottom;
|
||||
scroll.0.y = ((scroll_phys + delta) * inv).max(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Scrolls the settings panel inner card in response to mouse-wheel events.
|
||||
///
|
||||
/// `offset_y` increases downward (0 = top of content). Scrolling down (ev.y < 0)
|
||||
@@ -805,13 +985,19 @@ fn picker_row(
|
||||
..default()
|
||||
};
|
||||
parent
|
||||
.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: VAL_SPACE_2,
|
||||
flex_wrap: FlexWrap::Wrap,
|
||||
..default()
|
||||
})
|
||||
.spawn((
|
||||
// The row container is a `FocusRow` so Left / Right arrow
|
||||
// keys cycle within its swatch children. Tab still escapes
|
||||
// the row to the next focusable in the modal.
|
||||
FocusRow,
|
||||
Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: VAL_SPACE_2,
|
||||
flex_wrap: FlexWrap::Wrap,
|
||||
..default()
|
||||
},
|
||||
))
|
||||
.with_children(|row| {
|
||||
row.spawn((
|
||||
Text::new(label.to_string()),
|
||||
@@ -1141,6 +1327,94 @@ mod tests {
|
||||
assert!((offset - 200.0).abs() < 1e-3, "scrolling down should increase offset_y; got {offset}");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase 3 — keyboard focus ring, Settings buttons + FocusRow
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Headless app that runs the *real* (UI-enabled) `SettingsPlugin`
|
||||
/// alongside `UiModalPlugin` and `UiFocusPlugin`, so the spawn /
|
||||
/// auto-tag systems fire end-to-end without writing to disk.
|
||||
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(SettingsPlugin {
|
||||
// No persistence — keep the test isolated.
|
||||
storage_path: None,
|
||||
ui_enabled: true,
|
||||
});
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_buttons_get_focusable_marker() {
|
||||
let mut app = headless_app_with_focus();
|
||||
|
||||
// Open the panel.
|
||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||
app.update();
|
||||
// Two more ticks: the first runs `sync_settings_panel_visibility`
|
||||
// and queues the spawn commands; the second flushes them and
|
||||
// runs `attach_focusable_to_settings_buttons`.
|
||||
app.update();
|
||||
app.update();
|
||||
|
||||
// Every bespoke `SettingsButton` (not `Done`, which is also a
|
||||
// `ModalButton`) must carry a `Focusable`.
|
||||
let untagged: Vec<&SettingsButton> = app
|
||||
.world_mut()
|
||||
.query_filtered::<&SettingsButton, (With<Button>, Without<Focusable>, Without<ModalButton>)>()
|
||||
.iter(app.world())
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
untagged.is_empty(),
|
||||
"every bespoke Settings button must carry Focusable; missing: {:?}",
|
||||
untagged
|
||||
);
|
||||
|
||||
// And there must be at least one tagged `SettingsButton` so the
|
||||
// assertion above isn't vacuously true (the panel really did
|
||||
// spawn).
|
||||
let tagged_count = app
|
||||
.world_mut()
|
||||
.query_filtered::<&SettingsButton, With<Focusable>>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert!(
|
||||
tagged_count >= 6,
|
||||
"expected the panel to spawn many bespoke buttons (volume up/down ×2, toggles ×4, sync, swatches…); got {tagged_count}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_picker_rows_get_focus_row_marker() {
|
||||
let mut app = headless_app_with_focus();
|
||||
|
||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||
app.update();
|
||||
app.update();
|
||||
app.update();
|
||||
|
||||
// Two picker rows are spawned (card-back + background); each
|
||||
// must carry the FocusRow marker.
|
||||
let row_count = app
|
||||
.world_mut()
|
||||
.query_filtered::<Entity, With<FocusRow>>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert!(
|
||||
row_count >= 2,
|
||||
"expected at least two FocusRow containers (card-back + background); got {row_count}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_clamps_offset_to_zero_at_top() {
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
|
||||
@@ -29,11 +29,17 @@
|
||||
//!
|
||||
//! ## Phase scope
|
||||
//!
|
||||
//! Phase 1 is modal buttons only. The HUD action bar (Phase 2), Home
|
||||
//! mode cards (Phase 2), and Settings bespoke buttons + arrow-key
|
||||
//! handling (Phase 3) remain out of scope. 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.
|
||||
//! 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 bevy::ecs::query::Has;
|
||||
use bevy::input::ButtonInput;
|
||||
@@ -81,6 +87,21 @@ pub enum FocusGroup {
|
||||
#[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.
|
||||
@@ -310,16 +331,65 @@ fn clear_hud_focus_on_unhover(
|
||||
///
|
||||
/// Consumed keys are cleared from `ButtonInput<KeyCode>` so the
|
||||
/// selection plugin doesn't double-handle them.
|
||||
#[allow(clippy::type_complexity)]
|
||||
#[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)
|
||||
.map(|(_, disabled)| !disabled)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.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
|
||||
@@ -993,4 +1063,226 @@ mod tests {
|
||||
"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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user