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::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use bevy::ui::{ComputedNode, UiGlobalTransform};
|
||||||
use solitaire_core::game_state::DrawMode;
|
use solitaire_core::game_state::DrawMode;
|
||||||
use solitaire_data::{load_settings_from, save_settings_to, settings_file_path, settings::Theme, AnimSpeed, Settings};
|
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::font_plugin::FontResource;
|
||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
|
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
|
||||||
|
use crate::ui_focus::{FocusGroup, FocusRow, Focusable, FocusedButton};
|
||||||
use crate::ui_modal::{
|
use crate::ui_modal::{
|
||||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||||
|
ModalButton, ModalScrim,
|
||||||
};
|
};
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
BG_BASE, BG_ELEVATED_HI, BORDER_SUBTLE, RADIUS_SM, STATE_SUCCESS, TEXT_PRIMARY, TEXT_SECONDARY,
|
BG_BASE, BG_ELEVATED_HI, BORDER_SUBTLE, RADIUS_SM, SPACE_2, STATE_SUCCESS, TEXT_PRIMARY,
|
||||||
TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
|
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.
|
/// Side length of a swatch button in the card-back / background pickers.
|
||||||
@@ -123,6 +126,39 @@ enum SettingsButton {
|
|||||||
SelectBackground(usize),
|
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.
|
/// Plugin that owns the settings lifecycle.
|
||||||
pub struct SettingsPlugin {
|
pub struct SettingsPlugin {
|
||||||
/// Path to `settings.json`. `None` in headless/test mode.
|
/// Path to `settings.json`. `None` in headless/test mode.
|
||||||
@@ -178,6 +214,8 @@ impl Plugin for SettingsPlugin {
|
|||||||
update_background_text,
|
update_background_text,
|
||||||
update_anim_speed_text,
|
update_anim_speed_text,
|
||||||
update_color_blind_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() }
|
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.
|
/// 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)
|
/// `offset_y` increases downward (0 = top of content). Scrolling down (ev.y < 0)
|
||||||
@@ -805,13 +985,19 @@ fn picker_row(
|
|||||||
..default()
|
..default()
|
||||||
};
|
};
|
||||||
parent
|
parent
|
||||||
.spawn(Node {
|
.spawn((
|
||||||
flex_direction: FlexDirection::Row,
|
// The row container is a `FocusRow` so Left / Right arrow
|
||||||
align_items: AlignItems::Center,
|
// keys cycle within its swatch children. Tab still escapes
|
||||||
column_gap: VAL_SPACE_2,
|
// the row to the next focusable in the modal.
|
||||||
flex_wrap: FlexWrap::Wrap,
|
FocusRow,
|
||||||
..default()
|
Node {
|
||||||
})
|
flex_direction: FlexDirection::Row,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
column_gap: VAL_SPACE_2,
|
||||||
|
flex_wrap: FlexWrap::Wrap,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
))
|
||||||
.with_children(|row| {
|
.with_children(|row| {
|
||||||
row.spawn((
|
row.spawn((
|
||||||
Text::new(label.to_string()),
|
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}");
|
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]
|
#[test]
|
||||||
fn scroll_clamps_offset_to_zero_at_top() {
|
fn scroll_clamps_offset_to_zero_at_top() {
|
||||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||||
|
|||||||
@@ -29,11 +29,17 @@
|
|||||||
//!
|
//!
|
||||||
//! ## Phase scope
|
//! ## Phase scope
|
||||||
//!
|
//!
|
||||||
//! Phase 1 is modal buttons only. The HUD action bar (Phase 2), Home
|
//! Phase 1 is modal buttons only. Phase 2 extended the same component
|
||||||
//! mode cards (Phase 2), and Settings bespoke buttons + arrow-key
|
//! to the HUD action bar (on hover) and Home mode cards. Phase 3 closes
|
||||||
//! handling (Phase 3) remain out of scope. When no modal is open and no
|
//! out the engine: Settings bespoke buttons opt-in via the same
|
||||||
//! HUD button is hovered, every system here no-ops so
|
//! ancestry-walk pattern, picker rows inside Settings get [`FocusRow`]
|
||||||
//! [`crate::selection_plugin`]'s Tab/Enter card-selection still works.
|
//! 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::ecs::query::Has;
|
||||||
use bevy::input::ButtonInput;
|
use bevy::input::ButtonInput;
|
||||||
@@ -81,6 +87,21 @@ pub enum FocusGroup {
|
|||||||
#[derive(Component, Debug, Clone, Copy)]
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
pub struct Disabled;
|
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.
|
/// Globally-focused button entity, or `None` if nothing is focused.
|
||||||
/// Read-only in steady state; written by the focus systems on Tab,
|
/// Read-only in steady state; written by the focus systems on Tab,
|
||||||
/// mouse click, and modal open / close.
|
/// 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
|
/// Consumed keys are cleared from `ButtonInput<KeyCode>` so the
|
||||||
/// selection plugin doesn't double-handle them.
|
/// selection plugin doesn't double-handle them.
|
||||||
#[allow(clippy::type_complexity)]
|
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||||
fn handle_focus_keys(
|
fn handle_focus_keys(
|
||||||
mut keys: ResMut<ButtonInput<KeyCode>>,
|
mut keys: ResMut<ButtonInput<KeyCode>>,
|
||||||
scrims: Query<Entity, With<ModalScrim>>,
|
scrims: Query<Entity, With<ModalScrim>>,
|
||||||
children_q: Query<&Children>,
|
children_q: Query<&Children>,
|
||||||
|
parents_q: Query<&ChildOf>,
|
||||||
|
rows: Query<(), With<FocusRow>>,
|
||||||
focusables: Query<(&Focusable, Has<Disabled>)>,
|
focusables: Query<(&Focusable, Has<Disabled>)>,
|
||||||
hud_interactions: Query<(Entity, &Interaction, &Focusable), Without<Disabled>>,
|
hud_interactions: Query<(Entity, &Interaction, &Focusable), Without<Disabled>>,
|
||||||
mut focused: ResMut<FocusedButton>,
|
mut focused: ResMut<FocusedButton>,
|
||||||
mut writes: Commands,
|
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:
|
// Resolve the active focus group:
|
||||||
// 1. Any modal open ⇒ Modal(topmost scrim)
|
// 1. Any modal open ⇒ Modal(topmost scrim)
|
||||||
// 2. Any Hud-grouped focusable hovered ⇒ Hud
|
// 2. Any Hud-grouped focusable hovered ⇒ Hud
|
||||||
@@ -993,4 +1063,226 @@ mod tests {
|
|||||||
"FocusedButton must clear once no Hud button is hovered"
|
"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