├── .gitignore ├── Cargo.toml ├── src ├── events.rs ├── hover.rs ├── lib.rs ├── core_radio.rs ├── core_barrier.rs ├── cursor.rs ├── interaction_states.rs ├── core_checkbox.rs ├── core_button.rs ├── core_slider.rs ├── core_radio_group.rs └── core_scrollbar.rs └── examples ├── scrolling.rs └── controls.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /.vscode 3 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bevy_core_widgets" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | accesskit = "0.18.0" 8 | bevy = { version = "0.16.0-dev" } 9 | # bevy = { git = "https://github.com/bevyengine/bevy.git", rev = "refs/pull/18706/head" } 10 | -------------------------------------------------------------------------------- /src/events.rs: -------------------------------------------------------------------------------- 1 | use bevy::prelude::*; 2 | 3 | /// An event that indicates a change in value of a property. This is used by sliders, spinners 4 | /// and other widgets that edit a value. 5 | #[derive(Clone, Debug)] 6 | pub struct ValueChange(pub T); 7 | 8 | impl Event for ValueChange { 9 | type Traversal = &'static ChildOf; 10 | 11 | const AUTO_PROPAGATE: bool = true; 12 | } 13 | 14 | /// An event which is emitted when a button is clicked. This is different from the 15 | /// [`Pointer`] event, because it's also emitted when the button is focused and the `Enter` 16 | /// or `Space` key is pressed. 17 | #[derive(Clone, Debug)] 18 | pub struct ButtonClicked; 19 | 20 | impl Event for ButtonClicked { 21 | type Traversal = &'static ChildOf; 22 | 23 | const AUTO_PROPAGATE: bool = true; 24 | } 25 | -------------------------------------------------------------------------------- /src/hover.rs: -------------------------------------------------------------------------------- 1 | use bevy::{ 2 | picking::{hover::HoverMap, pointer::PointerId}, 3 | prelude::*, 4 | }; 5 | // use thorium_ui_core::Signal; 6 | 7 | /// Component which indicates that the entity is interested in knowing when the mouse is hovering 8 | /// over it or any of its children. 9 | #[derive(Debug, Clone, Copy, Component, Default)] 10 | pub struct Hovering(pub bool); 11 | 12 | // Note: previously this was implemented as a Reaction, however it was reacting every frame 13 | // because HoverMap is mutated every frame regardless of whether or not it changed. 14 | pub(crate) fn update_hover_states( 15 | hover_map: Option>, 16 | mut hovers: Query<(Entity, &mut Hovering)>, 17 | parent_query: Query<&ChildOf>, 18 | ) { 19 | let Some(hover_map) = hover_map else { return }; 20 | let hover_set = hover_map.get(&PointerId::Mouse); 21 | for (entity, mut hoverable) in hovers.iter_mut() { 22 | let is_hovering = match hover_set { 23 | Some(map) => map.iter().any(|(ha, _)| { 24 | *ha == entity || parent_query.iter_ancestors(*ha).any(|e| e == entity) 25 | }), 26 | None => false, 27 | }; 28 | if hoverable.0 != is_hovering { 29 | hoverable.0 = is_hovering; 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | use bevy::app::{App, Plugin, Update}; 2 | mod core_barrier; 3 | mod core_button; 4 | mod core_checkbox; 5 | mod core_radio; 6 | mod core_radio_group; 7 | mod core_scrollbar; 8 | mod core_slider; 9 | mod cursor; 10 | mod events; 11 | pub mod hover; 12 | mod interaction_states; 13 | 14 | pub use core_barrier::{CoreBarrier, CoreBarrierPlugin}; 15 | pub use core_button::{CoreButton, CoreButtonPlugin}; 16 | pub use core_checkbox::{CoreCheckbox, CoreCheckboxPlugin}; 17 | pub use core_radio::{CoreRadio, CoreRadioPlugin}; 18 | pub use core_radio_group::{CoreRadioGroup, CoreRadioGroupPlugin}; 19 | pub use core_scrollbar::{CoreScrollbar, CoreScrollbarPlugin, CoreScrollbarThumb, Orientation}; 20 | pub use core_slider::{CoreSlider, CoreSliderPlugin, SliderDragState}; 21 | pub use cursor::CursorIconPlugin; 22 | pub use events::{ButtonClicked, ValueChange}; 23 | pub use interaction_states::{ButtonPressed, Checked, InteractionDisabled}; 24 | 25 | pub struct CoreWidgetsPlugin; 26 | 27 | impl Plugin for CoreWidgetsPlugin { 28 | fn build(&self, app: &mut App) { 29 | app.add_plugins(( 30 | CoreBarrierPlugin, 31 | CoreButtonPlugin, 32 | CoreCheckboxPlugin, 33 | CoreRadioPlugin, 34 | CoreRadioGroupPlugin, 35 | CoreScrollbarPlugin, 36 | CoreSliderPlugin, 37 | CursorIconPlugin, 38 | )) 39 | .add_systems(Update, hover::update_hover_states); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/core_radio.rs: -------------------------------------------------------------------------------- 1 | use accesskit::Role; 2 | use bevy::{ 3 | a11y::AccessibilityNode, 4 | input_focus::{InputFocus, InputFocusVisible}, 5 | prelude::*, 6 | }; 7 | 8 | use crate::{interaction_states::Checked, ButtonClicked, InteractionDisabled}; 9 | 10 | /// Headless widget implementation for radio buttons. Note that this does not handle the mutual 11 | /// exclusion of radio buttons in the same group; that should be handled by the parent component. 12 | /// (This is relatively easy if the parent is a reactive widget.) 13 | /// 14 | /// The widget emits a `ButtonClick` event when clicked, or when the `Enter` or `Space` key is 15 | /// pressed while the radio button is focused. This event is normally handled by the parent 16 | /// `CoreRadioGroup` component. 17 | /// 18 | /// According to the WAI-ARIA best practices document, radio buttons should not be focusable, 19 | /// but rather the enclosing group should be focusable. 20 | /// See https://www.w3.org/WAI/ARIA/apg/patterns/radio/ 21 | #[derive(Component, Debug)] 22 | #[require(AccessibilityNode(accesskit::Node::new(Role::RadioButton)), Checked)] 23 | pub struct CoreRadio; 24 | 25 | fn radio_on_pointer_click( 26 | mut trigger: Trigger>, 27 | q_state: Query<(&Checked, Has), With>, 28 | mut focus: ResMut, 29 | mut focus_visible: ResMut, 30 | mut commands: Commands, 31 | ) { 32 | if let Ok((checked, disabled)) = q_state.get(trigger.target()) { 33 | let checkbox_id = trigger.target(); 34 | focus.0 = Some(checkbox_id); 35 | focus_visible.0 = false; 36 | trigger.propagate(false); 37 | if checked.0 || disabled { 38 | // If the radio is already checked, or disabled, we do nothing. 39 | return; 40 | } 41 | commands.trigger_targets(ButtonClicked, trigger.target()); 42 | } 43 | } 44 | 45 | pub struct CoreRadioPlugin; 46 | 47 | impl Plugin for CoreRadioPlugin { 48 | fn build(&self, app: &mut App) { 49 | app.add_observer(radio_on_pointer_click); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/core_barrier.rs: -------------------------------------------------------------------------------- 1 | use bevy::{ 2 | ecs::system::SystemId, 3 | input::{keyboard::KeyboardInput, ButtonState}, 4 | input_focus::{FocusedInput, InputFocus, InputFocusVisible}, 5 | prelude::*, 6 | }; 7 | 8 | /// A "barrier" is a backdrop element, one that covers the entire screen, blocks click events 9 | /// from reaching elements behind it, and can be used to close a dialog or menu. 10 | /// 11 | /// The `on_close` field is a system that will be run when the barrier gets a mouse down event. 12 | #[derive(Component, Debug)] 13 | pub struct CoreBarrier { 14 | pub on_close: Option, 15 | } 16 | 17 | pub(crate) fn barrier_on_key_input( 18 | mut trigger: Trigger>, 19 | q_state: Query<&CoreBarrier>, 20 | mut commands: Commands, 21 | ) { 22 | if let Ok(bstate) = q_state.get(trigger.target()) { 23 | let event = &trigger.event().input; 24 | if event.state == ButtonState::Pressed 25 | && !event.repeat 26 | && (event.key_code == KeyCode::Escape) 27 | { 28 | if let Some(on_close) = bstate.on_close { 29 | trigger.propagate(false); 30 | commands.run_system(on_close); 31 | } 32 | } 33 | } 34 | } 35 | 36 | pub(crate) fn barrier_on_pointer_down( 37 | mut trigger: Trigger>, 38 | q_state: Query<&CoreBarrier>, 39 | mut focus: ResMut, 40 | mut focus_visible: ResMut, 41 | mut commands: Commands, 42 | ) { 43 | let entity_id = trigger.target(); 44 | if let Ok(bstate) = q_state.get(entity_id) { 45 | focus.0 = Some(entity_id); 46 | focus_visible.0 = false; 47 | trigger.propagate(false); 48 | if let Some(on_close) = bstate.on_close { 49 | commands.run_system(on_close); 50 | } 51 | } 52 | } 53 | 54 | pub struct CoreBarrierPlugin; 55 | 56 | impl Plugin for CoreBarrierPlugin { 57 | fn build(&self, app: &mut App) { 58 | app.add_observer(barrier_on_key_input) 59 | .add_observer(barrier_on_pointer_down); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/cursor.rs: -------------------------------------------------------------------------------- 1 | use bevy::{ 2 | picking::{hover::HoverMap, pointer::PointerId}, 3 | prelude::*, 4 | winit::cursor::CursorIcon, 5 | }; 6 | 7 | /// A component that specifies the cursor icon to be used when the mouse is not hovering over 8 | /// any other entity. This is used to set the default cursor icon for the window. 9 | #[derive(Resource, Debug, Clone, Default)] 10 | pub struct DefaultCursorIcon(pub CursorIcon); 11 | 12 | /// System which updates the window cursor icon whenever the mouse hovers over an entity with 13 | /// a `CursorIcon` component. If no entity is hovered, the cursor icon is set to 14 | /// `CursorIcon::default()`. 15 | pub(crate) fn update_cursor( 16 | mut commands: Commands, 17 | hover_map: Option>, 18 | parent_query: Query<&ChildOf>, 19 | cursor_query: Query<&CursorIcon>, 20 | mut q_windows: Query<(Entity, &mut Window, Option<&CursorIcon>)>, 21 | r_default_cursor: Res, 22 | ) { 23 | let cursor = hover_map.and_then(|hover_map| match hover_map.get(&PointerId::Mouse) { 24 | Some(hover_set) => hover_set.keys().find_map(|entity| { 25 | cursor_query.get(*entity).ok().or_else(|| { 26 | parent_query 27 | .iter_ancestors(*entity) 28 | .find_map(|e| cursor_query.get(e).ok()) 29 | }) 30 | }), 31 | None => None, 32 | }); 33 | 34 | let mut windows_to_change: Vec = Vec::new(); 35 | for (entity, _window, prev_cursor) in q_windows.iter_mut() { 36 | match (cursor, prev_cursor) { 37 | (Some(cursor), Some(prev_cursor)) if cursor == prev_cursor => continue, 38 | (None, None) => continue, 39 | _ => { 40 | windows_to_change.push(entity); 41 | } 42 | } 43 | } 44 | windows_to_change.iter().for_each(|entity| { 45 | if let Some(cursor) = cursor { 46 | commands.entity(*entity).insert(cursor.clone()); 47 | } else { 48 | commands.entity(*entity).insert(r_default_cursor.0.clone()); 49 | } 50 | }); 51 | } 52 | 53 | pub struct CursorIconPlugin; 54 | 55 | impl Plugin for CursorIconPlugin { 56 | fn build(&self, app: &mut App) { 57 | if app.world().get_resource::().is_none() { 58 | app.init_resource::(); 59 | } 60 | app.add_systems(Update, update_cursor); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/interaction_states.rs: -------------------------------------------------------------------------------- 1 | use bevy::{ 2 | a11y::AccessibilityNode, 3 | ecs::{component::HookContext, world::DeferredWorld}, 4 | prelude::Component, 5 | }; 6 | 7 | /// A marker component to indicate that a widget is disabled and should be "grayed out". 8 | /// This is used to prevent user interaction with the widget. It should not, however, prevent 9 | /// the widget from being updated or rendered, or from acquiring keyboard focus. 10 | /// 11 | /// For apps which support a11y: if a widget (such as a slider) contains multiple entities, 12 | /// the `InteractionDisabled` component should be added to the root entity of the widget - the 13 | /// same entity that contains the `AccessibilityNode` component. This will ensure that 14 | /// the a11y tree is updated correctly. 15 | #[derive(Component, Debug, Clone, Copy)] 16 | #[component(on_add = on_add_disabled, on_remove = on_remove_disabled)] 17 | pub struct InteractionDisabled; 18 | 19 | // Hook to set the a11y "disabled" state when the widget is disabled. 20 | fn on_add_disabled(mut world: DeferredWorld, context: HookContext) { 21 | let mut entt = world.entity_mut(context.entity); 22 | if let Some(mut accessibility) = entt.get_mut::() { 23 | accessibility.set_disabled(); 24 | } 25 | } 26 | 27 | // Hook to remove the a11y "disabled" state when the widget is enabled. 28 | fn on_remove_disabled(mut world: DeferredWorld, context: HookContext) { 29 | let mut entt = world.entity_mut(context.entity); 30 | if let Some(mut accessibility) = entt.get_mut::() { 31 | accessibility.clear_disabled(); 32 | } 33 | } 34 | 35 | /// Component that indicates whether a button is currently pressed. This will be true while 36 | /// a drag action is in progress. 37 | #[derive(Component, Default, Debug)] 38 | pub struct ButtonPressed(pub bool); 39 | 40 | /// Component that indicates whether a checkbox or radio button is in a checked state. 41 | #[derive(Component, Default, Debug)] 42 | #[component(immutable, on_add = on_add_checked, on_replace = on_add_checked)] 43 | pub struct Checked(pub bool); 44 | 45 | // Hook to set the a11y "checked" state when the checkbox is added. 46 | fn on_add_checked(mut world: DeferredWorld, context: HookContext) { 47 | let mut entt = world.entity_mut(context.entity); 48 | let checked = entt.get::().unwrap().0; 49 | let mut accessibility = entt.get_mut::().unwrap(); 50 | accessibility.set_toggled(match checked { 51 | true => accesskit::Toggled::True, 52 | false => accesskit::Toggled::False, 53 | }); 54 | } 55 | -------------------------------------------------------------------------------- /src/core_checkbox.rs: -------------------------------------------------------------------------------- 1 | use accesskit::Role; 2 | use bevy::{ 3 | a11y::AccessibilityNode, 4 | ecs::system::SystemId, 5 | input::{keyboard::KeyboardInput, ButtonState}, 6 | input_focus::{FocusedInput, InputFocus, InputFocusVisible}, 7 | prelude::*, 8 | }; 9 | 10 | use crate::{interaction_states::Checked, InteractionDisabled, ValueChange}; 11 | 12 | /// Headless widget implementation for checkboxes. The `checked` represents the current state 13 | /// of the checkbox. The `on_change` field is a system that will be run when the checkbox 14 | /// is clicked, or when the Enter or Space key is pressed while the checkbox is focused. 15 | /// If the `on_change` field is `None`, the checkbox will emit a `ValueChange` event instead. 16 | #[derive(Component, Debug)] 17 | #[require(AccessibilityNode(accesskit::Node::new(Role::CheckBox)), Checked)] 18 | pub struct CoreCheckbox { 19 | pub on_change: Option>>, 20 | } 21 | 22 | fn checkbox_on_key_input( 23 | mut trigger: Trigger>, 24 | q_state: Query<(&CoreCheckbox, &Checked, Has)>, 25 | mut commands: Commands, 26 | ) { 27 | if let Ok((checkbox, checked, disabled)) = q_state.get(trigger.target()) { 28 | let event = &trigger.event().input; 29 | if !disabled 30 | && event.state == ButtonState::Pressed 31 | && !event.repeat 32 | && (event.key_code == KeyCode::Enter || event.key_code == KeyCode::Space) 33 | { 34 | let is_checked = checked.0; 35 | trigger.propagate(false); 36 | if let Some(on_change) = checkbox.on_change { 37 | commands.run_system_with(on_change, !is_checked); 38 | } else { 39 | commands.trigger_targets(ValueChange(!is_checked), trigger.target()); 40 | } 41 | } 42 | } 43 | } 44 | 45 | fn checkbox_on_pointer_click( 46 | mut trigger: Trigger>, 47 | q_state: Query<(&CoreCheckbox, &Checked, Has)>, 48 | mut focus: ResMut, 49 | mut focus_visible: ResMut, 50 | mut commands: Commands, 51 | ) { 52 | if let Ok((checkbox, checked, disabled)) = q_state.get(trigger.target()) { 53 | let checkbox_id = trigger.target(); 54 | focus.0 = Some(checkbox_id); 55 | focus_visible.0 = false; 56 | trigger.propagate(false); 57 | if !disabled { 58 | let is_checked = checked.0; 59 | if let Some(on_change) = checkbox.on_change { 60 | commands.run_system_with(on_change, !is_checked); 61 | } else { 62 | commands.trigger_targets(ValueChange(!is_checked), trigger.target()); 63 | } 64 | } 65 | } 66 | } 67 | 68 | pub struct CoreCheckboxPlugin; 69 | 70 | impl Plugin for CoreCheckboxPlugin { 71 | fn build(&self, app: &mut App) { 72 | app.add_observer(checkbox_on_key_input) 73 | .add_observer(checkbox_on_pointer_click); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/core_button.rs: -------------------------------------------------------------------------------- 1 | use accesskit::Role; 2 | use bevy::{ 3 | a11y::AccessibilityNode, 4 | ecs::system::SystemId, 5 | input::keyboard::KeyboardInput, 6 | input_focus::{FocusedInput, InputFocus, InputFocusVisible}, 7 | prelude::*, 8 | }; 9 | 10 | use crate::{events::ButtonClicked, ButtonPressed, InteractionDisabled}; 11 | 12 | /// Headless button widget. The `on_click` field is a system that will be run when the button 13 | /// is clicked, or when the Enter or Space key is pressed while the button is focused. If the 14 | /// `on_click` field is `None`, the button will emit a `ButtonClicked` event when clicked. 15 | #[derive(Component, Debug)] 16 | #[require(AccessibilityNode(accesskit::Node::new(Role::Button)))] 17 | #[require(ButtonPressed)] 18 | pub struct CoreButton { 19 | pub on_click: Option, 20 | } 21 | 22 | pub(crate) fn button_on_key_event( 23 | mut trigger: Trigger>, 24 | q_state: Query<(&CoreButton, Has)>, 25 | mut commands: Commands, 26 | ) { 27 | if let Ok((bstate, disabled)) = q_state.get(trigger.target()) { 28 | if !disabled { 29 | let event = &trigger.event().input; 30 | if !event.repeat 31 | && (event.key_code == KeyCode::Enter || event.key_code == KeyCode::Space) 32 | { 33 | if let Some(on_click) = bstate.on_click { 34 | trigger.propagate(false); 35 | commands.run_system(on_click); 36 | } else { 37 | commands.trigger_targets(ButtonClicked, trigger.target()); 38 | } 39 | } 40 | } 41 | } 42 | } 43 | 44 | pub(crate) fn button_on_pointer_click( 45 | mut trigger: Trigger>, 46 | mut q_state: Query<(&CoreButton, &mut ButtonPressed, Has)>, 47 | mut commands: Commands, 48 | ) { 49 | if let Ok((bstate, pressed, disabled)) = q_state.get_mut(trigger.target()) { 50 | trigger.propagate(false); 51 | if pressed.0 && !disabled { 52 | if let Some(on_click) = bstate.on_click { 53 | commands.run_system(on_click); 54 | } else { 55 | commands.trigger_targets(ButtonClicked, trigger.target()); 56 | } 57 | } 58 | } 59 | } 60 | 61 | pub(crate) fn button_on_pointer_down( 62 | mut trigger: Trigger>, 63 | mut q_state: Query<(&mut ButtonPressed, Has)>, 64 | mut focus: ResMut, 65 | mut focus_visible: ResMut, 66 | ) { 67 | if let Ok((mut pressed, disabled)) = q_state.get_mut(trigger.target()) { 68 | trigger.propagate(false); 69 | if !disabled { 70 | pressed.0 = true; 71 | focus.0 = Some(trigger.target()); 72 | focus_visible.0 = false; 73 | } 74 | } 75 | } 76 | 77 | pub(crate) fn button_on_pointer_up( 78 | mut trigger: Trigger>, 79 | mut q_state: Query<(&mut ButtonPressed, Has)>, 80 | ) { 81 | if let Ok((mut pressed, disabled)) = q_state.get_mut(trigger.target()) { 82 | trigger.propagate(false); 83 | if !disabled { 84 | pressed.0 = false; 85 | } 86 | } 87 | } 88 | 89 | pub(crate) fn button_on_pointer_drag_end( 90 | mut trigger: Trigger>, 91 | mut q_state: Query<(&mut ButtonPressed, Has)>, 92 | ) { 93 | if let Ok((mut pressed, disabled)) = q_state.get_mut(trigger.target()) { 94 | trigger.propagate(false); 95 | if !disabled { 96 | pressed.0 = false; 97 | } 98 | } 99 | } 100 | 101 | pub(crate) fn button_on_pointer_cancel( 102 | mut trigger: Trigger>, 103 | mut q_state: Query<(&mut ButtonPressed, Has)>, 104 | ) { 105 | if let Ok((mut pressed, disabled)) = q_state.get_mut(trigger.target()) { 106 | trigger.propagate(false); 107 | if !disabled { 108 | pressed.0 = false; 109 | } 110 | } 111 | } 112 | 113 | pub struct CoreButtonPlugin; 114 | 115 | impl Plugin for CoreButtonPlugin { 116 | fn build(&self, app: &mut App) { 117 | app.add_observer(button_on_key_event) 118 | .add_observer(button_on_pointer_down) 119 | .add_observer(button_on_pointer_up) 120 | .add_observer(button_on_pointer_click) 121 | .add_observer(button_on_pointer_drag_end) 122 | .add_observer(button_on_pointer_cancel); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/core_slider.rs: -------------------------------------------------------------------------------- 1 | use accesskit::{Orientation, Role}; 2 | use bevy::{ 3 | a11y::AccessibilityNode, 4 | ecs::system::SystemId, 5 | input::{keyboard::KeyboardInput, ButtonState}, 6 | input_focus::{FocusedInput, InputFocus, InputFocusVisible}, 7 | prelude::*, 8 | }; 9 | 10 | use crate::{InteractionDisabled, ValueChange}; 11 | 12 | /// A headless slider widget, which can be used to build custom sliders. This component emits 13 | /// [`ValueChange`] events when the slider value changes. Note that the value in the event is 14 | /// unclamped - the reason is that the receiver may want to quantize or otherwise modify the value 15 | /// before clamping. It is the receiver's responsibility to update the slider's value when 16 | /// the value change event is received. 17 | #[derive(Component, Debug)] 18 | #[require(SliderDragState)] 19 | #[require(AccessibilityNode(accesskit::Node::new(Role::Slider)))] 20 | pub struct CoreSlider { 21 | pub value: f32, 22 | pub min: f32, 23 | pub max: f32, 24 | pub increment: f32, 25 | pub thumb_size: f32, 26 | pub on_change: Option>>, 27 | } 28 | 29 | impl Default for CoreSlider { 30 | fn default() -> Self { 31 | Self { 32 | value: 0.5, 33 | min: 0.0, 34 | max: 1.0, 35 | increment: 1.0, 36 | thumb_size: 0.0, 37 | on_change: None, 38 | } 39 | } 40 | } 41 | 42 | impl CoreSlider { 43 | /// Get the current value of the slider. 44 | pub fn value(&self) -> f32 { 45 | self.value 46 | } 47 | 48 | /// Set the value of the slider, clamping it to the min and max values. 49 | pub fn set_value(&mut self, value: f32) { 50 | self.value = value.clamp(self.min, self.max); 51 | } 52 | 53 | /// Set the minimum and maximum value of the slider, clamping the current value to the new 54 | /// range. 55 | pub fn set_range(&mut self, min: f32, max: f32) { 56 | self.min = min; 57 | self.max = max; 58 | self.value = self.value.clamp(min, max); 59 | } 60 | 61 | /// Compute the position of the thumb on the slider, as a value between 0 and 1. 62 | pub fn thumb_position(&self) -> f32 { 63 | if self.max > self.min { 64 | (self.value - self.min) / (self.max - self.min) 65 | } else { 66 | 0.5 67 | } 68 | } 69 | } 70 | 71 | /// Component used to manage the state of a slider during dragging. 72 | #[derive(Component, Default)] 73 | pub struct SliderDragState { 74 | /// Whether the slider is currently being dragged. 75 | pub dragging: bool, 76 | /// The value of the slider when dragging started. 77 | offset: f32, 78 | } 79 | 80 | pub(crate) fn slider_on_pointer_down( 81 | trigger: Trigger>, 82 | q_state: Query<(), With>, 83 | mut focus: ResMut, 84 | mut focus_visible: ResMut, 85 | ) { 86 | if q_state.contains(trigger.target()) { 87 | // Set focus to slider and hide focus ring 88 | focus.0 = Some(trigger.target()); 89 | focus_visible.0 = false; 90 | } 91 | } 92 | 93 | pub(crate) fn slider_on_drag_start( 94 | mut trigger: Trigger>, 95 | mut q_state: Query<(&CoreSlider, &mut SliderDragState, Has)>, 96 | ) { 97 | if let Ok((slider, mut drag, disabled)) = q_state.get_mut(trigger.target()) { 98 | trigger.propagate(false); 99 | if !disabled { 100 | drag.dragging = true; 101 | drag.offset = slider.value; 102 | } 103 | } 104 | } 105 | 106 | pub(crate) fn slider_on_drag( 107 | mut trigger: Trigger>, 108 | mut q_state: Query<(&ComputedNode, &CoreSlider, &mut SliderDragState)>, 109 | mut commands: Commands, 110 | ) { 111 | if let Ok((node, slider, drag)) = q_state.get_mut(trigger.target()) { 112 | trigger.propagate(false); 113 | if drag.dragging { 114 | let distance = trigger.event().distance; 115 | // Measure node width and slider value. 116 | let slider_width = 117 | (node.size().x * node.inverse_scale_factor - slider.thumb_size).max(1.0); 118 | let range = slider.max - slider.min; 119 | let new_value = if range > 0. { 120 | drag.offset + (distance.x * range) / slider_width 121 | } else { 122 | slider.min + range * 0.5 123 | }; 124 | 125 | if let Some(on_change) = slider.on_change { 126 | commands.run_system_with(on_change, new_value); 127 | } else { 128 | commands.trigger_targets(ValueChange(new_value), trigger.target()); 129 | } 130 | } 131 | } 132 | } 133 | 134 | pub(crate) fn slider_on_drag_end( 135 | mut trigger: Trigger>, 136 | mut q_state: Query<(&CoreSlider, &mut SliderDragState)>, 137 | ) { 138 | if let Ok((_slider, mut drag)) = q_state.get_mut(trigger.target()) { 139 | trigger.propagate(false); 140 | if drag.dragging { 141 | drag.dragging = false; 142 | } 143 | } 144 | } 145 | 146 | fn slider_on_key_input( 147 | mut trigger: Trigger>, 148 | q_state: Query<(&CoreSlider, Has)>, 149 | mut commands: Commands, 150 | ) { 151 | if let Ok((slider, disabled)) = q_state.get(trigger.target()) { 152 | let event = &trigger.event().input; 153 | if !disabled && event.state == ButtonState::Pressed { 154 | let new_value = match event.key_code { 155 | KeyCode::ArrowLeft => (slider.value - slider.increment).max(slider.min), 156 | KeyCode::ArrowRight => (slider.value + slider.increment).min(slider.max), 157 | KeyCode::Home => slider.min, 158 | KeyCode::End => slider.max, 159 | _ => { 160 | return; 161 | } 162 | }; 163 | trigger.propagate(false); 164 | if let Some(on_change) = slider.on_change { 165 | commands.run_system_with(on_change, new_value); 166 | } else { 167 | commands.trigger_targets(ValueChange(new_value), trigger.target()); 168 | } 169 | } 170 | } 171 | } 172 | 173 | fn update_slider_a11y(mut q_state: Query<(&CoreSlider, &mut AccessibilityNode)>) { 174 | for (slider, mut node) in q_state.iter_mut() { 175 | node.set_numeric_value(slider.value.into()); 176 | node.set_min_numeric_value(slider.min.into()); 177 | node.set_max_numeric_value(slider.max.into()); 178 | node.set_numeric_value_step(slider.increment.into()); 179 | node.set_orientation(Orientation::Horizontal); 180 | } 181 | } 182 | 183 | pub struct CoreSliderPlugin; 184 | 185 | impl Plugin for CoreSliderPlugin { 186 | fn build(&self, app: &mut App) { 187 | app.add_observer(slider_on_pointer_down) 188 | .add_observer(slider_on_drag_start) 189 | .add_observer(slider_on_drag_end) 190 | .add_observer(slider_on_drag) 191 | .add_observer(slider_on_key_input) 192 | .add_systems(PostUpdate, update_slider_a11y); 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /src/core_radio_group.rs: -------------------------------------------------------------------------------- 1 | use accesskit::Role; 2 | use bevy::{ 3 | a11y::AccessibilityNode, 4 | ecs::system::SystemId, 5 | input::{keyboard::KeyboardInput, ButtonState}, 6 | input_focus::{FocusedInput, InputFocus, InputFocusVisible}, 7 | prelude::*, 8 | }; 9 | 10 | use crate::{ButtonClicked, Checked, CoreRadio, InteractionDisabled, ValueChange}; 11 | 12 | /// Headless widget implementation for a "radio group". This component is used to group multiple 13 | /// `CoreRadio` components together, allowing them to behave as a single unit. It implements 14 | /// the tab navigation logic and keyboard shortcuts for radio buttons. 15 | /// 16 | /// The `CoreRadioGroup` component does not have any state itself, and makes no assumptions about 17 | /// what, if any, value is associated with each radio button. Instead, it relies on the `CoreRadio` 18 | /// components to trigger a `ButtonPress` event, and tranforms this into a `ValueChange` event 19 | /// which contains the id of the selected button. The app can then derive the selected value 20 | /// from this using app-specific data. 21 | #[derive(Component, Debug)] 22 | #[require(AccessibilityNode(accesskit::Node::new(Role::RadioGroup)))] 23 | pub struct CoreRadioGroup { 24 | pub on_change: Option>>, 25 | } 26 | 27 | fn radio_group_on_key_input( 28 | mut trigger: Trigger>, 29 | q_group: Query<(&CoreRadioGroup, &Children)>, 30 | q_radio: Query<(&Checked, Has), With>, 31 | mut commands: Commands, 32 | ) { 33 | if let Ok((CoreRadioGroup { on_change }, group_children)) = q_group.get(trigger.target()) { 34 | let event = &trigger.event().input; 35 | if event.state == ButtonState::Pressed 36 | && !event.repeat 37 | && matches!( 38 | event.key_code, 39 | KeyCode::ArrowUp 40 | | KeyCode::ArrowDown 41 | | KeyCode::ArrowLeft 42 | | KeyCode::ArrowRight 43 | | KeyCode::Home 44 | | KeyCode::End 45 | ) 46 | { 47 | let key_code = event.key_code; 48 | trigger.propagate(false); 49 | let radio_children = group_children 50 | .iter() 51 | .filter_map(|child_id| match q_radio.get(child_id) { 52 | Ok((checked, false)) => Some((child_id, checked.0)), 53 | Ok((_, true)) => None, 54 | Err(_) => None, 55 | }) 56 | .collect::>(); 57 | if radio_children.is_empty() { 58 | return; // No enabled radio buttons in the group 59 | } 60 | let current_index = radio_children 61 | .iter() 62 | .position(|(_, checked)| *checked) 63 | .unwrap_or(usize::MAX); // Default to invalid index if none are checked 64 | 65 | let next_index = match key_code { 66 | KeyCode::ArrowUp | KeyCode::ArrowLeft => { 67 | // Navigate to the previous radio button in the group 68 | if current_index == 0 { 69 | // If we're at the first one, wrap around to the last 70 | radio_children.len() - 1 71 | } else { 72 | // Move to the previous one 73 | current_index - 1 74 | } 75 | } 76 | KeyCode::ArrowDown | KeyCode::ArrowRight => { 77 | // Navigate to the next radio button in the group 78 | if current_index >= radio_children.len() - 1 { 79 | // If we're at the last one, wrap around to the first 80 | 0 81 | } else { 82 | // Move to the next one 83 | current_index + 1 84 | } 85 | } 86 | KeyCode::Home => { 87 | // Navigate to the first radio button in the group 88 | 0 89 | } 90 | KeyCode::End => { 91 | // Navigate to the last radio button in the group 92 | radio_children.len() - 1 93 | } 94 | _ => { 95 | return; 96 | } 97 | }; 98 | 99 | if current_index == next_index { 100 | // If the next index is the same as the current, do nothing 101 | return; 102 | } 103 | 104 | let (next_id, _) = radio_children[next_index]; 105 | 106 | // Trigger the on_change event for the newly checked radio button 107 | if let Some(on_change) = on_change { 108 | commands.run_system_with(*on_change, next_id); 109 | } else { 110 | commands.trigger_targets(ValueChange(next_id), trigger.target()); 111 | } 112 | } 113 | } 114 | } 115 | 116 | fn radio_group_on_button_click( 117 | mut trigger: Trigger, 118 | q_group: Query<(&CoreRadioGroup, &Children)>, 119 | q_radio: Query<(&Checked, &ChildOf, Has), With>, 120 | mut focus: ResMut, 121 | mut focus_visible: ResMut, 122 | mut commands: Commands, 123 | ) { 124 | let radio_id = trigger.target(); 125 | 126 | // Find the radio button that was clicked. 127 | let Ok((_, child_of, _)) = q_radio.get(radio_id) else { 128 | return; 129 | }; 130 | 131 | // Find the parent CoreRadioGroup of the clicked radio button. 132 | let group_id = child_of.parent(); 133 | let Ok((CoreRadioGroup { on_change }, group_children)) = q_group.get(group_id) else { 134 | // The radio button's parent is not a CoreRadioGroup, ignore the click 135 | warn!("Radio button clicked without a valid CoreRadioGroup parent"); 136 | return; 137 | }; 138 | 139 | // Set focus to group and hide focus ring 140 | focus.0 = Some(group_id); 141 | focus_visible.0 = false; 142 | 143 | // Get all the radio group children. 144 | let radio_children = group_children 145 | .iter() 146 | .filter_map(|child_id| match q_radio.get(child_id) { 147 | Ok((checked, _, false)) => Some((child_id, checked.0)), 148 | Ok((_, _, true)) => None, 149 | Err(_) => None, 150 | }) 151 | .collect::>(); 152 | 153 | if radio_children.is_empty() { 154 | return; // No enabled radio buttons in the group 155 | } 156 | 157 | trigger.propagate(false); 158 | let current_radio = radio_children 159 | .iter() 160 | .find(|(_, checked)| *checked) 161 | .map(|(id, _)| *id); 162 | 163 | if current_radio == Some(radio_id) { 164 | // If they clicked the currently checked radio button, do nothing 165 | return; 166 | } 167 | 168 | // Trigger the on_change event for the newly checked radio button 169 | if let Some(on_change) = on_change { 170 | commands.run_system_with(*on_change, radio_id); 171 | } else { 172 | commands.trigger_targets(ValueChange(radio_id), group_id); 173 | } 174 | } 175 | 176 | pub struct CoreRadioGroupPlugin; 177 | 178 | impl Plugin for CoreRadioGroupPlugin { 179 | fn build(&self, app: &mut App) { 180 | app.add_observer(radio_group_on_key_input) 181 | .add_observer(radio_group_on_button_click); 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /src/core_scrollbar.rs: -------------------------------------------------------------------------------- 1 | use bevy::prelude::*; 2 | 3 | #[derive(Debug, Default, Clone, Copy, PartialEq)] 4 | pub enum Orientation { 5 | Horizontal, 6 | #[default] 7 | Vertical, 8 | } 9 | 10 | /// A headless scrollbar widget, which can be used to build custom scrollbars. This component emits 11 | /// [`ValueChange`] events when the scrollbar value changes. 12 | /// 13 | /// Unlike sliders, scrollbars don't have an [`AccessibilityNode`] component, nor can they have 14 | /// keyboard focus. This is because scrollbars are usually used in conjunction with a scrollable 15 | /// container, which is itself accessible and focusable. 16 | /// 17 | /// A scrollbar can have any number of child entities, but one entity must be the scrollbar 18 | /// thumb, which is marked with the [`CoreScrollbarThumb`] component. Other children are ignored. 19 | #[derive(Component, Debug)] 20 | #[require(ScrollbarDragState)] 21 | pub struct CoreScrollbar { 22 | /// Entity being scrolled. 23 | pub target: Entity, 24 | /// Whether the scrollbar is vertical or horizontal. 25 | pub orientation: Orientation, 26 | /// Minimum size of the scrollbar thumb, in pixel units. 27 | pub min_thumb_size: f32, 28 | } 29 | 30 | /// Marker component to indicate that the entity is a scrollbar thumb. This should be a child 31 | /// of the scrollbar entity. 32 | #[derive(Component, Debug)] 33 | pub struct CoreScrollbarThumb; 34 | 35 | impl CoreScrollbar { 36 | pub fn new(target: Entity, orientation: Orientation, min_thumb_size: f32) -> Self { 37 | Self { 38 | target, 39 | orientation, 40 | min_thumb_size, 41 | } 42 | } 43 | } 44 | 45 | /// Component used to manage the state of a scrollbar during dragging. 46 | #[derive(Component, Default)] 47 | pub struct ScrollbarDragState { 48 | /// Whether the scrollbar is currently being dragged. 49 | dragging: bool, 50 | /// The value of the scrollbar when dragging started. 51 | offset: f32, 52 | } 53 | 54 | pub(crate) fn scrollbar_on_pointer_down( 55 | mut trigger: Trigger>, 56 | q_thumb: Query<&ChildOf, With>, 57 | mut q_scrollbar: Query<(&CoreScrollbar, &GlobalTransform)>, 58 | mut _q_scroll_pos: Query<(&mut ScrollPosition, &ComputedNode), Without>, 59 | ) { 60 | if q_thumb.contains(trigger.target()) { 61 | // If they click on the thumb, do nothing. This will be handled by the drag event. 62 | trigger.propagate(false); 63 | } else if let Ok((_scrollbar, _transform)) = q_scrollbar.get_mut(trigger.target()) { 64 | // If they click on the scrollbar track, page up or down. 65 | trigger.propagate(false); 66 | // TODO: Finish this once we figure out how to get the local click coordinates. 67 | 68 | // let Ok((mut scroll_pos, scroll_content)) = q_scroll_pos.get_mut(scrollbar.target) else { 69 | // return; 70 | // }; 71 | // // Rather than scanning for the thumb entity, just calculate where the thumb will be. 72 | // let visible_size = scroll_content.size() * scroll_content.inverse_scale_factor; 73 | // let content_size = scroll_content.content_size() * scroll_content.inverse_scale_factor; 74 | // info!( 75 | // "translation: {:?} location {:?}", 76 | // transform.translation(), 77 | // trigger.event().pointer_location.position 78 | // ); 79 | // match scrollbar.orientation { 80 | // Orientation::Horizontal => { 81 | // // let hit_pos = trigger.event().pointer_location.position.x 82 | // // - transform.translation().x * scroll_content.inverse_scale_factor; 83 | // } 84 | // Orientation::Vertical => { 85 | // // let hit_pos = trigger.event().pointer_location.position.y 86 | // // - transform.translation().y * scroll_content.inverse_scale_factor; 87 | // // info!("scrollbar_on_pointer_down: {hit_pos}"); 88 | // } 89 | // } 90 | } 91 | } 92 | 93 | pub(crate) fn scrollbar_on_drag_start( 94 | mut trigger: Trigger>, 95 | q_thumb: Query<&ChildOf, With>, 96 | mut q_scrollbar: Query<(&CoreScrollbar, &mut ScrollbarDragState)>, 97 | q_scroll_area: Query<&ScrollPosition>, 98 | ) { 99 | if let Ok(ChildOf(thumb_parent)) = q_thumb.get(trigger.target()) { 100 | trigger.propagate(false); 101 | if let Ok((scrollbar, mut drag)) = q_scrollbar.get_mut(*thumb_parent) { 102 | if let Ok(scroll_area) = q_scroll_area.get(scrollbar.target) { 103 | drag.dragging = true; 104 | drag.offset = match scrollbar.orientation { 105 | Orientation::Horizontal => scroll_area.offset_x, 106 | Orientation::Vertical => scroll_area.offset_y, 107 | }; 108 | } 109 | } 110 | } 111 | } 112 | 113 | pub(crate) fn scrollbar_on_drag( 114 | mut trigger: Trigger>, 115 | mut q_scrollbar: Query<(&ComputedNode, &CoreScrollbar, &mut ScrollbarDragState)>, 116 | mut q_scroll_pos: Query<(&mut ScrollPosition, &ComputedNode), Without>, 117 | ) { 118 | if let Ok((node, scrollbar, drag)) = q_scrollbar.get_mut(trigger.target()) { 119 | trigger.propagate(false); 120 | let Ok((mut scroll_pos, scroll_content)) = q_scroll_pos.get_mut(scrollbar.target) else { 121 | return; 122 | }; 123 | 124 | if drag.dragging { 125 | let distance = trigger.event().distance; 126 | let visible_size = scroll_content.size() * scroll_content.inverse_scale_factor; 127 | let content_size = scroll_content.content_size() * scroll_content.inverse_scale_factor; 128 | match scrollbar.orientation { 129 | Orientation::Horizontal => { 130 | let range = (content_size.x - visible_size.x).max(0.); 131 | let scrollbar_width = (node.size().x * node.inverse_scale_factor 132 | - scrollbar.min_thumb_size) 133 | .max(1.0); 134 | scroll_pos.offset_x = if range > 0. { 135 | (drag.offset + (distance.x * content_size.x) / scrollbar_width) 136 | .clamp(0., range) 137 | } else { 138 | 0. 139 | } 140 | } 141 | Orientation::Vertical => { 142 | let range = (content_size.y - visible_size.y).max(0.); 143 | let scrollbar_height = (node.size().y * node.inverse_scale_factor 144 | - scrollbar.min_thumb_size) 145 | .max(1.0); 146 | scroll_pos.offset_y = if range > 0. { 147 | (drag.offset + (distance.y * content_size.y) / scrollbar_height) 148 | .clamp(0., range) 149 | } else { 150 | 0. 151 | } 152 | } 153 | }; 154 | } 155 | } 156 | } 157 | 158 | pub(crate) fn scrollbar_on_drag_end( 159 | mut trigger: Trigger>, 160 | mut q_scrollbar: Query<(&CoreScrollbar, &mut ScrollbarDragState)>, 161 | ) { 162 | if let Ok((_scrollbar, mut drag)) = q_scrollbar.get_mut(trigger.target()) { 163 | trigger.propagate(false); 164 | if drag.dragging { 165 | drag.dragging = false; 166 | } 167 | } 168 | } 169 | 170 | fn update_scrollbar_thumb( 171 | q_scroll_area: Query<(&ScrollPosition, &ComputedNode)>, 172 | q_scrollbar: Query<(&CoreScrollbar, &ComputedNode, &Children)>, 173 | mut q_thumb: Query<&mut Node, With>, 174 | ) { 175 | for (scrollbar, scrollbar_node, children) in q_scrollbar.iter() { 176 | let Ok(scroll_area) = q_scroll_area.get(scrollbar.target) else { 177 | continue; 178 | }; 179 | 180 | // Size of the visible scrolling area. 181 | let visible_size = scroll_area.1.size() * scroll_area.1.inverse_scale_factor; 182 | 183 | // Size of the scrolling content. 184 | let content_size = scroll_area.1.content_size() * scroll_area.1.inverse_scale_factor; 185 | 186 | // Length of the scrollbar track. 187 | let track_length = scrollbar_node.size() * scrollbar_node.inverse_scale_factor; 188 | 189 | for child in children { 190 | if let Ok(mut thumb) = q_thumb.get_mut(*child) { 191 | match scrollbar.orientation { 192 | Orientation::Horizontal => { 193 | let thumb_size = if content_size.x > visible_size.x { 194 | (track_length.x * visible_size.x / content_size.x) 195 | .max(scrollbar.min_thumb_size) 196 | .min(track_length.x) 197 | } else { 198 | track_length.x 199 | }; 200 | 201 | let thumb_pos = if content_size.x > visible_size.x { 202 | scroll_area.0.offset_x * (track_length.x - thumb_size) 203 | / (content_size.x - visible_size.x) 204 | } else { 205 | 0. 206 | }; 207 | 208 | thumb.top = Val::Px(0.); 209 | thumb.bottom = Val::Px(0.); 210 | thumb.left = Val::Px(thumb_pos); 211 | thumb.width = Val::Px(thumb_size); 212 | } 213 | Orientation::Vertical => { 214 | let thumb_size = if content_size.y > visible_size.y { 215 | (track_length.y * visible_size.y / content_size.y) 216 | .max(scrollbar.min_thumb_size) 217 | .min(track_length.y) 218 | } else { 219 | track_length.y 220 | }; 221 | 222 | let thumb_pos = if content_size.y > visible_size.y { 223 | scroll_area.0.offset_y * (track_length.y - thumb_size) 224 | / (content_size.y - visible_size.y) 225 | } else { 226 | 0. 227 | }; 228 | 229 | thumb.left = Val::Px(0.); 230 | thumb.right = Val::Px(0.); 231 | thumb.top = Val::Px(thumb_pos); 232 | thumb.height = Val::Px(thumb_size); 233 | } 234 | }; 235 | } 236 | } 237 | } 238 | } 239 | 240 | pub struct CoreScrollbarPlugin; 241 | 242 | impl Plugin for CoreScrollbarPlugin { 243 | fn build(&self, app: &mut App) { 244 | app.add_observer(scrollbar_on_pointer_down) 245 | .add_observer(scrollbar_on_drag_start) 246 | .add_observer(scrollbar_on_drag_end) 247 | .add_observer(scrollbar_on_drag) 248 | .add_systems(PostUpdate, update_scrollbar_thumb); 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /examples/scrolling.rs: -------------------------------------------------------------------------------- 1 | //! Demonstrations of scrolling and scrollbars. 2 | //! 3 | //! Note that this example should not be used as a basis for a real application. A real application 4 | //! would likely use a more sophisticated UI framework or library that includes composable styles, 5 | //! templates, reactive signals, and other techniques that improve the developer experience. This 6 | //! example has been written in a very brute-force, low-level style so as to demonstrate the 7 | //! functionality of the core widgets with minimal dependencies. 8 | 9 | use bevy::{ 10 | a11y::AccessibilityNode, 11 | ecs::{ 12 | component::HookContext, relationship::RelatedSpawner, spawn::SpawnWith, 13 | world::DeferredWorld, 14 | }, 15 | input_focus::{ 16 | tab_navigation::{TabGroup, TabIndex, TabNavigationPlugin}, 17 | InputDispatchPlugin, InputFocus, InputFocusVisible, 18 | }, 19 | prelude::*, 20 | ui, 21 | }; 22 | use bevy_core_widgets::{ 23 | hover::Hovering, CoreScrollbar, CoreScrollbarThumb, CoreSlider, CoreWidgetsPlugin, Orientation, 24 | ValueChange, 25 | }; 26 | 27 | fn main() { 28 | App::new() 29 | .add_plugins(( 30 | DefaultPlugins, 31 | CoreWidgetsPlugin, 32 | InputDispatchPlugin, 33 | TabNavigationPlugin, 34 | )) 35 | .add_systems(Startup, setup_view_root) 36 | .add_systems( 37 | Update, 38 | (update_focus_rect, update_scrollbar_thumb, close_on_esc), 39 | ) 40 | .run(); 41 | } 42 | 43 | fn setup_view_root(mut commands: Commands) { 44 | let camera = commands.spawn((Camera::default(), Camera2d)).id(); 45 | 46 | commands.spawn(( 47 | Node { 48 | display: Display::Flex, 49 | flex_direction: FlexDirection::Column, 50 | position_type: PositionType::Absolute, 51 | left: ui::Val::Px(0.), 52 | top: ui::Val::Px(0.), 53 | right: ui::Val::Px(0.), 54 | bottom: ui::Val::Px(0.), 55 | padding: ui::UiRect::all(Val::Px(3.)), 56 | row_gap: ui::Val::Px(6.), 57 | ..Default::default() 58 | }, 59 | BackgroundColor(Color::srgb(0.1, 0.1, 0.1)), 60 | UiTargetCamera(camera), 61 | TabGroup::default(), 62 | Children::spawn((Spawn(Text::new("Scrolling")), Spawn(scroll_area_demo()))), 63 | )); 64 | 65 | // Observer for sliders that don't have an on_change handler. 66 | commands.add_observer( 67 | |mut trigger: Trigger>, mut q_slider: Query<&mut CoreSlider>| { 68 | trigger.propagate(false); 69 | if let Ok(mut slider) = q_slider.get_mut(trigger.target()) { 70 | // Update slider state from event. 71 | slider.set_value(trigger.event().0); 72 | info!("New slider state: {:?}", slider.value()); 73 | } 74 | }, 75 | ); 76 | } 77 | 78 | pub fn close_on_esc(input: Res>, mut exit: EventWriter) { 79 | if input.just_pressed(KeyCode::Escape) { 80 | exit.write(AppExit::Success); 81 | } 82 | } 83 | 84 | /// The variant determines the button's color scheme 85 | #[derive(Clone, Copy, PartialEq, Default, Debug)] 86 | pub enum ButtonVariant { 87 | /// The default button apperance. 88 | #[default] 89 | Default, 90 | 91 | /// A more prominent, "call to action", appearance. 92 | Primary, 93 | 94 | /// An appearance indicating a potentially dangerous action. 95 | Danger, 96 | 97 | /// A button that is in a "toggled" state. 98 | Selected, 99 | } 100 | 101 | // Places an outline around the currently focused widget. This is a generic implementation for demo 102 | // purposes; in a real widget library the focus rectangle would be customized to the shape of 103 | // the individual widgets. 104 | #[allow(clippy::type_complexity)] 105 | fn update_focus_rect( 106 | mut query: Query<(Entity, Has), With>, 107 | focus: Res, 108 | focus_visible: ResMut, 109 | mut commands: Commands, 110 | ) { 111 | for (control, has_focus) in query.iter_mut() { 112 | let needs_focus = Some(control) == focus.0 && focus_visible.0; 113 | if needs_focus != has_focus { 114 | if needs_focus { 115 | commands.entity(control).insert(Outline { 116 | color: colors::FOCUS.into(), 117 | width: ui::Val::Px(2.0), 118 | offset: ui::Val::Px(1.0), 119 | }); 120 | } else { 121 | commands.entity(control).remove::(); 122 | } 123 | } 124 | } 125 | } 126 | 127 | /// Create a scrolling area. 128 | /// 129 | /// The "scroll area" is a container that can be scrolled. It has a nested structure which is 130 | /// three levels deep: 131 | /// - The outermost node is a grid that contains the scroll area and the scrollbars. 132 | /// - The scroll area is a flex container that contains the scrollable content. This 133 | /// is the element that has the `overflow: scroll` property. 134 | /// - The scrollable content consists of the elements actually displayed in the scrolling area. 135 | fn scroll_area_demo() -> impl Bundle { 136 | ( 137 | // Frame element which contains the scroll area and scrollbars. 138 | Node { 139 | display: ui::Display::Grid, 140 | width: ui::Val::Px(200.0), 141 | height: ui::Val::Px(150.0), 142 | grid_template_columns: vec![ 143 | ui::RepeatedGridTrack::flex(1, 1.), 144 | ui::RepeatedGridTrack::auto(1), 145 | ], 146 | grid_template_rows: vec![ 147 | ui::RepeatedGridTrack::flex(1, 1.), 148 | ui::RepeatedGridTrack::auto(1), 149 | ], 150 | row_gap: ui::Val::Px(2.0), 151 | column_gap: ui::Val::Px(2.0), 152 | ..default() 153 | }, 154 | Children::spawn((SpawnWith(|parent: &mut RelatedSpawner| { 155 | // The actual scrolling area. 156 | // Note that we're using `SpawnWith` here because we need to get the entity id of the 157 | // scroll area in order to set the target of the scrollbars. 158 | let scroll_area_id = parent 159 | .spawn(( 160 | Node { 161 | display: ui::Display::Flex, 162 | flex_direction: ui::FlexDirection::Column, 163 | padding: ui::UiRect::all(ui::Val::Px(4.0)), 164 | overflow: ui::Overflow::scroll(), 165 | ..default() 166 | }, 167 | BackgroundColor(colors::U3.into()), 168 | ScrollPosition { 169 | offset_x: 0.0, 170 | offset_y: 10.0, 171 | }, 172 | Children::spawn(( 173 | // The actual content of the scrolling area 174 | Spawn(text_row("Alpha Wolf")), 175 | Spawn(text_row("Beta Blocker")), 176 | Spawn(text_row("Delta Sleep")), 177 | Spawn(text_row("Gamma Ray")), 178 | Spawn(text_row("Epsilon Eridani")), 179 | Spawn(text_row("Zeta Function")), 180 | Spawn(text_row("Lambda Calculus")), 181 | Spawn(text_row("Nu Metal")), 182 | Spawn(text_row("Pi Day")), 183 | Spawn(text_row("Chi Pants")), 184 | Spawn(text_row("Psi Powers")), 185 | Spawn(text_row("Omega Fatty Acid")), 186 | )), 187 | )) 188 | .id(); 189 | 190 | // Vertical scrollbar 191 | parent.spawn(( 192 | Node { 193 | min_width: ui::Val::Px(8.0), 194 | grid_row: GridPlacement::start(1), 195 | grid_column: GridPlacement::start(2), 196 | ..default() 197 | }, 198 | Hovering(false), 199 | CoreScrollbar { 200 | orientation: Orientation::Vertical, 201 | target: scroll_area_id, 202 | min_thumb_size: 8.0, 203 | }, 204 | Children::spawn(Spawn(( 205 | Node { 206 | position_type: ui::PositionType::Absolute, 207 | ..default() 208 | }, 209 | Hovering(false), 210 | BackgroundColor(colors::U4.into()), 211 | BorderRadius::all(ui::Val::Px(4.0)), 212 | CoreScrollbarThumb, 213 | ))), 214 | )); 215 | 216 | // Horizontal scrollbar 217 | parent.spawn(( 218 | Node { 219 | min_height: ui::Val::Px(8.0), 220 | grid_row: GridPlacement::start(2), 221 | grid_column: GridPlacement::start(1), 222 | ..default() 223 | }, 224 | Hovering(false), 225 | CoreScrollbar { 226 | orientation: Orientation::Horizontal, 227 | target: scroll_area_id, 228 | min_thumb_size: 8.0, 229 | }, 230 | Children::spawn(Spawn(( 231 | Node { 232 | position_type: ui::PositionType::Absolute, 233 | ..default() 234 | }, 235 | Hovering(false), 236 | BackgroundColor(colors::U4.into()), 237 | BorderRadius::all(ui::Val::Px(4.0)), 238 | CoreScrollbarThumb, 239 | ))), 240 | )); 241 | }),)), 242 | ) 243 | } 244 | 245 | /// Create a list row 246 | fn text_row(caption: &str) -> impl Bundle { 247 | ( 248 | Text::new(caption), 249 | TextFont { 250 | font_size: 14.0, 251 | ..default() 252 | }, 253 | ) 254 | } 255 | 256 | // Update the button's background color. 257 | #[allow(clippy::type_complexity)] 258 | fn update_scrollbar_thumb( 259 | mut q_thumb: Query<(&CoreScrollbarThumb, &mut BackgroundColor, &Hovering), Changed>, 260 | ) { 261 | for (_thumb, mut thumb_bg, Hovering(is_hovering)) in q_thumb.iter_mut() { 262 | let color: Color = if *is_hovering { 263 | // If hovering, use a lighter color 264 | colors::U5 265 | } else { 266 | // Default color for the slider 267 | colors::U4 268 | } 269 | .into(); 270 | 271 | if thumb_bg.0 != color { 272 | // Update the color of the thumb 273 | thumb_bg.0 = color; 274 | } 275 | } 276 | } 277 | 278 | #[derive(Component, Default)] 279 | #[component(immutable, on_add = on_set_label, on_replace = on_set_label)] 280 | struct AccessibleName(String); 281 | 282 | // Hook to set the a11y "checked" state when the checkbox is added. 283 | fn on_set_label(mut world: DeferredWorld, context: HookContext) { 284 | let mut entt = world.entity_mut(context.entity); 285 | let name = entt.get::().unwrap().0.clone(); 286 | if let Some(mut accessibility) = entt.get_mut::() { 287 | accessibility.set_label(name.as_str()); 288 | } 289 | } 290 | 291 | mod colors { 292 | use bevy::color::Srgba; 293 | 294 | pub const U3: Srgba = Srgba::new(0.224, 0.224, 0.243, 1.0); 295 | pub const U4: Srgba = Srgba::new(0.486, 0.486, 0.529, 1.0); 296 | pub const U5: Srgba = Srgba::new(1.0, 1.0, 1.0, 1.0); 297 | // pub const PRIMARY: Srgba = Srgba::new(0.341, 0.435, 0.525, 1.0); 298 | // pub const DESTRUCTIVE: Srgba = Srgba::new(0.525, 0.341, 0.404, 1.0); 299 | pub const FOCUS: Srgba = Srgba::new(0.055, 0.647, 0.914, 0.15); 300 | } 301 | -------------------------------------------------------------------------------- /examples/controls.rs: -------------------------------------------------------------------------------- 1 | //! Demonstrations of the various core widgets. 2 | //! 3 | //! Note that this example should not be used as a basis for a real application. A real application 4 | //! would likely use a more sophisticated UI framework or library that includes composable styles, 5 | //! templates, reactive signals, and other techniques that improve the developer experience. This 6 | //! example has been written in a very brute-force, low-level style so as to demonstrate the 7 | //! functionality of the core widgets with minimal dependencies. 8 | 9 | use bevy::{ 10 | a11y::AccessibilityNode, 11 | ecs::{component::HookContext, system::SystemId, world::DeferredWorld}, 12 | input_focus::{ 13 | tab_navigation::{TabGroup, TabIndex, TabNavigationPlugin}, 14 | InputDispatchPlugin, InputFocus, InputFocusVisible, 15 | }, 16 | prelude::*, 17 | ui, 18 | window::SystemCursorIcon, 19 | winit::{cursor::CursorIcon, WinitSettings}, 20 | }; 21 | use bevy_core_widgets::{ 22 | hover::Hovering, ButtonClicked, ButtonPressed, Checked, CoreButton, CoreCheckbox, CoreRadio, 23 | CoreRadioGroup, CoreSlider, CoreWidgetsPlugin, InteractionDisabled, SliderDragState, 24 | ValueChange, 25 | }; 26 | 27 | fn main() { 28 | App::new() 29 | .add_plugins(( 30 | DefaultPlugins, 31 | CoreWidgetsPlugin, 32 | InputDispatchPlugin, 33 | TabNavigationPlugin, 34 | )) 35 | .insert_resource(WinitSettings::desktop_app()) 36 | .add_systems(Startup, setup_view_root) 37 | .add_systems( 38 | Update, 39 | ( 40 | update_button_bg_colors, 41 | update_focus_rect, 42 | update_checkbox_colors, 43 | update_radio_colors, 44 | update_slider_thumb, 45 | close_on_esc, 46 | ), 47 | ) 48 | .run(); 49 | } 50 | 51 | fn setup_view_root(mut commands: Commands) { 52 | let camera = commands.spawn((Camera::default(), Camera2d)).id(); 53 | 54 | // Demonstration click handler. 55 | let on_click = commands.register_system(|| { 56 | info!("Button on_click handler called!"); 57 | }); 58 | 59 | commands.spawn(( 60 | Node { 61 | display: Display::Flex, 62 | flex_direction: FlexDirection::Column, 63 | position_type: PositionType::Absolute, 64 | left: ui::Val::Px(0.), 65 | top: ui::Val::Px(0.), 66 | right: ui::Val::Px(0.), 67 | bottom: ui::Val::Px(0.), 68 | padding: ui::UiRect::all(Val::Px(3.)), 69 | row_gap: ui::Val::Px(6.), 70 | ..Default::default() 71 | }, 72 | BackgroundColor(Color::srgb(0.1, 0.1, 0.1)), 73 | UiTargetCamera(camera), 74 | TabGroup::default(), 75 | Children::spawn(( 76 | Spawn(Text::new("Button")), 77 | Spawn(buttons_demo(on_click)), 78 | Spawn(Text::new("Checkbox")), 79 | Spawn(checkbox_demo()), 80 | Spawn(Text::new("Radio")), 81 | Spawn(radio_demo()), 82 | Spawn(Text::new("Slider")), 83 | Spawn(slider_demo()), 84 | // Spawn(Text::new("SpinBox")), 85 | // Spawn(Text::new("DisclosureToggle")), 86 | )), 87 | )); 88 | 89 | // Observer for buttons that don't have an on_click handler. 90 | commands.add_observer( 91 | |mut trigger: Trigger, q_button: Query<&CoreButton>| { 92 | // If the button doesn't exist or is not a CoreButton 93 | if q_button.get(trigger.target()).is_ok() { 94 | trigger.propagate(false); 95 | let button_id = trigger.target(); 96 | info!("Got button click event: {:?}", button_id); 97 | } 98 | }, 99 | ); 100 | 101 | // Observer for checkboxes that don't have an on_change handler. 102 | commands.add_observer( 103 | |mut trigger: Trigger>, 104 | q_checkbox: Query<&CoreCheckbox>, 105 | mut commands: Commands| { 106 | trigger.propagate(false); 107 | if q_checkbox.contains(trigger.target()) { 108 | // Update checkbox state from event. 109 | let is_checked = trigger.event().0; 110 | commands 111 | .entity(trigger.target()) 112 | .insert(Checked(is_checked)); 113 | info!("New checkbox state: {:?}", is_checked); 114 | } 115 | }, 116 | ); 117 | 118 | // Observer for radio buttons. 119 | commands.add_observer( 120 | |mut trigger: Trigger>, 121 | q_radio_group: Query<&Children, With>, 122 | q_radio: Query<(&ChildOf, &RadioValue), With>, 123 | mut commands: Commands| { 124 | trigger.propagate(false); 125 | if q_radio_group.contains(trigger.target()) { 126 | // Update checkbox state from event. 127 | let selected_entity = trigger.event().0; 128 | let (child_of, radio_value) = q_radio.get(selected_entity).unwrap(); 129 | // Mutual exclusion logic 130 | let group_children = q_radio_group.get(child_of.parent()).unwrap(); 131 | for radio_child in group_children.iter() { 132 | if let Ok((_, value)) = q_radio.get(radio_child) { 133 | commands 134 | .entity(radio_child) 135 | .insert(Checked(value.0 == radio_value.0)); 136 | } 137 | } 138 | info!("Radio Value: {}", radio_value.0); 139 | } 140 | }, 141 | ); 142 | 143 | // Observer for sliders that don't have an on_change handler. 144 | commands.add_observer( 145 | |mut trigger: Trigger>, mut q_slider: Query<&mut CoreSlider>| { 146 | trigger.propagate(false); 147 | if let Ok(mut slider) = q_slider.get_mut(trigger.target()) { 148 | // Update slider state from event. 149 | slider.set_value(trigger.event().0); 150 | info!("New slider state: {:?}", slider.value()); 151 | } 152 | }, 153 | ); 154 | } 155 | 156 | pub fn close_on_esc(input: Res>, mut exit: EventWriter) { 157 | if input.just_pressed(KeyCode::Escape) { 158 | exit.write(AppExit::Success); 159 | } 160 | } 161 | 162 | /// The variant determines the button's color scheme 163 | #[derive(Clone, Copy, PartialEq, Default, Debug)] 164 | pub enum ButtonVariant { 165 | /// The default button apperance. 166 | #[default] 167 | Default, 168 | 169 | /// A more prominent, "call to action", appearance. 170 | Primary, 171 | 172 | /// An appearance indicating a potentially dangerous action. 173 | Danger, 174 | 175 | /// A button that is in a "toggled" state. 176 | Selected, 177 | } 178 | 179 | // Places an outline around the currently focused widget. This is a generic implementation for demo 180 | // purposes; in a real widget library the focus rectangle would be customized to the shape of 181 | // the individual widgets. 182 | #[allow(clippy::type_complexity)] 183 | fn update_focus_rect( 184 | mut query: Query<(Entity, Has), With>, 185 | focus: Res, 186 | focus_visible: ResMut, 187 | mut commands: Commands, 188 | ) { 189 | for (control, has_focus) in query.iter_mut() { 190 | let needs_focus = Some(control) == focus.0 && focus_visible.0; 191 | if needs_focus != has_focus { 192 | if needs_focus { 193 | commands.entity(control).insert(Outline { 194 | color: colors::FOCUS.into(), 195 | width: ui::Val::Px(2.0), 196 | offset: ui::Val::Px(1.0), 197 | }); 198 | } else { 199 | commands.entity(control).remove::(); 200 | } 201 | } 202 | } 203 | } 204 | 205 | /// Create a row of demo buttons 206 | fn buttons_demo(on_click: SystemId) -> impl Bundle { 207 | ( 208 | Node { 209 | display: ui::Display::Flex, 210 | flex_direction: ui::FlexDirection::Row, 211 | justify_content: ui::JustifyContent::Start, 212 | align_items: ui::AlignItems::Center, 213 | align_content: ui::AlignContent::Center, 214 | padding: ui::UiRect::axes(ui::Val::Px(12.0), ui::Val::Px(0.0)), 215 | column_gap: ui::Val::Px(6.0), 216 | ..default() 217 | }, 218 | Children::spawn(( 219 | Spawn(button("Open...", ButtonVariant::Default, Some(on_click))), 220 | Spawn(button("Save", ButtonVariant::Default, None)), 221 | Spawn(button("Create", ButtonVariant::Primary, None)), 222 | )), 223 | ) 224 | } 225 | 226 | #[derive(Component, Default)] 227 | struct DemoButton { 228 | variant: ButtonVariant, 229 | } 230 | 231 | /// Create a demo button 232 | fn button(caption: &str, variant: ButtonVariant, on_click: Option) -> impl Bundle { 233 | ( 234 | Node { 235 | display: ui::Display::Flex, 236 | flex_direction: ui::FlexDirection::Row, 237 | justify_content: ui::JustifyContent::Center, 238 | align_items: ui::AlignItems::Center, 239 | align_content: ui::AlignContent::Center, 240 | padding: ui::UiRect::axes(ui::Val::Px(12.0), ui::Val::Px(0.0)), 241 | border: ui::UiRect::all(ui::Val::Px(0.0)), 242 | min_height: ui::Val::Px(24.0), 243 | ..default() 244 | }, 245 | BorderRadius::all(ui::Val::Px(4.0)), 246 | Name::new("Button"), 247 | Hovering::default(), 248 | CursorIcon::System(SystemCursorIcon::Pointer), 249 | DemoButton { variant }, 250 | CoreButton { on_click }, 251 | AccessibleName(caption.to_string()), 252 | TabIndex(0), 253 | children![( 254 | Text::new(caption), 255 | TextFont { 256 | font_size: 14.0, 257 | ..default() 258 | } 259 | )], 260 | ) 261 | } 262 | 263 | // Update the button's background color. 264 | #[allow(clippy::type_complexity)] 265 | fn update_button_bg_colors( 266 | mut query: Query< 267 | ( 268 | &DemoButton, 269 | &mut BackgroundColor, 270 | &Hovering, 271 | &ButtonPressed, 272 | Has, 273 | ), 274 | Or<(Added, Changed, Changed)>, 275 | >, 276 | ) { 277 | for (button, mut bg_color, Hovering(is_hovering), ButtonPressed(is_pressed), is_disabled) in 278 | query.iter_mut() 279 | { 280 | // Update the background color based on the button's state 281 | let base_color = match button.variant { 282 | ButtonVariant::Default => colors::U3, 283 | ButtonVariant::Primary => colors::PRIMARY, 284 | ButtonVariant::Danger => colors::DESTRUCTIVE, 285 | ButtonVariant::Selected => colors::U4, 286 | }; 287 | 288 | let new_color = match (is_disabled, is_pressed, is_hovering) { 289 | (true, _, _) => base_color.with_alpha(0.2), 290 | (_, true, true) => base_color.lighter(0.07), 291 | (_, false, true) => base_color.lighter(0.03), 292 | _ => base_color, 293 | }; 294 | 295 | bg_color.0 = new_color.into(); 296 | } 297 | } 298 | 299 | /// Create a column of demo checkboxes 300 | fn checkbox_demo() -> impl Bundle { 301 | ( 302 | Node { 303 | display: ui::Display::Flex, 304 | flex_direction: ui::FlexDirection::Column, 305 | align_items: ui::AlignItems::Start, 306 | align_content: ui::AlignContent::Start, 307 | padding: ui::UiRect::axes(ui::Val::Px(12.0), ui::Val::Px(0.0)), 308 | row_gap: ui::Val::Px(6.0), 309 | ..default() 310 | }, 311 | Children::spawn(( 312 | Spawn(checkbox("Show Tutorial", true, None)), 313 | Spawn(checkbox("Just Kidding", false, None)), 314 | )), 315 | ) 316 | } 317 | 318 | #[derive(Component, Default)] 319 | struct DemoCheckbox; 320 | 321 | /// Create a demo checkbox 322 | fn checkbox(caption: &str, checked: bool, on_change: Option>>) -> impl Bundle { 323 | ( 324 | Node { 325 | display: ui::Display::Flex, 326 | flex_direction: ui::FlexDirection::Row, 327 | justify_content: ui::JustifyContent::FlexStart, 328 | align_items: ui::AlignItems::Center, 329 | align_content: ui::AlignContent::Center, 330 | column_gap: ui::Val::Px(4.0), 331 | ..default() 332 | }, 333 | Name::new("Checkbox"), 334 | AccessibleName(caption.to_string()), 335 | Hovering::default(), 336 | CursorIcon::System(SystemCursorIcon::Pointer), 337 | DemoCheckbox, 338 | CoreCheckbox { on_change }, 339 | Checked(checked), 340 | TabIndex(0), 341 | Children::spawn(( 342 | Spawn(( 343 | // Checkbox outer 344 | Node { 345 | display: ui::Display::Flex, 346 | width: ui::Val::Px(16.0), 347 | height: ui::Val::Px(16.0), 348 | border: ui::UiRect::all(ui::Val::Px(2.0)), 349 | ..default() 350 | }, 351 | BorderColor(colors::U4.into()), // Border color for the checkbox 352 | BorderRadius::all(ui::Val::Px(3.0)), 353 | children![ 354 | // Checkbox inner 355 | ( 356 | Node { 357 | display: ui::Display::Flex, 358 | width: ui::Val::Px(8.0), 359 | height: ui::Val::Px(8.0), 360 | position_type: ui::PositionType::Absolute, 361 | left: ui::Val::Px(2.0), 362 | top: ui::Val::Px(2.0), 363 | ..default() 364 | }, 365 | BackgroundColor(colors::PRIMARY.into()), 366 | ), 367 | ], 368 | )), 369 | Spawn(( 370 | Text::new(caption), 371 | TextFont { 372 | font_size: 14.0, 373 | ..default() 374 | }, 375 | )), 376 | )), 377 | ) 378 | } 379 | 380 | // Update the checkbox's background color. 381 | #[allow(clippy::type_complexity)] 382 | fn update_checkbox_colors( 383 | mut q_checkbox: Query< 384 | (&Checked, &Hovering, Has, &Children), 385 | ( 386 | With, 387 | Or<(Added, Changed, Changed)>, 388 | ), 389 | >, 390 | mut q_border_color: Query<(&mut BorderColor, &mut Children), Without>, 391 | mut q_bg_color: Query<&mut BackgroundColor, (Without, Without)>, 392 | ) { 393 | for (Checked(checked), Hovering(is_hovering), is_disabled, children) in q_checkbox.iter_mut() { 394 | let color: Color = if is_disabled { 395 | // If the checkbox is disabled, use a lighter color 396 | colors::U4.with_alpha(0.2) 397 | } else if *is_hovering { 398 | // If hovering, use a lighter color 399 | colors::U5 400 | } else { 401 | // Default color for the checkbox 402 | colors::U4 403 | } 404 | .into(); 405 | 406 | let Some(border_id) = children.first() else { 407 | continue; 408 | }; 409 | 410 | let Ok((mut border_color, border_children)) = q_border_color.get_mut(*border_id) else { 411 | continue; 412 | }; 413 | 414 | if border_color.0 != color { 415 | // Update the background color of the check mark 416 | border_color.0 = color; 417 | } 418 | 419 | let Some(mark_id) = border_children.first() else { 420 | warn!("Checkbox does not have a mark entity."); 421 | continue; 422 | }; 423 | 424 | let Ok(mut mark_bg) = q_bg_color.get_mut(*mark_id) else { 425 | warn!("Checkbox mark entity lacking a background color."); 426 | continue; 427 | }; 428 | 429 | let mark_color: Color = match (is_disabled, *checked) { 430 | (true, true) => colors::PRIMARY.with_alpha(0.5), 431 | (false, true) => colors::PRIMARY, 432 | (_, false) => Srgba::NONE, 433 | } 434 | .into(); 435 | 436 | if mark_bg.0 != mark_color { 437 | // Update the color of the check mark 438 | mark_bg.0 = mark_color; 439 | } 440 | } 441 | } 442 | 443 | /// Create a column of demo radio buttons 444 | fn radio_demo() -> impl Bundle { 445 | ( 446 | Node { 447 | display: ui::Display::Flex, 448 | flex_direction: ui::FlexDirection::Column, 449 | align_items: ui::AlignItems::Start, 450 | align_content: ui::AlignContent::Start, 451 | padding: ui::UiRect::axes(ui::Val::Px(12.0), ui::Val::Px(0.0)), 452 | row_gap: ui::Val::Px(6.0), 453 | ..default() 454 | }, 455 | TabIndex(0), 456 | CoreRadioGroup { on_change: None }, 457 | Children::spawn(( 458 | Spawn(radio("WKRP", true)), 459 | Spawn(radio("WPIG", false)), 460 | Spawn(radio("Galaxy News Radio", false)), 461 | Spawn(radio("KBBL-FM", false)), 462 | Spawn(radio("Radio Rock", false)), 463 | )), 464 | ) 465 | } 466 | 467 | #[derive(Component, Default)] 468 | struct DemoRadio; 469 | 470 | #[derive(Component, Default)] 471 | struct RadioValue(String); 472 | 473 | /// Create a demo radio button 474 | fn radio(caption: &str, checked: bool) -> impl Bundle { 475 | ( 476 | Node { 477 | display: ui::Display::Flex, 478 | flex_direction: ui::FlexDirection::Row, 479 | justify_content: ui::JustifyContent::FlexStart, 480 | align_items: ui::AlignItems::Center, 481 | align_content: ui::AlignContent::Center, 482 | column_gap: ui::Val::Px(4.0), 483 | ..default() 484 | }, 485 | Name::new("Radio"), 486 | AccessibleName(caption.to_string()), 487 | Hovering::default(), 488 | CursorIcon::System(SystemCursorIcon::Pointer), 489 | DemoRadio, 490 | CoreRadio, 491 | Checked(checked), 492 | RadioValue(caption.to_string()), 493 | Children::spawn(( 494 | Spawn(( 495 | // Radio outer 496 | Node { 497 | display: ui::Display::Flex, 498 | width: ui::Val::Px(16.0), 499 | height: ui::Val::Px(16.0), 500 | border: ui::UiRect::all(ui::Val::Px(2.0)), 501 | ..default() 502 | }, 503 | BorderColor(colors::U4.into()), // Border color for the radio 504 | BorderRadius::all(ui::Val::Percent(50.0)), 505 | children![ 506 | // Radio inner 507 | ( 508 | Node { 509 | display: ui::Display::Flex, 510 | width: ui::Val::Px(8.0), 511 | height: ui::Val::Px(8.0), 512 | position_type: ui::PositionType::Absolute, 513 | left: ui::Val::Px(2.0), 514 | top: ui::Val::Px(2.0), 515 | ..default() 516 | }, 517 | BackgroundColor(colors::PRIMARY.into()), 518 | BorderRadius::all(ui::Val::Percent(50.0)), 519 | ), 520 | ], 521 | )), 522 | Spawn(( 523 | Text::new(caption), 524 | TextFont { 525 | font_size: 14.0, 526 | ..default() 527 | }, 528 | )), 529 | )), 530 | ) 531 | } 532 | 533 | // Update the button's background color. 534 | #[allow(clippy::type_complexity)] 535 | fn update_radio_colors( 536 | mut q_radio: Query< 537 | (&Checked, &Hovering, Has, &Children), 538 | ( 539 | With, 540 | Or<(Added, Changed, Changed)>, 541 | ), 542 | >, 543 | mut q_border_color: Query<(&mut BorderColor, &mut Children), Without>, 544 | mut q_bg_color: Query<&mut BackgroundColor, (Without, Without)>, 545 | ) { 546 | for (Checked(checked), Hovering(is_hovering), is_disabled, children) in q_radio.iter_mut() { 547 | let color: Color = if is_disabled { 548 | // If the radio is disabled, use a lighter color 549 | colors::U4.with_alpha(0.2) 550 | } else if *is_hovering { 551 | // If hovering, use a lighter color 552 | colors::U5 553 | } else { 554 | // Default color for the radio 555 | colors::U4 556 | } 557 | .into(); 558 | 559 | let Some(border_id) = children.first() else { 560 | continue; 561 | }; 562 | 563 | let Ok((mut border_color, border_children)) = q_border_color.get_mut(*border_id) else { 564 | continue; 565 | }; 566 | 567 | if border_color.0 != color { 568 | // Update the background color of the check mark 569 | border_color.0 = color; 570 | } 571 | 572 | let Some(mark_id) = border_children.first() else { 573 | warn!("Radio does not have a mark entity."); 574 | continue; 575 | }; 576 | 577 | let Ok(mut mark_bg) = q_bg_color.get_mut(*mark_id) else { 578 | warn!("Radio mark entity lacking a background color."); 579 | continue; 580 | }; 581 | 582 | let mark_color: Color = match (is_disabled, *checked) { 583 | (true, true) => colors::PRIMARY.with_alpha(0.5), 584 | (false, true) => colors::PRIMARY, 585 | (_, false) => Srgba::NONE, 586 | } 587 | .into(); 588 | 589 | if mark_bg.0 != mark_color { 590 | // Update the color of the check mark 591 | mark_bg.0 = mark_color; 592 | } 593 | } 594 | } 595 | 596 | /// Create a column of demo checkboxes 597 | fn slider_demo() -> impl Bundle { 598 | ( 599 | Node { 600 | display: ui::Display::Flex, 601 | flex_direction: ui::FlexDirection::Column, 602 | align_items: ui::AlignItems::Start, 603 | align_content: ui::AlignContent::Start, 604 | width: ui::Val::Px(200.0), 605 | padding: ui::UiRect::axes(ui::Val::Px(12.0), ui::Val::Px(0.0)), 606 | row_gap: ui::Val::Px(6.0), 607 | ..default() 608 | }, 609 | Children::spawn(( 610 | Spawn(slider("Volume", 0.0, 100.0, 0.0, None)), 611 | Spawn(slider("Difficulty", 0.0, 10.0, 5.0, None)), 612 | )), 613 | ) 614 | } 615 | 616 | #[derive(Component, Default)] 617 | struct DemoSlider; 618 | 619 | /// Create a demo slider 620 | fn slider( 621 | label: &str, 622 | min: f32, 623 | max: f32, 624 | value: f32, 625 | on_change: Option>>, 626 | ) -> impl Bundle { 627 | ( 628 | Node { 629 | display: ui::Display::Flex, 630 | flex_direction: ui::FlexDirection::Column, 631 | justify_content: ui::JustifyContent::Center, 632 | align_self: ui::AlignSelf::Stretch, 633 | align_items: ui::AlignItems::Stretch, 634 | justify_items: ui::JustifyItems::Center, 635 | column_gap: ui::Val::Px(4.0), 636 | height: ui::Val::Px(12.0), 637 | ..default() 638 | }, 639 | Name::new("Slider"), 640 | AccessibleName(label.to_string()), 641 | Hovering::default(), 642 | CursorIcon::System(SystemCursorIcon::Pointer), 643 | DemoSlider, 644 | CoreSlider { 645 | max, 646 | min, 647 | value, 648 | on_change, 649 | thumb_size: 12.0, 650 | ..default() 651 | }, 652 | TabIndex(0), 653 | Children::spawn(( 654 | // Slider background rail 655 | Spawn(( 656 | Node { 657 | height: ui::Val::Px(6.0), 658 | ..default() 659 | }, 660 | BackgroundColor(colors::U3.into()), // Border color for the checkbox 661 | BorderRadius::all(ui::Val::Px(3.0)), 662 | )), 663 | // Invisible track to allow absolute placement of thumb entity. This is narrower than 664 | // the actual slider, which allows us to position the thumb entity using simple 665 | // percentages, without having to measure the actual width of the slider thumb. 666 | Spawn(( 667 | Node { 668 | display: ui::Display::Flex, 669 | position_type: ui::PositionType::Absolute, 670 | left: ui::Val::Px(0.0), 671 | right: ui::Val::Px(12.0), // Track is short by 12px to accommodate the thumb 672 | top: ui::Val::Px(0.0), 673 | bottom: ui::Val::Px(0.0), 674 | ..default() 675 | }, 676 | children![( 677 | // Thumb 678 | Node { 679 | display: ui::Display::Flex, 680 | width: ui::Val::Px(12.0), 681 | height: ui::Val::Px(12.0), 682 | position_type: ui::PositionType::Absolute, 683 | left: ui::Val::Percent(50.0), // This will be updated by the slider's value 684 | ..default() 685 | }, 686 | BorderRadius::all(ui::Val::Px(6.0)), 687 | BackgroundColor(colors::PRIMARY.into()), 688 | )], 689 | )), 690 | )), 691 | ) 692 | } 693 | 694 | // Update the button's background color. 695 | #[allow(clippy::type_complexity)] 696 | fn update_slider_thumb( 697 | mut q_radio: Query< 698 | ( 699 | &CoreSlider, 700 | &SliderDragState, 701 | &Hovering, 702 | Has, 703 | &Children, 704 | ), 705 | ( 706 | With, 707 | Or<(Added, Changed, Changed)>, 708 | ), 709 | >, 710 | mut q_track: Query<&mut Children, Without>, 711 | mut q_thumb: Query<(&mut BackgroundColor, &mut Node), (Without, Without)>, 712 | ) { 713 | for (slider_state, drag_state, Hovering(is_hovering), is_disabled, children) in 714 | q_radio.iter_mut() 715 | { 716 | let color: Color = if is_disabled { 717 | // If the slider is disabled, use a lighter color 718 | colors::U4.with_alpha(0.2) 719 | } else if *is_hovering || drag_state.dragging { 720 | // If hovering, use a lighter color 721 | colors::U5 722 | } else { 723 | // Default color for the slider 724 | colors::U4 725 | } 726 | .into(); 727 | 728 | let Some(track_id) = children.last() else { 729 | warn!("Slider does not have a track entity."); 730 | continue; 731 | }; 732 | 733 | let Ok(track_children) = q_track.get_mut(*track_id) else { 734 | continue; 735 | }; 736 | 737 | let Some(mark_id) = track_children.first() else { 738 | warn!("Slider does not have a thumb entity."); 739 | continue; 740 | }; 741 | 742 | let Ok((mut thumb_bg, mut node)) = q_thumb.get_mut(*mark_id) else { 743 | warn!("Slider thumb lacking a background color or node."); 744 | continue; 745 | }; 746 | 747 | if thumb_bg.0 != color { 748 | // Update the color of the thumb 749 | thumb_bg.0 = color; 750 | } 751 | 752 | let thumb_position = ui::Val::Percent(slider_state.thumb_position() * 100.0); 753 | if node.left != thumb_position { 754 | node.left = thumb_position; 755 | } 756 | } 757 | } 758 | 759 | #[derive(Component, Default)] 760 | #[component(immutable, on_add = on_set_label, on_replace = on_set_label)] 761 | struct AccessibleName(String); 762 | 763 | // Hook to set the a11y "checked" state when the checkbox is added. 764 | fn on_set_label(mut world: DeferredWorld, context: HookContext) { 765 | let mut entt = world.entity_mut(context.entity); 766 | let name = entt.get::().unwrap().0.clone(); 767 | if let Some(mut accessibility) = entt.get_mut::() { 768 | accessibility.set_label(name.as_str()); 769 | } 770 | } 771 | 772 | mod colors { 773 | use bevy::color::Srgba; 774 | 775 | pub const U3: Srgba = Srgba::new(0.224, 0.224, 0.243, 1.0); 776 | pub const U4: Srgba = Srgba::new(0.486, 0.486, 0.529, 1.0); 777 | pub const U5: Srgba = Srgba::new(1.0, 1.0, 1.0, 1.0); 778 | pub const PRIMARY: Srgba = Srgba::new(0.341, 0.435, 0.525, 1.0); 779 | pub const DESTRUCTIVE: Srgba = Srgba::new(0.525, 0.341, 0.404, 1.0); 780 | pub const FOCUS: Srgba = Srgba::new(0.055, 0.647, 0.914, 0.15); 781 | } 782 | --------------------------------------------------------------------------------