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:
funman300
2026-04-30 22:10:43 +00:00
parent 51d3454344
commit b78a493a0c
2 changed files with 581 additions and 15 deletions
+283 -9
View File
@@ -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};
+298 -6
View File
@@ -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"
);
}
}