diff --git a/Cargo.toml b/Cargo.toml index 0c160b3aea850..43734f62b11ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5364,6 +5364,17 @@ description = "Demonstrates use of core (headless) widgets in Bevy UI, with Obse category = "UI (User Interface)" wasm = true +[[example]] +name = "spinbox_months" +path = "examples/ui/widgets/spinbox_months.rs" +doc-scrape-examples = true + +[package.metadata.example.spinbox_months] +name = "Spinbox Months" +description = "Demonstrates using the headless spinbox with a Month enum and a non-editable text field" +category = "UI (User Interface)" +wasm = true + [[example]] name = "scrollbars" path = "examples/ui/scroll_and_overflow/scrollbars.rs" diff --git a/crates/bevy_ui_widgets/src/lib.rs b/crates/bevy_ui_widgets/src/lib.rs index ca1b10b0f533c..3a59e4d5c0278 100644 --- a/crates/bevy_ui_widgets/src/lib.rs +++ b/crates/bevy_ui_widgets/src/lib.rs @@ -34,6 +34,7 @@ pub mod popover; mod radio; mod scrollbar; mod slider; +mod spinbox; mod text_input; pub use button::*; @@ -43,6 +44,7 @@ pub use observe::*; pub use radio::*; pub use scrollbar::*; pub use slider::*; +pub use spinbox::*; pub use text_input::*; use bevy_app::{PluginGroup, PluginGroupBuilder}; @@ -65,6 +67,7 @@ impl PluginGroup for UiWidgetsPlugins { .add(RadioGroupPlugin) .add(ScrollbarPlugin) .add(SliderPlugin) + .add(SpinBoxPlugin) .add(EditableTextInputPlugin) } } diff --git a/crates/bevy_ui_widgets/src/spinbox.rs b/crates/bevy_ui_widgets/src/spinbox.rs new file mode 100644 index 0000000000000..cac23e0ae326d --- /dev/null +++ b/crates/bevy_ui_widgets/src/spinbox.rs @@ -0,0 +1,289 @@ +use bevy_app::{App, Plugin}; +use bevy_ecs::{ + component::Component, + entity::Entity, + event::EntityEvent, + hierarchy::ChildOf, + observer::On, + query::With, + relationship::Relationship, + system::{Commands, Query}, +}; +use bevy_input::keyboard::{KeyCode, KeyboardInput}; +use bevy_input::ButtonState; +use bevy_input_focus::FocusedInput; + +use crate::{Activate, Button}; + +/// Headless spinbox container. +/// +/// A spinbox composes increment and decrement buttons and emits direction intent from the spinbox +/// root. It does not assume a particular value type or editing surface. +/// +/// ```ignore +/// use bevy_ecs::prelude::*; +/// use bevy_ui_widgets::{ +/// SpinBox, SpinBoxButtonPress, SpinBoxDecrementButton, SpinBoxDirection, +/// SpinBoxIncrementButton, +/// }; +/// +/// let spinbox = commands.spawn(SpinBox).id(); +/// commands.spawn((SpinBoxDecrementButton, ChildOf(spinbox))); +/// commands.spawn((SpinBoxIncrementButton, ChildOf(spinbox))); +/// +/// commands.entity(spinbox).observe(|press: On| match press.direction { +/// SpinBoxDirection::Increment => info!("next"), +/// SpinBoxDirection::Decrement => info!("previous"), +/// }); +/// ``` +#[derive(Component, Debug, Default, Clone, Copy)] +pub struct SpinBox; + +/// The direction requested by a spinbox button activation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SpinBoxDirection { + /// Move to the next value. + Increment, + /// Move to the previous value. + Decrement, +} + +/// Marks the increment button of a [`SpinBox`]. +#[derive(Component, Debug, Default, Clone, Copy)] +#[require(Button)] +pub struct SpinBoxIncrementButton; + +/// Marks the decrement button of a [`SpinBox`]. +#[derive(Component, Debug, Default, Clone, Copy)] +#[require(Button)] +pub struct SpinBoxDecrementButton; + +/// Emitted when one of a spinbox's buttons is activated. +/// +/// This always targets the [`SpinBox`] root entity, so apps can use it for arbitrary value +/// domains such as enums or wrap it with more specialized adapters. +#[derive(Clone, Copy, Debug, PartialEq, Eq, EntityEvent)] +pub struct SpinBoxButtonPress { + /// The spinbox entity. + #[event_target] + pub entity: Entity, + /// The requested direction. + pub direction: SpinBoxDirection, +} + +pub(crate) fn spinbox_direction_for_key(key_code: KeyCode) -> Option { + match key_code { + KeyCode::ArrowUp | KeyCode::ArrowRight => Some(SpinBoxDirection::Increment), + KeyCode::ArrowDown | KeyCode::ArrowLeft => Some(SpinBoxDirection::Decrement), + _ => None, + } +} + +fn spinbox_on_activate( + activate: On, + q_increment: Query<(), With>, + q_decrement: Query<(), With>, + q_parent: Query<&ChildOf>, + q_spinbox: Query<(), With>, + mut commands: Commands, +) { + let button = activate.event_target(); + let direction = if q_increment.contains(button) { + SpinBoxDirection::Increment + } else if q_decrement.contains(button) { + SpinBoxDirection::Decrement + } else { + return; + }; + + let Some(spinbox) = find_spinbox_ancestor(button, &q_parent, &q_spinbox) else { + return; + }; + + commands.trigger(SpinBoxButtonPress { + entity: spinbox, + direction, + }); +} + +fn spinbox_on_key_input( + mut key_input: On>, + q_spinbox: Query<(), With>, + q_parent: Query<&ChildOf>, + mut commands: Commands, +) { + let input_event = &key_input.input; + if input_event.state != ButtonState::Pressed || input_event.repeat { + return; + } + + let Some(direction) = spinbox_direction_for_key(input_event.key_code) else { + return; + }; + + let spinbox = if q_spinbox.contains(key_input.focused_entity) { + Some(key_input.focused_entity) + } else { + find_spinbox_ancestor(key_input.focused_entity, &q_parent, &q_spinbox) + }; + + let Some(spinbox) = spinbox else { + return; + }; + + key_input.propagate(false); + commands.trigger(SpinBoxButtonPress { + entity: spinbox, + direction, + }); +} + +pub(crate) fn find_spinbox_ancestor( + entity: Entity, + q_parent: &Query<&ChildOf>, + q_spinbox: &Query<(), With>, +) -> Option { + let mut current = entity; + while let Ok(parent) = q_parent.get(current) { + let parent = parent.get(); + if q_spinbox.contains(parent) { + return Some(parent); + } + current = parent; + } + None +} + +/// Plugin that adds observers for [`SpinBox`]. +pub struct SpinBoxPlugin; + +impl Plugin for SpinBoxPlugin { + fn build(&self, app: &mut App) { + app.add_observer(spinbox_on_activate) + .add_observer(spinbox_on_key_input); + } +} + +#[cfg(test)] +mod tests { + use bevy_app::App; + use bevy_ecs::{observer::On, prelude::*}; + use bevy_input::{ + keyboard::{Key, KeyboardInput}, + ButtonState, InputPlugin, + }; + use bevy_input_focus::{InputDispatchPlugin, InputFocus, InputFocusPlugin}; + use bevy_window::{PrimaryWindow, Window}; + + use super::*; + + #[derive(Resource, Default)] + struct SpinBoxDirections(Vec<(Entity, SpinBoxDirection)>); + + fn keyboard_input(key_code: KeyCode) -> KeyboardInput { + KeyboardInput { + key_code, + logical_key: match key_code { + KeyCode::ArrowUp => Key::ArrowUp, + KeyCode::ArrowDown => Key::ArrowDown, + KeyCode::ArrowLeft => Key::ArrowLeft, + KeyCode::ArrowRight => Key::ArrowRight, + _ => unreachable!(), + }, + state: ButtonState::Pressed, + text: None, + repeat: false, + window: Entity::PLACEHOLDER, + } + } + + #[test] + fn spinbox_emits_increment_button_press_from_root() { + let mut app = App::new(); + app.init_resource::() + .add_plugins((crate::ButtonPlugin, SpinBoxPlugin)) + .add_observer( + |press: On, mut directions: ResMut| { + directions.0.push((press.entity, press.direction)); + }, + ); + + let spinbox = app.world_mut().spawn(SpinBox).id(); + let increment = app + .world_mut() + .spawn((SpinBoxIncrementButton, ChildOf(spinbox))) + .id(); + + app.world_mut() + .commands() + .trigger(Activate { entity: increment }); + app.update(); + + assert_eq!( + app.world().resource::().0, + vec![(spinbox, SpinBoxDirection::Increment)] + ); + } + + #[test] + fn spinbox_emits_decrement_button_press_without_value_input() { + let mut app = App::new(); + app.init_resource::() + .add_plugins((crate::ButtonPlugin, SpinBoxPlugin)) + .add_observer( + |press: On, mut directions: ResMut| { + directions.0.push((press.entity, press.direction)); + }, + ); + + let spinbox = app.world_mut().spawn(SpinBox).id(); + let decrement = app + .world_mut() + .spawn((SpinBoxDecrementButton, ChildOf(spinbox))) + .id(); + + app.world_mut() + .commands() + .trigger(Activate { entity: decrement }); + app.update(); + + assert_eq!( + app.world().resource::().0, + vec![(spinbox, SpinBoxDirection::Decrement)] + ); + } + + #[test] + fn spinbox_emits_button_press_for_focused_descendant_arrow_keys() { + let mut app = App::new(); + app.init_resource::() + .add_plugins(( + InputPlugin, + InputFocusPlugin, + InputDispatchPlugin, + SpinBoxPlugin, + )) + .add_observer( + |press: On, mut directions: ResMut| { + directions.0.push((press.entity, press.direction)); + }, + ); + app.world_mut().spawn((Window::default(), PrimaryWindow)); + app.update(); + + let spinbox = app.world_mut().spawn(SpinBox).id(); + let focused_child = app.world_mut().spawn(ChildOf(spinbox)).id(); + app.world_mut() + .insert_resource(InputFocus::from_entity(focused_child)); + + app.world_mut() + .write_message(keyboard_input(KeyCode::ArrowRight)) + .unwrap(); + app.update(); + + assert_eq!( + app.world().resource::().0, + vec![(spinbox, SpinBoxDirection::Increment)] + ); + } +} diff --git a/examples/README.md b/examples/README.md index 0871132574246..d084ca0e13c1c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -622,6 +622,7 @@ Example | Description [Scroll](../examples/ui/scroll_and_overflow/scroll.rs) | Demonstrates scrolling UI containers [Scrollbars](../examples/ui/scroll_and_overflow/scrollbars.rs) | Demonstrates use of core scrollbar in Bevy UI [Size Constraints](../examples/ui/layout/size_constraints.rs) | Demonstrates how the to use the size constraints to control the size of a UI node. +[Spinbox Months](../examples/ui/widgets/spinbox_months.rs) | Demonstrates using the headless spinbox with a Month enum and a non-editable text field [Stacked Gradients](../examples/ui/styling/stacked_gradients.rs) | An example demonstrating stacked gradients [Standard Widgets](../examples/ui/widgets/standard_widgets.rs) | Demonstrates use of core (headless) widgets in Bevy UI [Standard Widgets (w/Observers)](../examples/ui/widgets/standard_widgets_observers.rs) | Demonstrates use of core (headless) widgets in Bevy UI, with Observers diff --git a/examples/ui/widgets/spinbox_months.rs b/examples/ui/widgets/spinbox_months.rs new file mode 100644 index 0000000000000..8a6949c2e7b92 --- /dev/null +++ b/examples/ui/widgets/spinbox_months.rs @@ -0,0 +1,223 @@ +//! Demonstrates using the headless [`SpinBox`](bevy::ui_widgets::SpinBox) with a non-numeric +//! value type by cycling through a `Month` enum. + +use bevy::{ + ecs::relationship::Relationship, + input_focus::{ + tab_navigation::{TabGroup, TabIndex, TabNavigationPlugin}, + InputFocus, + }, + picking::hover::Hovered, + prelude::*, + ui_widgets::{ + observe, Button, SpinBox, SpinBoxButtonPress, SpinBoxDecrementButton, SpinBoxDirection, + SpinBoxIncrementButton, + }, +}; + +fn main() { + App::new() + .add_plugins((DefaultPlugins, TabNavigationPlugin)) + .init_resource::() + .add_systems(Startup, setup) + .add_systems(Update, (update_button_style, update_month_display)) + .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); + +#[derive(Component)] +struct MonthSpinBox; + +#[derive(Component)] +struct MonthDisplay; + +#[derive(Component)] +struct DemoButton; + +#[derive(Component, Clone, Copy)] +struct MonthValue(Month); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Month { + January, + February, + March, + April, + May, + June, + July, + August, + September, + October, + November, + December, +} + +impl Month { + const ALL: [Self; 12] = [ + Self::January, + Self::February, + Self::March, + Self::April, + Self::May, + Self::June, + Self::July, + Self::August, + Self::September, + Self::October, + Self::November, + Self::December, + ]; + + fn advance(self, direction: SpinBoxDirection) -> Self { + let index = Self::ALL.iter().position(|month| *month == self).unwrap(); + let next = match direction { + SpinBoxDirection::Increment => (index + 1) % Self::ALL.len(), + SpinBoxDirection::Decrement => (index + Self::ALL.len() - 1) % Self::ALL.len(), + }; + Self::ALL[next] + } + + fn label(self) -> &'static str { + match self { + Self::January => "January", + Self::February => "February", + Self::March => "March", + Self::April => "April", + Self::May => "May", + Self::June => "June", + Self::July => "July", + Self::August => "August", + Self::September => "September", + Self::October => "October", + Self::November => "November", + Self::December => "December", + } + } +} + +fn setup(mut commands: Commands, asset_server: Res) { + commands.spawn(Camera2d); + + commands.spawn(( + Node { + width: percent(100), + height: percent(100), + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + ..default() + }, + TabGroup::default(), + children![( + Node { + width: px(220), + height: px(54), + padding: UiRect::all(px(4)), + border: UiRect::all(px(2)), + column_gap: px(6), + justify_content: JustifyContent::SpaceBetween, + align_items: AlignItems::Center, + ..default() + }, + BorderColor::all(Color::WHITE), + BackgroundColor(Color::BLACK), + MonthSpinBox, + MonthValue(Month::January), + SpinBox, + TabIndex(0), + observe( + |press: On, + mut spinboxes: Query<&mut MonthValue, With>| { + if let Ok(mut month) = spinboxes.get_mut(press.entity) { + month.0 = month.0.advance(press.direction); + } + }, + ), + children![ + ( + Node { + flex_grow: 1.0, + padding: UiRect::horizontal(px(10)), + border: UiRect::all(px(1)), + align_items: AlignItems::Center, + ..default() + }, + BorderColor::all(Color::srgb(0.3, 0.3, 0.3)), + BackgroundColor(Color::srgb(0.08, 0.08, 0.08)), + MonthDisplay, + Text::new(Month::January.label()), + TextFont { + font: asset_server.load("fonts/FiraSans-Bold.ttf").into(), + font_size: FontSize::Px(24.0), + ..default() + }, + TextColor(Color::WHITE), + ), + ( + Node { + width: px(36), + height: percent(100), + flex_direction: FlexDirection::Column, + row_gap: px(4), + ..default() + }, + children![ + demo_button("+", SpinBoxIncrementButton), + demo_button("-", SpinBoxDecrementButton), + ], + ), + ], + )], + )); +} + +fn demo_button(label: &'static str, marker: M) -> impl Bundle { + ( + DemoButton, + marker, + Button, + Hovered::default(), + Node { + flex_grow: 1.0, + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + border: UiRect::all(px(1)), + ..default() + }, + BorderColor::all(Color::WHITE), + BackgroundColor(NORMAL_BUTTON), + children![( + Text::new(label), + TextFont { + font_size: FontSize::Px(18.0), + ..default() + }, + TextColor(Color::WHITE), + )], + ) +} + +fn update_button_style( + mut buttons: Query<(&Hovered, &mut BackgroundColor), (With, Changed)>, +) { + for (hovered, mut background) in &mut buttons { + background.0 = if hovered.0 { + HOVERED_BUTTON + } else { + NORMAL_BUTTON + }; + } +} + +fn update_month_display( + spinboxes: Query<&MonthValue, (With, Changed)>, + mut displays: Query<(&ChildOf, &mut Text), With>, +) { + for (parent, mut text) in &mut displays { + if let Ok(month) = spinboxes.get(parent.get()) { + **text = month.0.label().to_string(); + } + } +}