Tab navigation framework for bevy_input_focus. (#16795)
# Objective This PR continues the work of `bevy_input_focus` by adding a pluggable tab navigation framework. As part of this work, `FocusKeyboardEvent` now propagates to the window after exhausting all ancestors. ## Testing Unit tests and manual tests. --------- Co-authored-by: Alice Cecile <alice.i.cecile@gmail.com>
This commit is contained in:
parent
bf3692a011
commit
5c67cfc8b7
11
Cargo.toml
11
Cargo.toml
@ -3947,3 +3947,14 @@ doc-scrape-examples = true
|
||||
|
||||
[package.metadata.example.testbed_ui_layout_rounding]
|
||||
hidden = true
|
||||
|
||||
[[example]]
|
||||
name = "tab_navigation"
|
||||
path = "examples/ui/tab_navigation.rs"
|
||||
doc-scrape-examples = true
|
||||
|
||||
[package.metadata.example.tab_navigation]
|
||||
name = "Tab Navigation"
|
||||
description = "Demonstration of Tab Navigation"
|
||||
category = "UI (User Interface)"
|
||||
wasm = true
|
||||
|
@ -14,6 +14,7 @@ bevy_app = { path = "../bevy_app", version = "0.15.0-dev", default-features = fa
|
||||
bevy_ecs = { path = "../bevy_ecs", version = "0.15.0-dev", default-features = false }
|
||||
bevy_input = { path = "../bevy_input", version = "0.15.0-dev", default-features = false }
|
||||
bevy_hierarchy = { path = "../bevy_hierarchy", version = "0.15.0-dev", default-features = false }
|
||||
bevy_utils = { path = "../bevy_utils", version = "0.15.0-dev", default-features = false }
|
||||
bevy_window = { path = "../bevy_window", version = "0.15.0-dev", default-features = false }
|
||||
|
||||
[dev-dependencies]
|
||||
|
@ -16,18 +16,22 @@
|
||||
//! This crate does *not* provide any integration with UI widgets, or provide functions for
|
||||
//! tab navigation or gamepad-based focus navigation, as those are typically application-specific.
|
||||
|
||||
pub mod tab_navigation;
|
||||
|
||||
use bevy_app::{App, Plugin, PreUpdate};
|
||||
use bevy_ecs::{
|
||||
component::Component,
|
||||
entity::Entity,
|
||||
event::{Event, EventReader},
|
||||
query::With,
|
||||
query::{QueryData, With},
|
||||
system::{Commands, Query, Res, Resource, SystemParam},
|
||||
traversal::Traversal,
|
||||
world::{Command, DeferredWorld, World},
|
||||
};
|
||||
use bevy_hierarchy::{HierarchyQueryExt, Parent};
|
||||
use bevy_input::keyboard::KeyboardInput;
|
||||
use bevy_window::PrimaryWindow;
|
||||
use bevy_window::{PrimaryWindow, Window};
|
||||
use core::fmt::Debug;
|
||||
|
||||
/// Resource representing which entity has input focus, if any. Keyboard events will be
|
||||
/// dispatched to the current focus entity, or to the primary window if no entity has focus.
|
||||
@ -102,14 +106,43 @@ impl SetInputFocus for Commands<'_, '_> {
|
||||
/// input focus entity, if any. If no entity has input focus, then the event is dispatched to
|
||||
/// the main window.
|
||||
#[derive(Clone, Debug, Component)]
|
||||
pub struct FocusKeyboardInput(pub KeyboardInput);
|
||||
pub struct FocusKeyboardInput {
|
||||
/// The keyboard input event.
|
||||
pub input: KeyboardInput,
|
||||
window: Entity,
|
||||
}
|
||||
|
||||
impl Event for FocusKeyboardInput {
|
||||
type Traversal = &'static Parent;
|
||||
type Traversal = WindowTraversal;
|
||||
|
||||
const AUTO_PROPAGATE: bool = true;
|
||||
}
|
||||
|
||||
#[derive(QueryData)]
|
||||
/// These are for accessing components defined on the targeted entity
|
||||
pub struct WindowTraversal {
|
||||
parent: Option<&'static Parent>,
|
||||
window: Option<&'static Window>,
|
||||
}
|
||||
|
||||
impl Traversal<FocusKeyboardInput> for WindowTraversal {
|
||||
fn traverse(item: Self::Item<'_>, event: &FocusKeyboardInput) -> Option<Entity> {
|
||||
let WindowTraversalItem { parent, window } = item;
|
||||
|
||||
// Send event to parent, if it has one.
|
||||
if let Some(parent) = parent {
|
||||
return Some(parent.get());
|
||||
};
|
||||
|
||||
// Otherwise, send it to the window entity (unless this is a window entity).
|
||||
if window.is_none() {
|
||||
return Some(event.window);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Plugin which registers the system for dispatching keyboard events based on focus and
|
||||
/// hover state.
|
||||
pub struct InputDispatchPlugin;
|
||||
@ -130,17 +163,29 @@ fn dispatch_keyboard_input(
|
||||
windows: Query<Entity, With<PrimaryWindow>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
// If an element has keyboard focus, then dispatch the key event to that element.
|
||||
if let Some(focus_elt) = focus.0 {
|
||||
for ev in key_events.read() {
|
||||
commands.trigger_targets(FocusKeyboardInput(ev.clone()), focus_elt);
|
||||
}
|
||||
} else {
|
||||
// If no element has input focus, then dispatch the key event to the primary window.
|
||||
// There should be only one primary window.
|
||||
if let Ok(window) = windows.get_single() {
|
||||
if let Ok(window) = windows.get_single() {
|
||||
// If an element has keyboard focus, then dispatch the key event to that element.
|
||||
if let Some(focus_elt) = focus.0 {
|
||||
for ev in key_events.read() {
|
||||
commands.trigger_targets(FocusKeyboardInput(ev.clone()), window);
|
||||
commands.trigger_targets(
|
||||
FocusKeyboardInput {
|
||||
input: ev.clone(),
|
||||
window,
|
||||
},
|
||||
focus_elt,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// If no element has input focus, then dispatch the key event to the primary window.
|
||||
// There should be only one primary window.
|
||||
for ev in key_events.read() {
|
||||
commands.trigger_targets(
|
||||
FocusKeyboardInput {
|
||||
input: ev.clone(),
|
||||
window,
|
||||
},
|
||||
window,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -248,6 +293,7 @@ mod tests {
|
||||
keyboard::{Key, KeyCode},
|
||||
ButtonState, InputPlugin,
|
||||
};
|
||||
use bevy_window::WindowResolution;
|
||||
use smol_str::SmolStr;
|
||||
|
||||
#[derive(Component)]
|
||||
@ -266,7 +312,7 @@ mod tests {
|
||||
mut query: Query<&mut GatherKeyboardEvents>,
|
||||
) {
|
||||
if let Ok(mut gather) = query.get_mut(trigger.target()) {
|
||||
if let Key::Character(c) = &trigger.0.logical_key {
|
||||
if let Key::Character(c) = &trigger.input.logical_key {
|
||||
gather.0.push_str(c.as_str());
|
||||
}
|
||||
}
|
||||
@ -324,6 +370,12 @@ mod tests {
|
||||
app.add_plugins((InputPlugin, InputDispatchPlugin))
|
||||
.add_observer(gather_keyboard_events);
|
||||
|
||||
let window = Window {
|
||||
resolution: WindowResolution::new(800., 600.),
|
||||
..Default::default()
|
||||
};
|
||||
app.world_mut().spawn((window, PrimaryWindow));
|
||||
|
||||
let entity_a = app
|
||||
.world_mut()
|
||||
.spawn((GatherKeyboardEvents::default(), SetFocusOnAdd))
|
||||
|
335
crates/bevy_input_focus/src/tab_navigation.rs
Normal file
335
crates/bevy_input_focus/src/tab_navigation.rs
Normal file
@ -0,0 +1,335 @@
|
||||
//! This module provides a framework for handling linear tab-key navigation in Bevy applications.
|
||||
//!
|
||||
//! The rules of tabbing are derived from the HTML specification, and are as follows:
|
||||
//!
|
||||
//! * An index >= 0 means that the entity is tabbable via sequential navigation.
|
||||
//! The order of tabbing is determined by the index, with lower indices being tabbed first.
|
||||
//! If two entities have the same index, then the order is determined by the order of
|
||||
//! the entities in the ECS hierarchy (as determined by Parent/Child).
|
||||
//! * An index < 0 means that the entity is not focusable via sequential navigation, but
|
||||
//! can still be focused via direct selection.
|
||||
//!
|
||||
//! Tabbable entities must be descendants of a `TabGroup` entity, which is a component that
|
||||
//! marks a tree of entities as containing tabbable elements. The order of tab groups
|
||||
//! is determined by the `order` field, with lower orders being tabbed first. Modal tab groups
|
||||
//! are used for ui elements that should only tab within themselves, such as modal dialog boxes.
|
||||
//!
|
||||
//! There are several different ways to use this module. To enable automatic tabbing, add the
|
||||
//! `TabNavigationPlugin` to your app. (Make sure you also have `InputDispatchPlugin` installed).
|
||||
//! This will install a keyboard event observer on the primary window which automatically handles
|
||||
//! tab navigation for you.
|
||||
//!
|
||||
//! Alternatively, if you want to have more control over tab navigation, or are using an event
|
||||
//! mapping framework such as LWIM, you can use the `TabNavigation` helper object directly instead.
|
||||
//! This object can be injected into your systems, and provides a `navigate` method which can be
|
||||
//! used to navigate between focusable entities.
|
||||
//!
|
||||
//! This module also provides `AutoFocus`, a component which can be added to an entity to
|
||||
//! automatically focus it when it is added to the world.
|
||||
use bevy_app::{App, Plugin, Startup};
|
||||
use bevy_ecs::{
|
||||
component::{Component, ComponentId},
|
||||
entity::Entity,
|
||||
observer::Trigger,
|
||||
query::{With, Without},
|
||||
system::{Commands, Query, Res, ResMut, SystemParam},
|
||||
world::DeferredWorld,
|
||||
};
|
||||
use bevy_hierarchy::{Children, HierarchyQueryExt, Parent};
|
||||
use bevy_input::{keyboard::KeyCode, ButtonInput, ButtonState};
|
||||
use bevy_utils::tracing::warn;
|
||||
use bevy_window::PrimaryWindow;
|
||||
|
||||
use crate::{FocusKeyboardInput, InputFocus, InputFocusVisible, SetInputFocus};
|
||||
|
||||
/// A component which indicates that an entity wants to participate in tab navigation.
|
||||
///
|
||||
/// Note that you must also add the [`TabGroup`] component to the entity's ancestor in order
|
||||
/// for this component to have any effect.
|
||||
#[derive(Debug, Default, Component, Copy, Clone)]
|
||||
pub struct TabIndex(pub i32);
|
||||
|
||||
/// Indicates that this widget should automatically receive focus when it's added.
|
||||
#[derive(Debug, Default, Component, Copy, Clone)]
|
||||
pub struct AutoFocus;
|
||||
|
||||
/// A component used to mark a tree of entities as containing tabbable elements.
|
||||
#[derive(Debug, Default, Component, Copy, Clone)]
|
||||
pub struct TabGroup {
|
||||
/// The order of the tab group relative to other tab groups.
|
||||
pub order: i32,
|
||||
|
||||
/// Whether this is a 'modal' group. If true, then tabbing within the group (that is,
|
||||
/// if the current focus entity is a child of this group) will cycle through the children
|
||||
/// of this group. If false, then tabbing within the group will cycle through all non-modal
|
||||
/// tab groups.
|
||||
pub modal: bool,
|
||||
}
|
||||
|
||||
impl TabGroup {
|
||||
/// Create a new tab group with the given order.
|
||||
pub fn new(order: i32) -> Self {
|
||||
Self {
|
||||
order,
|
||||
modal: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a modal tab group.
|
||||
pub fn modal() -> Self {
|
||||
Self {
|
||||
order: 0,
|
||||
modal: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigation action for tabbing.
|
||||
pub enum NavAction {
|
||||
/// Navigate to the next focusable entity, wrapping around to the beginning if at the end.
|
||||
Next,
|
||||
/// Navigate to the previous focusable entity, wrapping around to the end if at the beginning.
|
||||
Previous,
|
||||
/// Navigate to the first focusable entity.
|
||||
First,
|
||||
/// Navigate to the last focusable entity.
|
||||
Last,
|
||||
}
|
||||
|
||||
/// An injectable helper object that provides tab navigation functionality.
|
||||
#[doc(hidden)]
|
||||
#[derive(SystemParam)]
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub struct TabNavigation<'w, 's> {
|
||||
// Query for tab groups.
|
||||
tabgroup_query: Query<'w, 's, (Entity, &'static TabGroup, &'static Children)>,
|
||||
// Query for tab indices.
|
||||
tabindex_query: Query<
|
||||
'w,
|
||||
's,
|
||||
(Entity, Option<&'static TabIndex>, Option<&'static Children>),
|
||||
Without<TabGroup>,
|
||||
>,
|
||||
// Query for parents.
|
||||
parent_query: Query<'w, 's, &'static Parent>,
|
||||
}
|
||||
|
||||
impl TabNavigation<'_, '_> {
|
||||
/// Navigate to the next focusable entity.
|
||||
///
|
||||
/// Arguments:
|
||||
/// * `focus`: The current focus entity, or `None` if no entity has focus.
|
||||
/// * `action`: Whether to select the next, previous, first, or last focusable entity.
|
||||
///
|
||||
/// If no focusable entities are found, then this function will return either the first
|
||||
/// or last focusable entity, depending on the direction of navigation. For example, if
|
||||
/// `action` is `Next` and no focusable entities are found, then this function will return
|
||||
/// the first focusable entity.
|
||||
pub fn navigate(&self, focus: Option<Entity>, action: NavAction) -> Option<Entity> {
|
||||
// If there are no tab groups, then there are no focusable entities.
|
||||
if self.tabgroup_query.is_empty() {
|
||||
warn!("No tab groups found");
|
||||
return None;
|
||||
}
|
||||
|
||||
// Start by identifying which tab group we are in. Mainly what we want to know is if
|
||||
// we're in a modal group.
|
||||
let tabgroup = focus.and_then(|focus_ent| {
|
||||
self.parent_query
|
||||
.iter_ancestors(focus_ent)
|
||||
.find_map(|entity| {
|
||||
self.tabgroup_query
|
||||
.get(entity)
|
||||
.ok()
|
||||
.map(|(_, tg, _)| (entity, tg))
|
||||
})
|
||||
});
|
||||
|
||||
if focus.is_some() && tabgroup.is_none() {
|
||||
warn!("No tab group found for focus entity");
|
||||
return None;
|
||||
}
|
||||
|
||||
self.navigate_in_group(tabgroup, focus, action)
|
||||
}
|
||||
|
||||
fn navigate_in_group(
|
||||
&self,
|
||||
tabgroup: Option<(Entity, &TabGroup)>,
|
||||
focus: Option<Entity>,
|
||||
action: NavAction,
|
||||
) -> Option<Entity> {
|
||||
// List of all focusable entities found.
|
||||
let mut focusable: Vec<(Entity, TabIndex)> =
|
||||
Vec::with_capacity(self.tabindex_query.iter().len());
|
||||
|
||||
match tabgroup {
|
||||
Some((tg_entity, tg)) if tg.modal => {
|
||||
// We're in a modal tab group, then gather all tab indices in that group.
|
||||
if let Ok((_, _, children)) = self.tabgroup_query.get(tg_entity) {
|
||||
for child in children.iter() {
|
||||
self.gather_focusable(&mut focusable, *child);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Otherwise, gather all tab indices in all non-modal tab groups.
|
||||
let mut tab_groups: Vec<(Entity, TabGroup)> = self
|
||||
.tabgroup_query
|
||||
.iter()
|
||||
.filter(|(_, tg, _)| !tg.modal)
|
||||
.map(|(e, tg, _)| (e, *tg))
|
||||
.collect();
|
||||
// Stable sort by group order
|
||||
tab_groups.sort_by(compare_tab_groups);
|
||||
|
||||
// Search group descendants
|
||||
tab_groups.iter().for_each(|(tg_entity, _)| {
|
||||
self.gather_focusable(&mut focusable, *tg_entity);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if focusable.is_empty() {
|
||||
warn!("No focusable entities found");
|
||||
return None;
|
||||
}
|
||||
|
||||
// Stable sort by tabindex
|
||||
focusable.sort_by(compare_tab_indices);
|
||||
|
||||
let index = focusable.iter().position(|e| Some(e.0) == focus);
|
||||
let count = focusable.len();
|
||||
let next = match (index, action) {
|
||||
(Some(idx), NavAction::Next) => (idx + 1).rem_euclid(count),
|
||||
(Some(idx), NavAction::Previous) => (idx + count - 1).rem_euclid(count),
|
||||
(None, NavAction::Next) | (_, NavAction::First) => 0,
|
||||
(None, NavAction::Previous) | (_, NavAction::Last) => count - 1,
|
||||
};
|
||||
focusable.get(next).map(|(e, _)| e).copied()
|
||||
}
|
||||
|
||||
/// Gather all focusable entities in tree order.
|
||||
fn gather_focusable(&self, out: &mut Vec<(Entity, TabIndex)>, parent: Entity) {
|
||||
if let Ok((entity, tabindex, children)) = self.tabindex_query.get(parent) {
|
||||
if let Some(tabindex) = tabindex {
|
||||
if tabindex.0 >= 0 {
|
||||
out.push((entity, *tabindex));
|
||||
}
|
||||
}
|
||||
if let Some(children) = children {
|
||||
for child in children.iter() {
|
||||
// Don't traverse into tab groups, as they are handled separately.
|
||||
if self.tabgroup_query.get(*child).is_err() {
|
||||
self.gather_focusable(out, *child);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Ok((_, tabgroup, children)) = self.tabgroup_query.get(parent) {
|
||||
if !tabgroup.modal {
|
||||
for child in children.iter() {
|
||||
self.gather_focusable(out, *child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn compare_tab_groups(a: &(Entity, TabGroup), b: &(Entity, TabGroup)) -> core::cmp::Ordering {
|
||||
a.1.order.cmp(&b.1.order)
|
||||
}
|
||||
|
||||
// Stable sort which compares by tab index
|
||||
fn compare_tab_indices(a: &(Entity, TabIndex), b: &(Entity, TabIndex)) -> core::cmp::Ordering {
|
||||
a.1 .0.cmp(&b.1 .0)
|
||||
}
|
||||
|
||||
/// Plugin for handling keyboard input.
|
||||
pub struct TabNavigationPlugin;
|
||||
|
||||
impl Plugin for TabNavigationPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(Startup, setup_tab_navigation);
|
||||
app.world_mut()
|
||||
.register_component_hooks::<AutoFocus>()
|
||||
.on_add(on_auto_focus_added);
|
||||
}
|
||||
}
|
||||
|
||||
fn setup_tab_navigation(mut commands: Commands, window: Query<Entity, With<PrimaryWindow>>) {
|
||||
for window in window.iter() {
|
||||
commands.entity(window).observe(handle_tab_navigation);
|
||||
}
|
||||
}
|
||||
|
||||
/// Observer function which handles tab navigation.
|
||||
pub fn handle_tab_navigation(
|
||||
mut trigger: Trigger<FocusKeyboardInput>,
|
||||
nav: TabNavigation,
|
||||
mut focus: ResMut<InputFocus>,
|
||||
mut visible: ResMut<InputFocusVisible>,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
) {
|
||||
// Tab navigation.
|
||||
let key_event = &trigger.event().input;
|
||||
if key_event.key_code == KeyCode::Tab
|
||||
&& key_event.state == ButtonState::Pressed
|
||||
&& !key_event.repeat
|
||||
{
|
||||
let next = nav.navigate(
|
||||
focus.0,
|
||||
if keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight) {
|
||||
NavAction::Previous
|
||||
} else {
|
||||
NavAction::Next
|
||||
},
|
||||
);
|
||||
if next.is_some() {
|
||||
trigger.propagate(false);
|
||||
focus.0 = next;
|
||||
visible.0 = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_auto_focus_added(mut world: DeferredWorld, entity: Entity, _: ComponentId) {
|
||||
if world.entity(entity).contains::<TabIndex>() {
|
||||
world.set_input_focus(entity);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use bevy_ecs::system::SystemState;
|
||||
use bevy_hierarchy::BuildChildren;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_tab_navigation() {
|
||||
let mut app = App::new();
|
||||
let world = app.world_mut();
|
||||
|
||||
let tab_entity_1 = world.spawn(TabIndex(0)).id();
|
||||
let tab_entity_2 = world.spawn(TabIndex(1)).id();
|
||||
let mut tab_group_entity = world.spawn(TabGroup::new(0));
|
||||
tab_group_entity.replace_children(&[tab_entity_1, tab_entity_2]);
|
||||
|
||||
let mut system_state: SystemState<TabNavigation> = SystemState::new(world);
|
||||
let tab_navigation = system_state.get(world);
|
||||
assert_eq!(tab_navigation.tabgroup_query.iter().count(), 1);
|
||||
assert_eq!(tab_navigation.tabindex_query.iter().count(), 2);
|
||||
|
||||
let next_entity = tab_navigation.navigate(Some(tab_entity_1), NavAction::Next);
|
||||
assert_eq!(next_entity, Some(tab_entity_2));
|
||||
|
||||
let prev_entity = tab_navigation.navigate(Some(tab_entity_2), NavAction::Previous);
|
||||
assert_eq!(prev_entity, Some(tab_entity_1));
|
||||
|
||||
let first_entity = tab_navigation.navigate(None, NavAction::First);
|
||||
assert_eq!(first_entity, Some(tab_entity_1));
|
||||
|
||||
let last_entity = tab_navigation.navigate(None, NavAction::Last);
|
||||
assert_eq!(last_entity, Some(tab_entity_2));
|
||||
}
|
||||
}
|
@ -518,6 +518,7 @@ Example | Description
|
||||
[Render UI to Texture](../examples/ui/render_ui_to_texture.rs) | An example of rendering UI as a part of a 3D world
|
||||
[Scroll](../examples/ui/scroll.rs) | Demonstrates scrolling UI containers
|
||||
[Size Constraints](../examples/ui/size_constraints.rs) | Demonstrates how the to use the size constraints to control the size of a UI node.
|
||||
[Tab Navigation](../examples/ui/tab_navigation.rs) | Demonstration of Tab Navigation
|
||||
[Text](../examples/ui/text.rs) | Illustrates creating and updating text
|
||||
[Text Debug](../examples/ui/text_debug.rs) | An example for debugging text layout
|
||||
[Text Wrap Debug](../examples/ui/text_wrap_debug.rs) | Demonstrates text wrapping
|
||||
|
221
examples/ui/tab_navigation.rs
Normal file
221
examples/ui/tab_navigation.rs
Normal file
@ -0,0 +1,221 @@
|
||||
//! This example illustrates the use of tab navigation.
|
||||
|
||||
use bevy::{
|
||||
color::palettes::basic::*,
|
||||
input_focus::{
|
||||
tab_navigation::{TabGroup, TabIndex, TabNavigationPlugin},
|
||||
InputDispatchPlugin, InputFocus,
|
||||
},
|
||||
prelude::*,
|
||||
winit::WinitSettings,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
App::new()
|
||||
.add_plugins((DefaultPlugins, InputDispatchPlugin, TabNavigationPlugin))
|
||||
// Only run the app when there is user input. This will significantly reduce CPU/GPU use.
|
||||
.insert_resource(WinitSettings::desktop_app())
|
||||
.add_systems(Startup, setup)
|
||||
.add_systems(Update, (button_system, focus_system))
|
||||
.run();
|
||||
}
|
||||
|
||||
const NORMAL_BUTTON: Color = Color::srgb(0.15, 0.15, 0.15);
|
||||
const HOVERED_BUTTON: Color = Color::srgb(0.25, 0.25, 0.25);
|
||||
const PRESSED_BUTTON: Color = Color::srgb(0.35, 0.75, 0.35);
|
||||
|
||||
fn button_system(
|
||||
mut interaction_query: Query<
|
||||
(
|
||||
&Interaction,
|
||||
&mut BackgroundColor,
|
||||
&mut BorderColor,
|
||||
&Children,
|
||||
),
|
||||
(Changed<Interaction>, With<Button>),
|
||||
>,
|
||||
mut text_query: Query<&mut Text>,
|
||||
) {
|
||||
for (interaction, mut color, mut border_color, children) in &mut interaction_query {
|
||||
let mut text = text_query.get_mut(children[0]).unwrap();
|
||||
match *interaction {
|
||||
Interaction::Pressed => {
|
||||
**text = "Press".to_string();
|
||||
*color = PRESSED_BUTTON.into();
|
||||
border_color.0 = RED.into();
|
||||
}
|
||||
Interaction::Hovered => {
|
||||
**text = "Hover".to_string();
|
||||
*color = HOVERED_BUTTON.into();
|
||||
border_color.0 = Color::WHITE;
|
||||
}
|
||||
Interaction::None => {
|
||||
**text = "Button".to_string();
|
||||
*color = NORMAL_BUTTON.into();
|
||||
border_color.0 = Color::BLACK;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn focus_system(
|
||||
mut commands: Commands,
|
||||
focus: Res<InputFocus>,
|
||||
mut query: Query<Entity, With<Button>>,
|
||||
) {
|
||||
if focus.is_changed() {
|
||||
for button in query.iter_mut() {
|
||||
if focus.0 == Some(button) {
|
||||
commands.entity(button).insert(Outline {
|
||||
color: Color::WHITE,
|
||||
width: Val::Px(2.0),
|
||||
offset: Val::Px(2.0),
|
||||
});
|
||||
} else {
|
||||
commands.entity(button).remove::<Outline>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
|
||||
// ui camera
|
||||
commands.spawn(Camera2d);
|
||||
commands
|
||||
.spawn(Node {
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Percent(100.0),
|
||||
display: Display::Flex,
|
||||
flex_direction: FlexDirection::Column,
|
||||
align_items: AlignItems::Center,
|
||||
justify_content: JustifyContent::Center,
|
||||
..default()
|
||||
})
|
||||
.observe(
|
||||
|mut trigger: Trigger<Pointer<Click>>, mut focus: ResMut<InputFocus>| {
|
||||
focus.0 = None;
|
||||
trigger.propagate(false);
|
||||
},
|
||||
)
|
||||
.with_children(|parent| {
|
||||
parent.spawn(Text::new("Tab Group 0"));
|
||||
parent
|
||||
.spawn((
|
||||
Node {
|
||||
display: Display::Flex,
|
||||
flex_direction: FlexDirection::Row,
|
||||
column_gap: Val::Px(6.0),
|
||||
margin: UiRect {
|
||||
bottom: Val::Px(10.0),
|
||||
..default()
|
||||
},
|
||||
..default()
|
||||
},
|
||||
TabGroup::new(0),
|
||||
))
|
||||
.with_children(|parent| {
|
||||
create_button(parent, &asset_server);
|
||||
create_button(parent, &asset_server);
|
||||
create_button(parent, &asset_server);
|
||||
create_button(parent, &asset_server);
|
||||
});
|
||||
|
||||
parent.spawn(Text::new("Tab Group 2"));
|
||||
parent
|
||||
.spawn((
|
||||
Node {
|
||||
display: Display::Flex,
|
||||
flex_direction: FlexDirection::Row,
|
||||
column_gap: Val::Px(6.0),
|
||||
margin: UiRect {
|
||||
bottom: Val::Px(10.0),
|
||||
..default()
|
||||
},
|
||||
..default()
|
||||
},
|
||||
TabGroup::new(2),
|
||||
))
|
||||
.with_children(|parent| {
|
||||
create_button(parent, &asset_server);
|
||||
create_button(parent, &asset_server);
|
||||
create_button(parent, &asset_server);
|
||||
create_button(parent, &asset_server);
|
||||
});
|
||||
|
||||
parent.spawn(Text::new("Tab Group 1"));
|
||||
parent
|
||||
.spawn((
|
||||
Node {
|
||||
display: Display::Flex,
|
||||
flex_direction: FlexDirection::Row,
|
||||
column_gap: Val::Px(6.0),
|
||||
margin: UiRect {
|
||||
bottom: Val::Px(10.0),
|
||||
..default()
|
||||
},
|
||||
..default()
|
||||
},
|
||||
TabGroup::new(1),
|
||||
))
|
||||
.with_children(|parent| {
|
||||
create_button(parent, &asset_server);
|
||||
create_button(parent, &asset_server);
|
||||
create_button(parent, &asset_server);
|
||||
create_button(parent, &asset_server);
|
||||
});
|
||||
|
||||
parent.spawn(Text::new("Modal Tab Group"));
|
||||
parent
|
||||
.spawn((
|
||||
Node {
|
||||
display: Display::Flex,
|
||||
flex_direction: FlexDirection::Row,
|
||||
column_gap: Val::Px(6.0),
|
||||
..default()
|
||||
},
|
||||
TabGroup::modal(),
|
||||
))
|
||||
.with_children(|parent| {
|
||||
create_button(parent, &asset_server);
|
||||
create_button(parent, &asset_server);
|
||||
create_button(parent, &asset_server);
|
||||
create_button(parent, &asset_server);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn create_button(parent: &mut ChildBuilder<'_>, asset_server: &AssetServer) {
|
||||
parent
|
||||
.spawn((
|
||||
Button,
|
||||
Node {
|
||||
width: Val::Px(150.0),
|
||||
height: Val::Px(65.0),
|
||||
border: UiRect::all(Val::Px(5.0)),
|
||||
// horizontally center child text
|
||||
justify_content: JustifyContent::Center,
|
||||
// vertically center child text
|
||||
align_items: AlignItems::Center,
|
||||
..default()
|
||||
},
|
||||
BorderColor(Color::BLACK),
|
||||
BorderRadius::MAX,
|
||||
BackgroundColor(NORMAL_BUTTON),
|
||||
TabIndex(0),
|
||||
))
|
||||
.observe(
|
||||
|mut trigger: Trigger<Pointer<Click>>, mut focus: ResMut<InputFocus>| {
|
||||
focus.0 = Some(trigger.target());
|
||||
trigger.propagate(false);
|
||||
},
|
||||
)
|
||||
.with_child((
|
||||
Text::new("Button"),
|
||||
TextFont {
|
||||
font: asset_server.load("fonts/FiraSans-Bold.ttf"),
|
||||
font_size: 23.0,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::srgb(0.9, 0.9, 0.9)),
|
||||
));
|
||||
}
|
Loading…
Reference in New Issue
Block a user