ui focus
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
use crate::main_menu::InteractionColors;
|
||||
use bevy::{
|
||||
input::gamepad::GamepadButton,
|
||||
input_focus::{
|
||||
InputFocus, InputFocusSystems, InputFocusVisible,
|
||||
directional_navigation::DirectionalNavigationPlugin, tab_navigation::TabNavigationPlugin,
|
||||
},
|
||||
math::{CompassOctant, Dir2},
|
||||
prelude::*,
|
||||
text::EditableText,
|
||||
ui::{InteractionDisabled, auto_directional_navigation::AutoDirectionalNavigator},
|
||||
ui_widgets::{Activate, Button, RadioGroup},
|
||||
};
|
||||
|
||||
/// Orders initial focus selection before directional movement in `PreUpdate`.
|
||||
#[derive(SystemSet, Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub(crate) enum NavigationSystems {
|
||||
/// Selects the active menu layer's first focusable item when navigation starts without focus.
|
||||
Initialize,
|
||||
/// Applies arrow-key and D-pad movement after focus initialization.
|
||||
Navigate,
|
||||
}
|
||||
|
||||
/// Prevents the input that initializes focus from also moving it directionally.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct NavigationInputState {
|
||||
/// Set by the initializer and consumed by the directional-navigation system in the same frame.
|
||||
pub(crate) focus_initialized: bool,
|
||||
}
|
||||
|
||||
/// Installs Bevy focus, tab, and directional navigation for the application's UI.
|
||||
pub struct FocusNavigationPlugin;
|
||||
|
||||
impl Plugin for FocusNavigationPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_plugins((TabNavigationPlugin, DirectionalNavigationPlugin))
|
||||
.insert_resource(InputFocusVisible(true))
|
||||
.init_resource::<NavigationInputState>()
|
||||
.configure_sets(
|
||||
PreUpdate,
|
||||
(NavigationSystems::Initialize, NavigationSystems::Navigate)
|
||||
.chain()
|
||||
.after(InputFocusSystems::Dispatch),
|
||||
)
|
||||
.add_systems(
|
||||
PreUpdate,
|
||||
navigate_directionally.in_set(NavigationSystems::Navigate),
|
||||
)
|
||||
.add_systems(Update, style_focus_outlines)
|
||||
.add_observer(show_focus_after_activation);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn navigate_directionally(
|
||||
keyboard: Res<ButtonInput<KeyCode>>,
|
||||
gamepads: Query<&Gamepad>,
|
||||
mut navigator: AutoDirectionalNavigator,
|
||||
editable_texts: Query<(), With<EditableText>>,
|
||||
radio_groups: Query<(), With<RadioGroup>>,
|
||||
focusable_buttons: Query<(), (With<Button>, Without<InteractionDisabled>)>,
|
||||
mut focus_visible: ResMut<InputFocusVisible>,
|
||||
mut navigation_input: ResMut<NavigationInputState>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
if std::mem::take(&mut navigation_input.focus_initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
let any_gamepad_pressed = |button| gamepads.iter().any(|gamepad| gamepad.just_pressed(button));
|
||||
let focused_entity = navigator.input_focus();
|
||||
let keyboard_navigation_enabled = focused_entity
|
||||
.is_some_and(|entity| !editable_texts.contains(entity) && !radio_groups.contains(entity));
|
||||
|
||||
let mut horizontal = 0_i8;
|
||||
let mut vertical = 0_i8;
|
||||
if keyboard_navigation_enabled {
|
||||
horizontal += i8::from(keyboard.just_pressed(KeyCode::ArrowRight));
|
||||
horizontal -= i8::from(keyboard.just_pressed(KeyCode::ArrowLeft));
|
||||
vertical += i8::from(keyboard.just_pressed(KeyCode::ArrowUp));
|
||||
vertical -= i8::from(keyboard.just_pressed(KeyCode::ArrowDown));
|
||||
}
|
||||
horizontal += i8::from(any_gamepad_pressed(GamepadButton::DPadRight));
|
||||
horizontal -= i8::from(any_gamepad_pressed(GamepadButton::DPadLeft));
|
||||
vertical += i8::from(any_gamepad_pressed(GamepadButton::DPadUp));
|
||||
vertical -= i8::from(any_gamepad_pressed(GamepadButton::DPadDown));
|
||||
|
||||
if let Some(direction) = Dir2::from_xy(horizontal as f32, vertical as f32)
|
||||
.ok()
|
||||
.map(CompassOctant::from)
|
||||
&& navigator.navigate(direction).is_ok()
|
||||
{
|
||||
focus_visible.0 = true;
|
||||
}
|
||||
|
||||
if any_gamepad_pressed(GamepadButton::South)
|
||||
&& let Some(focused_entity) = navigator.input_focus()
|
||||
&& focusable_buttons.contains(focused_entity)
|
||||
{
|
||||
commands.trigger(Activate {
|
||||
entity: focused_entity,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn show_focus_after_activation(_: On<Activate>, mut focus_visible: ResMut<InputFocusVisible>) {
|
||||
focus_visible.0 = true;
|
||||
}
|
||||
|
||||
fn style_focus_outlines(
|
||||
input_focus: Res<InputFocus>,
|
||||
mut outlines: Query<(Entity, &InteractionColors, &mut BorderColor)>,
|
||||
) {
|
||||
let focused_entity = input_focus.get();
|
||||
|
||||
for (entity, colors, mut border_color) in &mut outlines {
|
||||
let color = if focused_entity == Some(entity) {
|
||||
colors.focused_outline
|
||||
} else {
|
||||
colors.default_outline
|
||||
};
|
||||
let new_border_color = BorderColor::all(color);
|
||||
if *border_color != new_border_color {
|
||||
*border_color = new_border_color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bevy::{
|
||||
camera::NormalizedRenderTarget,
|
||||
input_focus::{
|
||||
InputFocus, InputFocusPlugin,
|
||||
directional_navigation::{DirectionalNavigationMap, DirectionalNavigationPlugin},
|
||||
tab_navigation::TabIndex,
|
||||
},
|
||||
picking::{
|
||||
backend::HitData,
|
||||
events::{Pointer, Press},
|
||||
pointer::{Location, PointerButton, PointerId},
|
||||
},
|
||||
window::PrimaryWindow,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn clicking_the_window_clears_focus_via_tab_navigation_plugin() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins((InputFocusPlugin, TabNavigationPlugin));
|
||||
let field = app.world_mut().spawn(TabIndex(0)).id();
|
||||
let window = app
|
||||
.world_mut()
|
||||
.spawn((Window::default(), PrimaryWindow))
|
||||
.id();
|
||||
app.world_mut()
|
||||
.insert_resource(InputFocus::from_entity(field));
|
||||
app.update();
|
||||
|
||||
app.world_mut().trigger(Pointer::new(
|
||||
PointerId::Mouse,
|
||||
Location {
|
||||
target: NormalizedRenderTarget::None {
|
||||
width: 0,
|
||||
height: 0,
|
||||
},
|
||||
position: Vec2::ZERO,
|
||||
},
|
||||
Press {
|
||||
button: PointerButton::Primary,
|
||||
hit: HitData {
|
||||
camera: Entity::PLACEHOLDER,
|
||||
depth: 0.0,
|
||||
position: None,
|
||||
normal: None,
|
||||
extra: None,
|
||||
},
|
||||
count: 1,
|
||||
},
|
||||
window,
|
||||
));
|
||||
app.update();
|
||||
|
||||
assert_eq!(app.world().resource::<InputFocus>().get(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_key_moves_focus_with_directional_navigation() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(DirectionalNavigationPlugin);
|
||||
let current = app.world_mut().spawn_empty().id();
|
||||
let next = app.world_mut().spawn_empty().id();
|
||||
app.world_mut()
|
||||
.insert_resource(InputFocus::from_entity(current));
|
||||
app.world_mut().insert_resource(InputFocusVisible(false));
|
||||
app.world_mut().init_resource::<NavigationInputState>();
|
||||
app.world_mut().init_resource::<ButtonInput<KeyCode>>();
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::ArrowRight);
|
||||
app.world_mut()
|
||||
.resource_mut::<DirectionalNavigationMap>()
|
||||
.add_edge(current, next, CompassOctant::East);
|
||||
app.add_systems(PreUpdate, navigate_directionally);
|
||||
|
||||
app.update();
|
||||
|
||||
assert_eq!(app.world().resource::<InputFocus>().get(), Some(next));
|
||||
assert!(app.world().resource::<InputFocusVisible>().0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn white_outline_tracks_focus_not_hover() {
|
||||
let mut app = App::new();
|
||||
let colors = InteractionColors::DEFAULT;
|
||||
let focused = app
|
||||
.world_mut()
|
||||
.spawn((colors.clone(), BorderColor::all(colors.default_outline)))
|
||||
.id();
|
||||
let hovered = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
colors.clone(),
|
||||
BorderColor::all(colors.default_outline),
|
||||
Interaction::Hovered,
|
||||
))
|
||||
.id();
|
||||
app.world_mut()
|
||||
.insert_resource(InputFocus::from_entity(focused));
|
||||
app.add_systems(Update, style_focus_outlines);
|
||||
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
app.world().get::<BorderColor>(focused).unwrap(),
|
||||
&BorderColor::all(Color::WHITE)
|
||||
);
|
||||
assert_eq!(
|
||||
app.world().get::<BorderColor>(hovered).unwrap(),
|
||||
&BorderColor::all(Color::BLACK)
|
||||
);
|
||||
}
|
||||
}
|
||||
+49
-4
@@ -1,9 +1,12 @@
|
||||
use bevy::{
|
||||
color::palettes::tailwind::SLATE_900,
|
||||
input_focus::tab_navigation::TabIndex,
|
||||
input_focus::{
|
||||
AutoFocus, InputFocus,
|
||||
tab_navigation::{TabGroup, TabIndex},
|
||||
},
|
||||
prelude::*,
|
||||
text::{EditableText, EditableTextFilter, TextCursorStyle},
|
||||
ui::Checked,
|
||||
ui::{Checked, auto_directional_navigation::AutoDirectionalNavigation},
|
||||
ui_widgets::{Activate, Button, RadioButton, RadioGroup, ValueChange},
|
||||
};
|
||||
|
||||
@@ -83,6 +86,7 @@ impl Plugin for LobbyPlugin {
|
||||
session_visuals,
|
||||
sync_lobby_fields,
|
||||
style_privacy_options,
|
||||
style_lobby_focus,
|
||||
)
|
||||
.chain(),
|
||||
);
|
||||
@@ -101,6 +105,7 @@ fn setup_lobby(mut commands: Commands) {
|
||||
commands.spawn_scene(bsn! {
|
||||
#LobbyUiRoot
|
||||
LobbyUiRoot
|
||||
TabGroup::default()
|
||||
DespawnOnExit::<AppState>(AppState::Lobby)
|
||||
Node {
|
||||
width: percent(100),
|
||||
@@ -148,6 +153,8 @@ fn build_lobby_ui(mut commands: Commands, root_ui: Single<Entity, With<LobbyUiRo
|
||||
),
|
||||
(
|
||||
RadioGroup
|
||||
AutoFocus
|
||||
AutoDirectionalNavigation::default()
|
||||
Node {
|
||||
display: Display::Flex,
|
||||
flex_direction: FlexDirection::Row,
|
||||
@@ -183,6 +190,8 @@ fn build_lobby_ui(mut commands: Commands, root_ui: Single<Entity, With<LobbyUiRo
|
||||
(
|
||||
Button
|
||||
ResetPortButton
|
||||
AutoDirectionalNavigation::default()
|
||||
TabIndex(2)
|
||||
Node {
|
||||
width: px(600),
|
||||
height: px(130),
|
||||
@@ -230,6 +239,8 @@ fn build_lobby_ui(mut commands: Commands, root_ui: Single<Entity, With<LobbyUiRo
|
||||
BorderColor::from(Color::BLACK)
|
||||
InteractionColors::GOOD
|
||||
Interaction
|
||||
AutoDirectionalNavigation::default()
|
||||
TabIndex(4)
|
||||
on(|_event: On<Activate>, mut lobby: ResMut<LobbyUiState>| {
|
||||
info!("Session clicked!");
|
||||
lobby.session_active = !lobby.session_active;
|
||||
@@ -313,7 +324,8 @@ fn port_field() -> impl Scene {
|
||||
TextCursorStyle {
|
||||
color: Color::BLACK,
|
||||
}
|
||||
TabIndex(0)
|
||||
AutoDirectionalNavigation::default()
|
||||
TabIndex(1)
|
||||
BackgroundColor(TEXT_INPUT_BACKGROUND)
|
||||
}
|
||||
}
|
||||
@@ -339,7 +351,8 @@ fn password_field() -> impl Scene {
|
||||
TextCursorStyle {
|
||||
color: Color::BLACK,
|
||||
}
|
||||
TabIndex(1)
|
||||
AutoDirectionalNavigation::default()
|
||||
TabIndex(3)
|
||||
BackgroundColor(TEXT_INPUT_BACKGROUND)
|
||||
}
|
||||
}
|
||||
@@ -494,6 +507,28 @@ fn style_privacy_options(
|
||||
}
|
||||
}
|
||||
|
||||
fn style_lobby_focus(
|
||||
input_focus: Res<InputFocus>,
|
||||
mut options: Query<
|
||||
(&ChildOf, &mut BorderColor),
|
||||
(With<LobbyPrivacyOption>, Without<InteractionColors>),
|
||||
>,
|
||||
) {
|
||||
let focused_entity = input_focus.get();
|
||||
|
||||
for (parent, mut border_color) in &mut options {
|
||||
let color = if focused_entity == Some(parent.parent()) {
|
||||
OPTION_TEXT_COLOR
|
||||
} else {
|
||||
OPTION_BORDER_COLOR
|
||||
};
|
||||
let new_border_color = BorderColor::all(color);
|
||||
if *border_color != new_border_color {
|
||||
*border_color = new_border_color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -506,6 +541,16 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autofocus_claims_initial_lobby_focus() {
|
||||
let mut world = World::new();
|
||||
world.insert_resource(InputFocus::default());
|
||||
|
||||
let radio_group = world.spawn((RadioGroup, AutoFocus)).id();
|
||||
|
||||
assert_eq!(world.resource::<InputFocus>().get(), Some(radio_group));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_option_is_styled() {
|
||||
let mut world = World::new();
|
||||
|
||||
@@ -2,9 +2,11 @@ use bevy::{camera::ScalingMode, color::palettes::css::GREEN, prelude::*};
|
||||
use main_menu::MainMenuPlugin;
|
||||
use networking::NetworkingPlugin;
|
||||
|
||||
use crate::focus_navigation::FocusNavigationPlugin;
|
||||
use crate::{debug::DebugInspectorPlugin, lobby::LobbyPlugin, save::SavePlugin};
|
||||
|
||||
mod debug;
|
||||
mod focus_navigation;
|
||||
mod lobby;
|
||||
mod main_menu;
|
||||
mod networking;
|
||||
@@ -32,6 +34,7 @@ fn main() {
|
||||
App::new()
|
||||
.add_plugins((
|
||||
DefaultPlugins,
|
||||
FocusNavigationPlugin,
|
||||
MainMenuPlugin,
|
||||
NetworkingPlugin,
|
||||
SavePlugin,
|
||||
|
||||
+206
-30
@@ -1,8 +1,14 @@
|
||||
use crate::focus_navigation::{NavigationInputState, NavigationSystems};
|
||||
use crate::save::{ActiveGameSave, create_game_save, delete_game_save, get_saves};
|
||||
use crate::{AppState, WINDOW_TARGET_HEIGHT, WINDOW_TARGET_WIDTH};
|
||||
use bevy::color::palettes::tailwind::SLATE_900;
|
||||
use bevy::input_focus::tab_navigation::TabIndex;
|
||||
use bevy::input::gamepad::GamepadButton;
|
||||
use bevy::input_focus::{
|
||||
AutoFocus, FocusCause, InputFocus, InputFocusVisible,
|
||||
tab_navigation::{TabGroup, TabIndex},
|
||||
};
|
||||
use bevy::text::{EditableText, EditableTextFilter, TextCursorStyle};
|
||||
use bevy::ui::auto_directional_navigation::AutoDirectionalNavigation;
|
||||
use bevy::ui_widgets::Button;
|
||||
use bevy::{prelude::*, ui_widgets::Activate};
|
||||
use derive_more::{Deref, DerefMut};
|
||||
@@ -16,17 +22,24 @@ impl Plugin for MainMenuPlugin {
|
||||
// .add_systems(Startup, enable_ui_debug)
|
||||
.add_systems(Update, (menu_redraw).run_if(in_state(AppState::Menu)))
|
||||
.add_observer(button_activate_observer)
|
||||
.add_systems(Update, (scale_ui, style_ui_elements));
|
||||
.add_systems(Update, (scale_ui, style_ui_elements))
|
||||
.add_systems(
|
||||
PreUpdate,
|
||||
focus_rightmost_layer_on_navigation
|
||||
.in_set(NavigationSystems::Initialize)
|
||||
.run_if(in_state(AppState::Menu)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The number of layers that the main menu is sized for.
|
||||
const N_LAYERS: usize = 5;
|
||||
|
||||
/// The text field for the name of the game.
|
||||
#[derive(Component, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Default, Debug)]
|
||||
struct GameNameTextField;
|
||||
|
||||
/// A UI object that is selected.
|
||||
/// A UI object selected by activation, distinct from keyboard/pointer focus.
|
||||
#[derive(Component, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Default, Debug)]
|
||||
struct Selected;
|
||||
|
||||
@@ -39,6 +52,8 @@ pub(crate) struct InteractionColors {
|
||||
selected: Color,
|
||||
selected_hovered: Color,
|
||||
selected_pressed: Color,
|
||||
pub(crate) default_outline: Color,
|
||||
pub(crate) focused_outline: Color,
|
||||
}
|
||||
impl InteractionColors {
|
||||
pub const DEFAULT: InteractionColors = InteractionColors {
|
||||
@@ -48,6 +63,8 @@ impl InteractionColors {
|
||||
selected: Color::srgb(0.93, 0.66, 0.06),
|
||||
selected_hovered: Color::srgb(1., 0.74, 0.16),
|
||||
selected_pressed: Color::srgb(0.86, 0.56, 0.),
|
||||
default_outline: Color::BLACK,
|
||||
focused_outline: Color::WHITE,
|
||||
};
|
||||
pub const GOOD: InteractionColors = InteractionColors {
|
||||
default: Color::srgb(0.25, 0.75, 0.25),
|
||||
@@ -56,6 +73,8 @@ impl InteractionColors {
|
||||
selected: Color::srgb(0.35, 0.8, 0.35),
|
||||
selected_hovered: Color::srgb(0.45, 0.9, 0.45),
|
||||
selected_pressed: Color::srgb(0.25, 0.7, 0.25),
|
||||
default_outline: Color::BLACK,
|
||||
focused_outline: Color::WHITE,
|
||||
};
|
||||
pub const DANGER: InteractionColors = InteractionColors {
|
||||
default: Color::srgb(0.75, 0.25, 0.25),
|
||||
@@ -64,6 +83,8 @@ impl InteractionColors {
|
||||
selected: Color::srgb(0.8, 0.3, 0.3),
|
||||
selected_hovered: Color::srgb(0.9, 0.4, 0.4),
|
||||
selected_pressed: Color::srgb(0.7, 0.2, 0.2),
|
||||
default_outline: Color::BLACK,
|
||||
focused_outline: Color::WHITE,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -322,6 +343,8 @@ fn new_game_menu() -> impl SceneList {
|
||||
TextCursorStyle {
|
||||
color: Color::BLACK,
|
||||
}
|
||||
AutoDirectionalNavigation::default()
|
||||
AutoFocus
|
||||
TabIndex(0)
|
||||
BackgroundColor(Color::srgb(0.48, 0.84, 0.87))
|
||||
),
|
||||
@@ -396,6 +419,8 @@ fn game_info_menu(name: impl Into<String>) -> impl SceneList {
|
||||
TextCursorStyle {
|
||||
color: Color::BLACK,
|
||||
}
|
||||
AutoDirectionalNavigation::default()
|
||||
AutoFocus
|
||||
TabIndex(0)
|
||||
BackgroundColor(Color::srgb(0.48, 0.84, 0.87))
|
||||
),
|
||||
@@ -445,6 +470,7 @@ fn menu_layer(layer: usize) -> impl Scene {
|
||||
bsn! {
|
||||
Name(name)
|
||||
Layer(layer)
|
||||
TabGroup::new(layer as i32)
|
||||
Node {
|
||||
width: percent(100.0 / N_LAYERS as f32),
|
||||
height: percent(100),
|
||||
@@ -460,6 +486,7 @@ fn full_menu_layer(layer: usize) -> impl Scene {
|
||||
bsn! {
|
||||
Name(name)
|
||||
Layer(layer)
|
||||
TabGroup::new(-(layer as i32))
|
||||
Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
align_self: AlignSelf::FlexEnd,
|
||||
@@ -473,9 +500,10 @@ fn full_menu_layer(layer: usize) -> impl Scene {
|
||||
|
||||
fn menu_button(label: impl Into<String>, next_layer: Option<MenuLayer>) -> Box<dyn Scene> {
|
||||
let label = label.into();
|
||||
|
||||
Box::new(bsn! {
|
||||
Button
|
||||
AutoDirectionalNavigation::default()
|
||||
TabIndex(0)
|
||||
Node {
|
||||
width: px(600),
|
||||
height: px(130),
|
||||
@@ -527,7 +555,6 @@ fn button_activate_observer(
|
||||
// push next layer
|
||||
menu.push(next_layer.0.clone());
|
||||
|
||||
// update the buttons so the correct one is selected
|
||||
for (entity, selected) in q_selected.iter_many(layer_entries) {
|
||||
if entity == event.entity {
|
||||
if !selected {
|
||||
@@ -542,40 +569,28 @@ fn button_activate_observer(
|
||||
|
||||
/// Styles the UI elements according to their [`InteractionColors`].
|
||||
fn style_ui_elements(
|
||||
mut q_interaction_set: ParamSet<(
|
||||
Query<
|
||||
(
|
||||
&Interaction,
|
||||
&mut BackgroundColor,
|
||||
&InteractionColors,
|
||||
Has<Selected>,
|
||||
),
|
||||
(Or<(Changed<Interaction>, Added<Selected>)>, With<Button>),
|
||||
>,
|
||||
Query<(&Interaction, &mut BackgroundColor, &InteractionColors)>,
|
||||
input_focus: Res<InputFocus>,
|
||||
mut elements: Query<(
|
||||
Entity,
|
||||
&Interaction,
|
||||
&mut BackgroundColor,
|
||||
&InteractionColors,
|
||||
Has<Selected>,
|
||||
)>,
|
||||
mut removed_selected: RemovedComponents<Selected>,
|
||||
) {
|
||||
for (interaction, mut bg, colors, selected) in q_interaction_set.p0().iter_mut() {
|
||||
let new_color = match (selected, interaction) {
|
||||
let focused_entity = input_focus.get();
|
||||
|
||||
for (entity, interaction, mut bg, colors, selected) in &mut elements {
|
||||
let new_color = match (selected, *interaction) {
|
||||
(true, Interaction::Pressed) => colors.selected_pressed,
|
||||
(true, Interaction::Hovered) => colors.selected_hovered,
|
||||
(true, Interaction::None) => colors.selected,
|
||||
(false, Interaction::Pressed) => colors.pressed,
|
||||
(false, Interaction::Hovered) => colors.hovered,
|
||||
(false, Interaction::None) if focused_entity == Some(entity) => colors.hovered,
|
||||
(false, Interaction::None) => colors.default,
|
||||
};
|
||||
bg.0 = new_color;
|
||||
}
|
||||
|
||||
// also do entities that have had `Selected` removed
|
||||
for entity in removed_selected.read() {
|
||||
if let Ok((interaction, mut bg, colors)) = q_interaction_set.p1().get_mut(entity) {
|
||||
let new_color = match interaction {
|
||||
Interaction::Pressed => colors.pressed,
|
||||
Interaction::Hovered => colors.hovered,
|
||||
Interaction::None => colors.default,
|
||||
};
|
||||
if bg.0 != new_color {
|
||||
bg.0 = new_color;
|
||||
}
|
||||
}
|
||||
@@ -586,3 +601,164 @@ fn scale_ui(mut ui_scale: ResMut<UiScale>, window: Single<&Window>) {
|
||||
let scale_factor = f32::min(size.x / WINDOW_TARGET_WIDTH, size.y / WINDOW_TARGET_HEIGHT);
|
||||
ui_scale.0 = scale_factor;
|
||||
}
|
||||
|
||||
fn focus_rightmost_layer_on_navigation(
|
||||
keyboard: Res<ButtonInput<KeyCode>>,
|
||||
gamepads: Query<&Gamepad>,
|
||||
mut input_focus: ResMut<InputFocus>,
|
||||
mut input_focus_visible: ResMut<InputFocusVisible>,
|
||||
mut navigation_input: ResMut<NavigationInputState>,
|
||||
layers: Query<(Entity, &Layer)>,
|
||||
children: Query<&Children>,
|
||||
focusable_entities: Query<(), With<AutoDirectionalNavigation>>,
|
||||
) {
|
||||
let navigation_pressed = [
|
||||
KeyCode::Tab,
|
||||
KeyCode::ArrowUp,
|
||||
KeyCode::ArrowDown,
|
||||
KeyCode::ArrowLeft,
|
||||
KeyCode::ArrowRight,
|
||||
]
|
||||
.into_iter()
|
||||
.any(|key| keyboard.just_pressed(key))
|
||||
|| gamepads.iter().any(|gamepad| {
|
||||
[
|
||||
GamepadButton::DPadUp,
|
||||
GamepadButton::DPadDown,
|
||||
GamepadButton::DPadLeft,
|
||||
GamepadButton::DPadRight,
|
||||
]
|
||||
.into_iter()
|
||||
.any(|button| gamepad.just_pressed(button))
|
||||
});
|
||||
|
||||
if !navigation_pressed
|
||||
|| input_focus
|
||||
.get()
|
||||
.is_some_and(|focused| focusable_entities.contains(focused))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let mut rightmost_target = None;
|
||||
for (entity, layer) in &layers {
|
||||
if let Some(target) = first_focusable_in_layer(entity, &children, &focusable_entities)
|
||||
&& rightmost_target.is_none_or(|(rightmost_layer, _)| layer.0 > rightmost_layer)
|
||||
{
|
||||
rightmost_target = Some((layer.0, target));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((_, target)) = rightmost_target {
|
||||
input_focus.set(target, FocusCause::Navigated);
|
||||
input_focus_visible.0 = true;
|
||||
navigation_input.focus_initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn first_focusable_in_layer(
|
||||
entity: Entity,
|
||||
children: &Query<&Children>,
|
||||
focusable_entities: &Query<(), With<AutoDirectionalNavigation>>,
|
||||
) -> Option<Entity> {
|
||||
if focusable_entities.contains(entity) {
|
||||
return Some(entity);
|
||||
}
|
||||
|
||||
children
|
||||
.get(entity)
|
||||
.ok()?
|
||||
.iter()
|
||||
.find_map(|child| first_focusable_in_layer(child, children, focusable_entities))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::focus_navigation::navigate_directionally;
|
||||
use bevy::{
|
||||
input_focus::directional_navigation::{
|
||||
DirectionalNavigationMap, DirectionalNavigationPlugin,
|
||||
},
|
||||
math::CompassOctant,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn focus_uses_hover_color_while_selected_keeps_selected_color() {
|
||||
let mut app = App::new();
|
||||
let colors = InteractionColors::DEFAULT;
|
||||
let focused = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Interaction::None,
|
||||
BackgroundColor(colors.default),
|
||||
colors.clone(),
|
||||
))
|
||||
.id();
|
||||
let selected = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Interaction::None,
|
||||
BackgroundColor(colors.default),
|
||||
colors.clone(),
|
||||
Selected,
|
||||
))
|
||||
.id();
|
||||
app.world_mut()
|
||||
.insert_resource(InputFocus::from_entity(focused));
|
||||
app.add_systems(Update, style_ui_elements);
|
||||
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
app.world().get::<BackgroundColor>(focused).unwrap().0,
|
||||
colors.hovered
|
||||
);
|
||||
assert_eq!(
|
||||
app.world().get::<BackgroundColor>(selected).unwrap().0,
|
||||
colors.selected
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_directional_input_only_focuses_first_item_in_rightmost_layer() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(DirectionalNavigationPlugin);
|
||||
let left_layer = app.world_mut().spawn(Layer(0)).id();
|
||||
app.world_mut()
|
||||
.spawn((AutoDirectionalNavigation::default(), ChildOf(left_layer)));
|
||||
|
||||
let right_layer = app.world_mut().spawn(Layer(1)).id();
|
||||
let first_right_item = app
|
||||
.world_mut()
|
||||
.spawn((AutoDirectionalNavigation::default(), ChildOf(right_layer)))
|
||||
.id();
|
||||
let next_right_item = app
|
||||
.world_mut()
|
||||
.spawn((AutoDirectionalNavigation::default(), ChildOf(right_layer)))
|
||||
.id();
|
||||
|
||||
app.world_mut().insert_resource(InputFocus::default());
|
||||
app.world_mut().insert_resource(InputFocusVisible(false));
|
||||
app.world_mut().init_resource::<NavigationInputState>();
|
||||
app.world_mut().init_resource::<ButtonInput<KeyCode>>();
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::ArrowRight);
|
||||
app.world_mut()
|
||||
.resource_mut::<DirectionalNavigationMap>()
|
||||
.add_edge(first_right_item, next_right_item, CompassOctant::East);
|
||||
app.add_systems(
|
||||
PreUpdate,
|
||||
(focus_rightmost_layer_on_navigation, navigate_directionally).chain(),
|
||||
);
|
||||
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
app.world().resource::<InputFocus>().get(),
|
||||
Some(first_right_item)
|
||||
);
|
||||
assert!(app.world().resource::<InputFocusVisible>().0);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user