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::{
|
use bevy::{
|
||||||
color::palettes::tailwind::SLATE_900,
|
color::palettes::tailwind::SLATE_900,
|
||||||
input_focus::tab_navigation::TabIndex,
|
input_focus::{
|
||||||
|
AutoFocus, InputFocus,
|
||||||
|
tab_navigation::{TabGroup, TabIndex},
|
||||||
|
},
|
||||||
prelude::*,
|
prelude::*,
|
||||||
text::{EditableText, EditableTextFilter, TextCursorStyle},
|
text::{EditableText, EditableTextFilter, TextCursorStyle},
|
||||||
ui::Checked,
|
ui::{Checked, auto_directional_navigation::AutoDirectionalNavigation},
|
||||||
ui_widgets::{Activate, Button, RadioButton, RadioGroup, ValueChange},
|
ui_widgets::{Activate, Button, RadioButton, RadioGroup, ValueChange},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -83,6 +86,7 @@ impl Plugin for LobbyPlugin {
|
|||||||
session_visuals,
|
session_visuals,
|
||||||
sync_lobby_fields,
|
sync_lobby_fields,
|
||||||
style_privacy_options,
|
style_privacy_options,
|
||||||
|
style_lobby_focus,
|
||||||
)
|
)
|
||||||
.chain(),
|
.chain(),
|
||||||
);
|
);
|
||||||
@@ -101,6 +105,7 @@ fn setup_lobby(mut commands: Commands) {
|
|||||||
commands.spawn_scene(bsn! {
|
commands.spawn_scene(bsn! {
|
||||||
#LobbyUiRoot
|
#LobbyUiRoot
|
||||||
LobbyUiRoot
|
LobbyUiRoot
|
||||||
|
TabGroup::default()
|
||||||
DespawnOnExit::<AppState>(AppState::Lobby)
|
DespawnOnExit::<AppState>(AppState::Lobby)
|
||||||
Node {
|
Node {
|
||||||
width: percent(100),
|
width: percent(100),
|
||||||
@@ -148,6 +153,8 @@ fn build_lobby_ui(mut commands: Commands, root_ui: Single<Entity, With<LobbyUiRo
|
|||||||
),
|
),
|
||||||
(
|
(
|
||||||
RadioGroup
|
RadioGroup
|
||||||
|
AutoFocus
|
||||||
|
AutoDirectionalNavigation::default()
|
||||||
Node {
|
Node {
|
||||||
display: Display::Flex,
|
display: Display::Flex,
|
||||||
flex_direction: FlexDirection::Row,
|
flex_direction: FlexDirection::Row,
|
||||||
@@ -183,6 +190,8 @@ fn build_lobby_ui(mut commands: Commands, root_ui: Single<Entity, With<LobbyUiRo
|
|||||||
(
|
(
|
||||||
Button
|
Button
|
||||||
ResetPortButton
|
ResetPortButton
|
||||||
|
AutoDirectionalNavigation::default()
|
||||||
|
TabIndex(2)
|
||||||
Node {
|
Node {
|
||||||
width: px(600),
|
width: px(600),
|
||||||
height: px(130),
|
height: px(130),
|
||||||
@@ -230,6 +239,8 @@ fn build_lobby_ui(mut commands: Commands, root_ui: Single<Entity, With<LobbyUiRo
|
|||||||
BorderColor::from(Color::BLACK)
|
BorderColor::from(Color::BLACK)
|
||||||
InteractionColors::GOOD
|
InteractionColors::GOOD
|
||||||
Interaction
|
Interaction
|
||||||
|
AutoDirectionalNavigation::default()
|
||||||
|
TabIndex(4)
|
||||||
on(|_event: On<Activate>, mut lobby: ResMut<LobbyUiState>| {
|
on(|_event: On<Activate>, mut lobby: ResMut<LobbyUiState>| {
|
||||||
info!("Session clicked!");
|
info!("Session clicked!");
|
||||||
lobby.session_active = !lobby.session_active;
|
lobby.session_active = !lobby.session_active;
|
||||||
@@ -313,7 +324,8 @@ fn port_field() -> impl Scene {
|
|||||||
TextCursorStyle {
|
TextCursorStyle {
|
||||||
color: Color::BLACK,
|
color: Color::BLACK,
|
||||||
}
|
}
|
||||||
TabIndex(0)
|
AutoDirectionalNavigation::default()
|
||||||
|
TabIndex(1)
|
||||||
BackgroundColor(TEXT_INPUT_BACKGROUND)
|
BackgroundColor(TEXT_INPUT_BACKGROUND)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -339,7 +351,8 @@ fn password_field() -> impl Scene {
|
|||||||
TextCursorStyle {
|
TextCursorStyle {
|
||||||
color: Color::BLACK,
|
color: Color::BLACK,
|
||||||
}
|
}
|
||||||
TabIndex(1)
|
AutoDirectionalNavigation::default()
|
||||||
|
TabIndex(3)
|
||||||
BackgroundColor(TEXT_INPUT_BACKGROUND)
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn checked_option_is_styled() {
|
fn checked_option_is_styled() {
|
||||||
let mut world = World::new();
|
let mut world = World::new();
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ use bevy::{camera::ScalingMode, color::palettes::css::GREEN, prelude::*};
|
|||||||
use main_menu::MainMenuPlugin;
|
use main_menu::MainMenuPlugin;
|
||||||
use networking::NetworkingPlugin;
|
use networking::NetworkingPlugin;
|
||||||
|
|
||||||
|
use crate::focus_navigation::FocusNavigationPlugin;
|
||||||
use crate::{debug::DebugInspectorPlugin, lobby::LobbyPlugin, save::SavePlugin};
|
use crate::{debug::DebugInspectorPlugin, lobby::LobbyPlugin, save::SavePlugin};
|
||||||
|
|
||||||
mod debug;
|
mod debug;
|
||||||
|
mod focus_navigation;
|
||||||
mod lobby;
|
mod lobby;
|
||||||
mod main_menu;
|
mod main_menu;
|
||||||
mod networking;
|
mod networking;
|
||||||
@@ -32,6 +34,7 @@ fn main() {
|
|||||||
App::new()
|
App::new()
|
||||||
.add_plugins((
|
.add_plugins((
|
||||||
DefaultPlugins,
|
DefaultPlugins,
|
||||||
|
FocusNavigationPlugin,
|
||||||
MainMenuPlugin,
|
MainMenuPlugin,
|
||||||
NetworkingPlugin,
|
NetworkingPlugin,
|
||||||
SavePlugin,
|
SavePlugin,
|
||||||
|
|||||||
+202
-26
@@ -1,8 +1,14 @@
|
|||||||
|
use crate::focus_navigation::{NavigationInputState, NavigationSystems};
|
||||||
use crate::save::{ActiveGameSave, create_game_save, delete_game_save, get_saves};
|
use crate::save::{ActiveGameSave, create_game_save, delete_game_save, get_saves};
|
||||||
use crate::{AppState, WINDOW_TARGET_HEIGHT, WINDOW_TARGET_WIDTH};
|
use crate::{AppState, WINDOW_TARGET_HEIGHT, WINDOW_TARGET_WIDTH};
|
||||||
use bevy::color::palettes::tailwind::SLATE_900;
|
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::text::{EditableText, EditableTextFilter, TextCursorStyle};
|
||||||
|
use bevy::ui::auto_directional_navigation::AutoDirectionalNavigation;
|
||||||
use bevy::ui_widgets::Button;
|
use bevy::ui_widgets::Button;
|
||||||
use bevy::{prelude::*, ui_widgets::Activate};
|
use bevy::{prelude::*, ui_widgets::Activate};
|
||||||
use derive_more::{Deref, DerefMut};
|
use derive_more::{Deref, DerefMut};
|
||||||
@@ -16,17 +22,24 @@ impl Plugin for MainMenuPlugin {
|
|||||||
// .add_systems(Startup, enable_ui_debug)
|
// .add_systems(Startup, enable_ui_debug)
|
||||||
.add_systems(Update, (menu_redraw).run_if(in_state(AppState::Menu)))
|
.add_systems(Update, (menu_redraw).run_if(in_state(AppState::Menu)))
|
||||||
.add_observer(button_activate_observer)
|
.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;
|
const N_LAYERS: usize = 5;
|
||||||
|
|
||||||
/// The text field for the name of the game.
|
/// The text field for the name of the game.
|
||||||
#[derive(Component, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Default, Debug)]
|
#[derive(Component, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Default, Debug)]
|
||||||
struct GameNameTextField;
|
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)]
|
#[derive(Component, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Default, Debug)]
|
||||||
struct Selected;
|
struct Selected;
|
||||||
|
|
||||||
@@ -39,6 +52,8 @@ pub(crate) struct InteractionColors {
|
|||||||
selected: Color,
|
selected: Color,
|
||||||
selected_hovered: Color,
|
selected_hovered: Color,
|
||||||
selected_pressed: Color,
|
selected_pressed: Color,
|
||||||
|
pub(crate) default_outline: Color,
|
||||||
|
pub(crate) focused_outline: Color,
|
||||||
}
|
}
|
||||||
impl InteractionColors {
|
impl InteractionColors {
|
||||||
pub const DEFAULT: InteractionColors = InteractionColors {
|
pub const DEFAULT: InteractionColors = InteractionColors {
|
||||||
@@ -48,6 +63,8 @@ impl InteractionColors {
|
|||||||
selected: Color::srgb(0.93, 0.66, 0.06),
|
selected: Color::srgb(0.93, 0.66, 0.06),
|
||||||
selected_hovered: Color::srgb(1., 0.74, 0.16),
|
selected_hovered: Color::srgb(1., 0.74, 0.16),
|
||||||
selected_pressed: Color::srgb(0.86, 0.56, 0.),
|
selected_pressed: Color::srgb(0.86, 0.56, 0.),
|
||||||
|
default_outline: Color::BLACK,
|
||||||
|
focused_outline: Color::WHITE,
|
||||||
};
|
};
|
||||||
pub const GOOD: InteractionColors = InteractionColors {
|
pub const GOOD: InteractionColors = InteractionColors {
|
||||||
default: Color::srgb(0.25, 0.75, 0.25),
|
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: Color::srgb(0.35, 0.8, 0.35),
|
||||||
selected_hovered: Color::srgb(0.45, 0.9, 0.45),
|
selected_hovered: Color::srgb(0.45, 0.9, 0.45),
|
||||||
selected_pressed: Color::srgb(0.25, 0.7, 0.25),
|
selected_pressed: Color::srgb(0.25, 0.7, 0.25),
|
||||||
|
default_outline: Color::BLACK,
|
||||||
|
focused_outline: Color::WHITE,
|
||||||
};
|
};
|
||||||
pub const DANGER: InteractionColors = InteractionColors {
|
pub const DANGER: InteractionColors = InteractionColors {
|
||||||
default: Color::srgb(0.75, 0.25, 0.25),
|
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: Color::srgb(0.8, 0.3, 0.3),
|
||||||
selected_hovered: Color::srgb(0.9, 0.4, 0.4),
|
selected_hovered: Color::srgb(0.9, 0.4, 0.4),
|
||||||
selected_pressed: Color::srgb(0.7, 0.2, 0.2),
|
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 {
|
TextCursorStyle {
|
||||||
color: Color::BLACK,
|
color: Color::BLACK,
|
||||||
}
|
}
|
||||||
|
AutoDirectionalNavigation::default()
|
||||||
|
AutoFocus
|
||||||
TabIndex(0)
|
TabIndex(0)
|
||||||
BackgroundColor(Color::srgb(0.48, 0.84, 0.87))
|
BackgroundColor(Color::srgb(0.48, 0.84, 0.87))
|
||||||
),
|
),
|
||||||
@@ -396,6 +419,8 @@ fn game_info_menu(name: impl Into<String>) -> impl SceneList {
|
|||||||
TextCursorStyle {
|
TextCursorStyle {
|
||||||
color: Color::BLACK,
|
color: Color::BLACK,
|
||||||
}
|
}
|
||||||
|
AutoDirectionalNavigation::default()
|
||||||
|
AutoFocus
|
||||||
TabIndex(0)
|
TabIndex(0)
|
||||||
BackgroundColor(Color::srgb(0.48, 0.84, 0.87))
|
BackgroundColor(Color::srgb(0.48, 0.84, 0.87))
|
||||||
),
|
),
|
||||||
@@ -445,6 +470,7 @@ fn menu_layer(layer: usize) -> impl Scene {
|
|||||||
bsn! {
|
bsn! {
|
||||||
Name(name)
|
Name(name)
|
||||||
Layer(layer)
|
Layer(layer)
|
||||||
|
TabGroup::new(layer as i32)
|
||||||
Node {
|
Node {
|
||||||
width: percent(100.0 / N_LAYERS as f32),
|
width: percent(100.0 / N_LAYERS as f32),
|
||||||
height: percent(100),
|
height: percent(100),
|
||||||
@@ -460,6 +486,7 @@ fn full_menu_layer(layer: usize) -> impl Scene {
|
|||||||
bsn! {
|
bsn! {
|
||||||
Name(name)
|
Name(name)
|
||||||
Layer(layer)
|
Layer(layer)
|
||||||
|
TabGroup::new(-(layer as i32))
|
||||||
Node {
|
Node {
|
||||||
flex_direction: FlexDirection::Column,
|
flex_direction: FlexDirection::Column,
|
||||||
align_self: AlignSelf::FlexEnd,
|
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> {
|
fn menu_button(label: impl Into<String>, next_layer: Option<MenuLayer>) -> Box<dyn Scene> {
|
||||||
let label = label.into();
|
let label = label.into();
|
||||||
|
|
||||||
Box::new(bsn! {
|
Box::new(bsn! {
|
||||||
Button
|
Button
|
||||||
|
AutoDirectionalNavigation::default()
|
||||||
|
TabIndex(0)
|
||||||
Node {
|
Node {
|
||||||
width: px(600),
|
width: px(600),
|
||||||
height: px(130),
|
height: px(130),
|
||||||
@@ -527,7 +555,6 @@ fn button_activate_observer(
|
|||||||
// push next layer
|
// push next layer
|
||||||
menu.push(next_layer.0.clone());
|
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) {
|
for (entity, selected) in q_selected.iter_many(layer_entries) {
|
||||||
if entity == event.entity {
|
if entity == event.entity {
|
||||||
if !selected {
|
if !selected {
|
||||||
@@ -542,40 +569,28 @@ fn button_activate_observer(
|
|||||||
|
|
||||||
/// Styles the UI elements according to their [`InteractionColors`].
|
/// Styles the UI elements according to their [`InteractionColors`].
|
||||||
fn style_ui_elements(
|
fn style_ui_elements(
|
||||||
mut q_interaction_set: ParamSet<(
|
input_focus: Res<InputFocus>,
|
||||||
Query<
|
mut elements: Query<(
|
||||||
(
|
Entity,
|
||||||
&Interaction,
|
&Interaction,
|
||||||
&mut BackgroundColor,
|
&mut BackgroundColor,
|
||||||
&InteractionColors,
|
&InteractionColors,
|
||||||
Has<Selected>,
|
Has<Selected>,
|
||||||
),
|
|
||||||
(Or<(Changed<Interaction>, Added<Selected>)>, With<Button>),
|
|
||||||
>,
|
|
||||||
Query<(&Interaction, &mut BackgroundColor, &InteractionColors)>,
|
|
||||||
)>,
|
)>,
|
||||||
mut removed_selected: RemovedComponents<Selected>,
|
|
||||||
) {
|
) {
|
||||||
for (interaction, mut bg, colors, selected) in q_interaction_set.p0().iter_mut() {
|
let focused_entity = input_focus.get();
|
||||||
let new_color = match (selected, interaction) {
|
|
||||||
|
for (entity, interaction, mut bg, colors, selected) in &mut elements {
|
||||||
|
let new_color = match (selected, *interaction) {
|
||||||
(true, Interaction::Pressed) => colors.selected_pressed,
|
(true, Interaction::Pressed) => colors.selected_pressed,
|
||||||
(true, Interaction::Hovered) => colors.selected_hovered,
|
(true, Interaction::Hovered) => colors.selected_hovered,
|
||||||
(true, Interaction::None) => colors.selected,
|
(true, Interaction::None) => colors.selected,
|
||||||
(false, Interaction::Pressed) => colors.pressed,
|
(false, Interaction::Pressed) => colors.pressed,
|
||||||
(false, Interaction::Hovered) => colors.hovered,
|
(false, Interaction::Hovered) => colors.hovered,
|
||||||
|
(false, Interaction::None) if focused_entity == Some(entity) => colors.hovered,
|
||||||
(false, Interaction::None) => colors.default,
|
(false, Interaction::None) => colors.default,
|
||||||
};
|
};
|
||||||
bg.0 = new_color;
|
if 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,
|
|
||||||
};
|
|
||||||
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);
|
let scale_factor = f32::min(size.x / WINDOW_TARGET_WIDTH, size.y / WINDOW_TARGET_HEIGHT);
|
||||||
ui_scale.0 = scale_factor;
|
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