From d2e1f725db05feee42d19f73728fb724db8ff209 Mon Sep 17 00:00:00 2001 From: theotherphil Date: Thu, 19 Jun 2025 20:33:49 +0100 Subject: [PATCH 01/21] deny(missing_docs) for bevy_window (#19655) # Objective Write some more boilerplate-y docs, to get one crate closer to closing https://github.com/bevyengine/bevy/issues/3492. --- crates/bevy_window/src/event.rs | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/bevy_window/src/event.rs b/crates/bevy_window/src/event.rs index 5a320439d7..81360ef9c4 100644 --- a/crates/bevy_window/src/event.rs +++ b/crates/bevy_window/src/event.rs @@ -502,38 +502,66 @@ impl AppLifecycle { all(feature = "serialize", feature = "bevy_reflect"), reflect(Serialize, Deserialize) )] -#[expect(missing_docs, reason = "Not all docs are written yet, see #3492.")] pub enum WindowEvent { + /// An application lifecycle event. AppLifecycle(AppLifecycle), + /// The user's cursor has entered a window. CursorEntered(CursorEntered), + ///The user's cursor has left a window. CursorLeft(CursorLeft), + /// The user's cursor has moved inside a window. CursorMoved(CursorMoved), + /// A file drag and drop event. FileDragAndDrop(FileDragAndDrop), + /// An Input Method Editor event. Ime(Ime), + /// A redraw of all of the application's windows has been requested. RequestRedraw(RequestRedraw), + /// The window's OS-reported scale factor has changed. WindowBackendScaleFactorChanged(WindowBackendScaleFactorChanged), + /// The OS has requested that a window be closed. WindowCloseRequested(WindowCloseRequested), + /// A new window has been created. WindowCreated(WindowCreated), + /// A window has been destroyed by the underlying windowing system. WindowDestroyed(WindowDestroyed), + /// A window has received or lost focus. WindowFocused(WindowFocused), + /// A window has been moved. WindowMoved(WindowMoved), + /// A window has started or stopped being occluded. WindowOccluded(WindowOccluded), + /// A window's logical size has changed. WindowResized(WindowResized), + /// A window's scale factor has changed. WindowScaleFactorChanged(WindowScaleFactorChanged), + /// Sent for windows that are using the system theme when the system theme changes. WindowThemeChanged(WindowThemeChanged), + /// The state of a mouse button has changed. MouseButtonInput(MouseButtonInput), + /// The physical position of a pointing device has changed. MouseMotion(MouseMotion), + /// The mouse wheel has moved. MouseWheel(MouseWheel), + /// A two finger pinch gesture. PinchGesture(PinchGesture), + /// A two finger rotation gesture. RotationGesture(RotationGesture), + /// A double tap gesture. DoubleTapGesture(DoubleTapGesture), + /// A pan gesture. PanGesture(PanGesture), + /// A touch input state change. TouchInput(TouchInput), + /// A keyboard input. KeyboardInput(KeyboardInput), + /// Sent when focus has been lost for all Bevy windows. + /// + /// Used to clear pressed key state. KeyboardFocusLost(KeyboardFocusLost), } From b6bd205e8a5ea5d8424608dc8b74d61a85c7a839 Mon Sep 17 00:00:00 2001 From: theotherphil Date: Thu, 19 Jun 2025 22:58:17 +0100 Subject: [PATCH 02/21] Gate Reflect derive behind feature flag (#19745) # Objective Fix https://github.com/bevyengine/bevy/issues/19733 ## Solution Gate reflect features behind `feature(bevy_reflect)` ## Tests None --- crates/bevy_window/src/window.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/bevy_window/src/window.rs b/crates/bevy_window/src/window.rs index e2a9ca3c0f..403801e9d0 100644 --- a/crates/bevy_window/src/window.rs +++ b/crates/bevy_window/src/window.rs @@ -1168,13 +1168,17 @@ pub enum MonitorSelection { /// References an exclusive fullscreen video mode. /// /// Used when setting [`WindowMode::Fullscreen`] on a window. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Reflect)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr( - feature = "serialize", - derive(serde::Serialize, serde::Deserialize), + feature = "bevy_reflect", + derive(Reflect), + reflect(Debug, PartialEq, Clone) +)] +#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr( + all(feature = "serialize", feature = "bevy_reflect"), reflect(Serialize, Deserialize) )] -#[reflect(Debug, PartialEq, Clone)] pub enum VideoModeSelection { /// Uses the video mode that the monitor is already in. Current, From 8e1d0051d2c97dd75c788c874a2b7e652767cc57 Mon Sep 17 00:00:00 2001 From: Emerson Coskey Date: Fri, 20 Jun 2025 08:48:16 -0700 Subject: [PATCH 03/21] Fix QueryData derive codegen (#19750) Custom derived `QueryData` impls currently generate `Item` structs with the lifetimes swapped, which blows up the borrow checker sometimes. See: https://discord.com/channels/691052431525675048/749335865876021248/1385509416086011914 could add a regression test, TBH I don't know the error well enough to do that minimally. Seems like it's that both lifetimes on `QueryData::Item` need to be covariant, but I'm not sure. --- crates/bevy_ecs/macros/src/query_data.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bevy_ecs/macros/src/query_data.rs b/crates/bevy_ecs/macros/src/query_data.rs index 910d9ce3b6..12d9c2bf1c 100644 --- a/crates/bevy_ecs/macros/src/query_data.rs +++ b/crates/bevy_ecs/macros/src/query_data.rs @@ -83,7 +83,7 @@ pub fn derive_query_data_impl(input: TokenStream) -> TokenStream { let user_generics_with_world_and_state = { let mut generics = ast.generics; generics.params.insert(0, parse_quote!('__w)); - generics.params.insert(0, parse_quote!('__s)); + generics.params.insert(1, parse_quote!('__s)); generics }; let ( From 35166d9029be1ff05c36570e0350b5182488d29b Mon Sep 17 00:00:00 2001 From: Giacomo Stevanato Date: Fri, 20 Jun 2025 18:36:08 +0200 Subject: [PATCH 04/21] Refactor bundle derive (#19749) # Objective - Splitted off from #19491 - Make adding generated code to the `Bundle` derive macro easier - Fix a bug when multiple fields are `#[bundle(ignore)]` ## Solution - Instead of accumulating the code for each method in a different `Vec`, accumulate only the names of non-ignored fields and their types, then use `quote` to generate the code for each of them in the method body. - To fix the bug, change the code populating the `BundleFieldKind` to push only one of them per-field (previously each `#[bundle(ignore)]` resulted in pushing twice, once for the correct `BundleFieldKind::Ignore` and then again unconditionally for `BundleFieldKind::Component`) ## Testing - Added a regression test for the bug that was fixed --- crates/bevy_ecs/macros/src/lib.rs | 124 +++++++++++++----------------- crates/bevy_ecs/src/bundle.rs | 9 +++ 2 files changed, 61 insertions(+), 72 deletions(-) diff --git a/crates/bevy_ecs/macros/src/lib.rs b/crates/bevy_ecs/macros/src/lib.rs index 8090cff7de..7750f97259 100644 --- a/crates/bevy_ecs/macros/src/lib.rs +++ b/crates/bevy_ecs/macros/src/lib.rs @@ -16,7 +16,7 @@ use crate::{ use bevy_macro_utils::{derive_label, ensure_no_collision, get_struct_fields, BevyManifest}; use proc_macro::TokenStream; use proc_macro2::{Ident, Span}; -use quote::{format_ident, quote}; +use quote::{format_ident, quote, ToTokens}; use syn::{ parse_macro_input, parse_quote, punctuated::Punctuated, spanned::Spanned, token::Comma, ConstParam, Data, DataStruct, DeriveInput, GenericParam, Index, TypeParam, @@ -79,6 +79,8 @@ pub fn derive_bundle(input: TokenStream) -> TokenStream { let mut field_kind = Vec::with_capacity(named_fields.len()); for field in named_fields { + let mut kind = BundleFieldKind::Component; + for attr in field .attrs .iter() @@ -86,7 +88,7 @@ pub fn derive_bundle(input: TokenStream) -> TokenStream { { if let Err(error) = attr.parse_nested_meta(|meta| { if meta.path.is_ident(BUNDLE_ATTRIBUTE_IGNORE_NAME) { - field_kind.push(BundleFieldKind::Ignore); + kind = BundleFieldKind::Ignore; Ok(()) } else { Err(meta.error(format!( @@ -98,7 +100,7 @@ pub fn derive_bundle(input: TokenStream) -> TokenStream { } } - field_kind.push(BundleFieldKind::Component); + field_kind.push(kind); } let field = named_fields @@ -111,82 +113,33 @@ pub fn derive_bundle(input: TokenStream) -> TokenStream { .map(|field| &field.ty) .collect::>(); - let mut field_component_ids = Vec::new(); - let mut field_get_component_ids = Vec::new(); - let mut field_get_components = Vec::new(); - let mut field_from_components = Vec::new(); - let mut field_required_components = Vec::new(); + let mut active_field_types = Vec::new(); + let mut active_field_tokens = Vec::new(); + let mut inactive_field_tokens = Vec::new(); for (((i, field_type), field_kind), field) in field_type .iter() .enumerate() .zip(field_kind.iter()) .zip(field.iter()) { + let field_tokens = match field { + Some(field) => field.to_token_stream(), + None => Index::from(i).to_token_stream(), + }; match field_kind { BundleFieldKind::Component => { - field_component_ids.push(quote! { - <#field_type as #ecs_path::bundle::Bundle>::component_ids(components, &mut *ids); - }); - field_required_components.push(quote! { - <#field_type as #ecs_path::bundle::Bundle>::register_required_components(components, required_components); - }); - field_get_component_ids.push(quote! { - <#field_type as #ecs_path::bundle::Bundle>::get_component_ids(components, &mut *ids); - }); - match field { - Some(field) => { - field_get_components.push(quote! { - self.#field.get_components(&mut *func); - }); - field_from_components.push(quote! { - #field: <#field_type as #ecs_path::bundle::BundleFromComponents>::from_components(ctx, &mut *func), - }); - } - None => { - let index = Index::from(i); - field_get_components.push(quote! { - self.#index.get_components(&mut *func); - }); - field_from_components.push(quote! { - #index: <#field_type as #ecs_path::bundle::BundleFromComponents>::from_components(ctx, &mut *func), - }); - } - } + active_field_types.push(field_type); + active_field_tokens.push(field_tokens); } - BundleFieldKind::Ignore => { - field_from_components.push(quote! { - #field: ::core::default::Default::default(), - }); - } + BundleFieldKind::Ignore => inactive_field_tokens.push(field_tokens), } } let generics = ast.generics; let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); let struct_name = &ast.ident; - let from_components = attributes.impl_from_components.then(|| quote! { - // SAFETY: - // - ComponentId is returned in field-definition-order. [from_components] uses field-definition-order - #[allow(deprecated)] - unsafe impl #impl_generics #ecs_path::bundle::BundleFromComponents for #struct_name #ty_generics #where_clause { - #[allow(unused_variables, non_snake_case)] - unsafe fn from_components<__T, __F>(ctx: &mut __T, func: &mut __F) -> Self - where - __F: FnMut(&mut __T) -> #ecs_path::ptr::OwningPtr<'_> - { - Self{ - #(#field_from_components)* - } - } - } - }); - - let attribute_errors = &errors; - - TokenStream::from(quote! { - #(#attribute_errors)* - + let bundle_impl = quote! { // SAFETY: // - ComponentId is returned in field-definition-order. [get_components] uses field-definition-order // - `Bundle::get_components` is exactly once for each member. Rely's on the Component -> Bundle implementation to properly pass @@ -196,27 +149,27 @@ pub fn derive_bundle(input: TokenStream) -> TokenStream { fn component_ids( components: &mut #ecs_path::component::ComponentsRegistrator, ids: &mut impl FnMut(#ecs_path::component::ComponentId) - ){ - #(#field_component_ids)* + ) { + #(<#active_field_types as #ecs_path::bundle::Bundle>::component_ids(components, ids);)* } fn get_component_ids( components: &#ecs_path::component::Components, ids: &mut impl FnMut(Option<#ecs_path::component::ComponentId>) - ){ - #(#field_get_component_ids)* + ) { + #(<#active_field_types as #ecs_path::bundle::Bundle>::get_component_ids(components, &mut *ids);)* } fn register_required_components( components: &mut #ecs_path::component::ComponentsRegistrator, required_components: &mut #ecs_path::component::RequiredComponents - ){ - #(#field_required_components)* + ) { + #(<#active_field_types as #ecs_path::bundle::Bundle>::register_required_components(components, required_components);)* } } + }; - #from_components - + let dynamic_bundle_impl = quote! { #[allow(deprecated)] impl #impl_generics #ecs_path::bundle::DynamicBundle for #struct_name #ty_generics #where_clause { type Effect = (); @@ -226,9 +179,36 @@ pub fn derive_bundle(input: TokenStream) -> TokenStream { self, func: &mut impl FnMut(#ecs_path::component::StorageType, #ecs_path::ptr::OwningPtr<'_>) ) { - #(#field_get_components)* + #(<#active_field_types as #ecs_path::bundle::DynamicBundle>::get_components(self.#active_field_tokens, &mut *func);)* } } + }; + + let from_components_impl = attributes.impl_from_components.then(|| quote! { + // SAFETY: + // - ComponentId is returned in field-definition-order. [from_components] uses field-definition-order + #[allow(deprecated)] + unsafe impl #impl_generics #ecs_path::bundle::BundleFromComponents for #struct_name #ty_generics #where_clause { + #[allow(unused_variables, non_snake_case)] + unsafe fn from_components<__T, __F>(ctx: &mut __T, func: &mut __F) -> Self + where + __F: FnMut(&mut __T) -> #ecs_path::ptr::OwningPtr<'_> + { + Self { + #(#active_field_tokens: <#active_field_types as #ecs_path::bundle::BundleFromComponents>::from_components(ctx, &mut *func),)* + #(#inactive_field_tokens: ::core::default::Default::default(),)* + } + } + } + }); + + let attribute_errors = &errors; + + TokenStream::from(quote! { + #(#attribute_errors)* + #bundle_impl + #from_components_impl + #dynamic_bundle_impl }) } diff --git a/crates/bevy_ecs/src/bundle.rs b/crates/bevy_ecs/src/bundle.rs index 2687f7eb16..8efdc60ad9 100644 --- a/crates/bevy_ecs/src/bundle.rs +++ b/crates/bevy_ecs/src/bundle.rs @@ -2397,4 +2397,13 @@ mod tests { assert_eq!(world.resource::().0, 3); } + + #[derive(Bundle)] + #[expect(unused, reason = "tests the output of the derive macro is valid")] + struct Ignore { + #[bundle(ignore)] + foo: i32, + #[bundle(ignore)] + bar: i32, + } } From 9fdddf70894de08557d95052dd5686ed8cfd0de0 Mon Sep 17 00:00:00 2001 From: Talin Date: Fri, 20 Jun 2025 09:37:18 -0700 Subject: [PATCH 05/21] Core Checkbox (#19665) # Objective This is part of the "core widgets" effort: https://github.com/bevyengine/bevy/issues/19236. ## Solution This adds the "core checkbox" widget type. ## Testing Tested using examples core_widgets and core_widgets_observers. Note to reviewers: I reorganized the code in the examples, so the diffs are large because of code moves. --- crates/bevy_core_widgets/src/core_checkbox.rs | 179 ++++++ crates/bevy_core_widgets/src/lib.rs | 4 +- crates/bevy_ui/src/interaction_states.rs | 37 +- crates/bevy_ui/src/lib.rs | 8 +- examples/ui/core_widgets.rs | 440 ++++++++++----- examples/ui/core_widgets_observers.rs | 513 +++++++++++++----- .../release-notes/headless-widgets.md | 3 +- 7 files changed, 889 insertions(+), 295 deletions(-) create mode 100644 crates/bevy_core_widgets/src/core_checkbox.rs diff --git a/crates/bevy_core_widgets/src/core_checkbox.rs b/crates/bevy_core_widgets/src/core_checkbox.rs new file mode 100644 index 0000000000..fc12811055 --- /dev/null +++ b/crates/bevy_core_widgets/src/core_checkbox.rs @@ -0,0 +1,179 @@ +use accesskit::Role; +use bevy_a11y::AccessibilityNode; +use bevy_app::{App, Plugin}; +use bevy_ecs::event::{EntityEvent, Event}; +use bevy_ecs::query::{Has, Without}; +use bevy_ecs::system::{In, ResMut}; +use bevy_ecs::{ + component::Component, + observer::On, + system::{Commands, Query, SystemId}, +}; +use bevy_input::keyboard::{KeyCode, KeyboardInput}; +use bevy_input::ButtonState; +use bevy_input_focus::{FocusedInput, InputFocus, InputFocusVisible}; +use bevy_picking::events::{Click, Pointer}; +use bevy_ui::{Checkable, Checked, InteractionDisabled}; + +/// Headless widget implementation for checkboxes. The [`Checked`] component represents the current +/// state of the checkbox. The `on_change` field is an optional system id that will be run when the +/// checkbox is clicked, or when the `Enter` or `Space` key is pressed while the checkbox is +/// focused. If the `on_change` field is `None`, then instead of calling a callback, the checkbox +/// will update its own [`Checked`] state directly. +/// +/// # Toggle switches +/// +/// The [`CoreCheckbox`] component can be used to implement other kinds of toggle widgets. If you +/// are going to do a toggle switch, you should override the [`AccessibilityNode`] component with +/// the `Switch` role instead of the `Checkbox` role. +#[derive(Component, Debug, Default)] +#[require(AccessibilityNode(accesskit::Node::new(Role::CheckBox)), Checkable)] +pub struct CoreCheckbox { + /// One-shot system that is run when the checkbox state needs to be changed. + pub on_change: Option>>, +} + +fn checkbox_on_key_input( + mut ev: On>, + q_checkbox: Query<(&CoreCheckbox, Has), Without>, + mut commands: Commands, +) { + if let Ok((checkbox, is_checked)) = q_checkbox.get(ev.target()) { + let event = &ev.event().input; + if event.state == ButtonState::Pressed + && !event.repeat + && (event.key_code == KeyCode::Enter || event.key_code == KeyCode::Space) + { + ev.propagate(false); + set_checkbox_state(&mut commands, ev.target(), checkbox, !is_checked); + } + } +} + +fn checkbox_on_pointer_click( + mut ev: On>, + q_checkbox: Query<(&CoreCheckbox, Has, Has)>, + focus: Option>, + focus_visible: Option>, + mut commands: Commands, +) { + if let Ok((checkbox, is_checked, disabled)) = q_checkbox.get(ev.target()) { + // Clicking on a button makes it the focused input, + // and hides the focus ring if it was visible. + if let Some(mut focus) = focus { + focus.0 = Some(ev.target()); + } + if let Some(mut focus_visible) = focus_visible { + focus_visible.0 = false; + } + + ev.propagate(false); + if !disabled { + set_checkbox_state(&mut commands, ev.target(), checkbox, !is_checked); + } + } +} + +/// Event which can be triggered on a checkbox to set the checked state. This can be used to control +/// the checkbox via gamepad buttons or other inputs. +/// +/// # Example: +/// +/// ``` +/// use bevy_ecs::system::Commands; +/// use bevy_core_widgets::{CoreCheckbox, SetChecked}; +/// +/// fn setup(mut commands: Commands) { +/// // Create a checkbox +/// let checkbox = commands.spawn(( +/// CoreCheckbox::default(), +/// )).id(); +/// +/// // Set to checked +/// commands.trigger_targets(SetChecked(true), checkbox); +/// } +/// ``` +#[derive(Event, EntityEvent)] +pub struct SetChecked(pub bool); + +/// Event which can be triggered on a checkbox to toggle the checked state. This can be used to +/// control the checkbox via gamepad buttons or other inputs. +/// +/// # Example: +/// +/// ``` +/// use bevy_ecs::system::Commands; +/// use bevy_core_widgets::{CoreCheckbox, ToggleChecked}; +/// +/// fn setup(mut commands: Commands) { +/// // Create a checkbox +/// let checkbox = commands.spawn(( +/// CoreCheckbox::default(), +/// )).id(); +/// +/// // Set to checked +/// commands.trigger_targets(ToggleChecked, checkbox); +/// } +/// ``` +#[derive(Event, EntityEvent)] +pub struct ToggleChecked; + +fn checkbox_on_set_checked( + mut ev: On, + q_checkbox: Query<(&CoreCheckbox, Has, Has)>, + mut commands: Commands, +) { + if let Ok((checkbox, is_checked, disabled)) = q_checkbox.get(ev.target()) { + ev.propagate(false); + if disabled { + return; + } + + let will_be_checked = ev.event().0; + if will_be_checked != is_checked { + set_checkbox_state(&mut commands, ev.target(), checkbox, will_be_checked); + } + } +} + +fn checkbox_on_toggle_checked( + mut ev: On, + q_checkbox: Query<(&CoreCheckbox, Has, Has)>, + mut commands: Commands, +) { + if let Ok((checkbox, is_checked, disabled)) = q_checkbox.get(ev.target()) { + ev.propagate(false); + if disabled { + return; + } + + set_checkbox_state(&mut commands, ev.target(), checkbox, !is_checked); + } +} + +fn set_checkbox_state( + commands: &mut Commands, + entity: impl Into, + checkbox: &CoreCheckbox, + new_state: bool, +) { + if let Some(on_change) = checkbox.on_change { + commands.run_system_with(on_change, new_state); + } else if new_state { + commands.entity(entity.into()).insert(Checked); + } else { + commands.entity(entity.into()).remove::(); + } +} + +/// Plugin that adds the observers for the [`CoreCheckbox`] widget. +pub struct CoreCheckboxPlugin; + +impl Plugin for CoreCheckboxPlugin { + fn build(&self, app: &mut App) { + app.add_observer(checkbox_on_key_input) + .add_observer(checkbox_on_pointer_click) + .add_observer(checkbox_on_set_checked) + .add_observer(checkbox_on_toggle_checked); + } +} diff --git a/crates/bevy_core_widgets/src/lib.rs b/crates/bevy_core_widgets/src/lib.rs index 00812bddfc..cdb9142b52 100644 --- a/crates/bevy_core_widgets/src/lib.rs +++ b/crates/bevy_core_widgets/src/lib.rs @@ -15,11 +15,13 @@ // the widget level, like `SliderValue`, should not have the `Core` prefix. mod core_button; +mod core_checkbox; mod core_slider; use bevy_app::{App, Plugin}; pub use core_button::{CoreButton, CoreButtonPlugin}; +pub use core_checkbox::{CoreCheckbox, CoreCheckboxPlugin, SetChecked, ToggleChecked}; pub use core_slider::{ CoreSlider, CoreSliderDragState, CoreSliderPlugin, CoreSliderThumb, SetSliderValue, SliderRange, SliderStep, SliderValue, TrackClick, @@ -31,6 +33,6 @@ pub struct CoreWidgetsPlugin; impl Plugin for CoreWidgetsPlugin { fn build(&self, app: &mut App) { - app.add_plugins((CoreButtonPlugin, CoreSliderPlugin)); + app.add_plugins((CoreButtonPlugin, CoreCheckboxPlugin, CoreSliderPlugin)); } } diff --git a/crates/bevy_ui/src/interaction_states.rs b/crates/bevy_ui/src/interaction_states.rs index 6659204da4..b50f4cc245 100644 --- a/crates/bevy_ui/src/interaction_states.rs +++ b/crates/bevy_ui/src/interaction_states.rs @@ -2,7 +2,7 @@ use bevy_a11y::AccessibilityNode; use bevy_ecs::{ component::Component, - lifecycle::{Add, Insert, Remove}, + lifecycle::{Add, Remove}, observer::On, world::DeferredWorld, }; @@ -40,21 +40,17 @@ pub(crate) fn on_remove_disabled( #[derive(Component, Default, Debug)] pub struct Pressed; +/// Component that indicates that a widget can be checked. +#[derive(Component, Default, Debug)] +pub struct Checkable; + /// Component that indicates whether a checkbox or radio button is in a checked state. #[derive(Component, Default, Debug)] -#[component(immutable)] -pub struct Checked(pub bool); +pub struct Checked; -impl Checked { - /// Returns whether the checkbox or radio button is currently checked. - pub fn get(&self) -> bool { - self.0 - } -} - -pub(crate) fn on_insert_is_checked(trigger: On, mut world: DeferredWorld) { +pub(crate) fn on_add_checkable(trigger: On, mut world: DeferredWorld) { let mut entity = world.entity_mut(trigger.target()); - let checked = entity.get::().unwrap().get(); + let checked = entity.get::().is_some(); if let Some(mut accessibility) = entity.get_mut::() { accessibility.set_toggled(match checked { true => accesskit::Toggled::True, @@ -63,7 +59,22 @@ pub(crate) fn on_insert_is_checked(trigger: On, mut world: Defe } } -pub(crate) fn on_remove_is_checked(trigger: On, mut world: DeferredWorld) { +pub(crate) fn on_remove_checkable(trigger: On, mut world: DeferredWorld) { + // Remove the 'toggled' attribute entirely. + let mut entity = world.entity_mut(trigger.target()); + if let Some(mut accessibility) = entity.get_mut::() { + accessibility.clear_toggled(); + } +} + +pub(crate) fn on_add_checked(trigger: On, mut world: DeferredWorld) { + let mut entity = world.entity_mut(trigger.target()); + if let Some(mut accessibility) = entity.get_mut::() { + accessibility.set_toggled(accesskit::Toggled::True); + } +} + +pub(crate) fn on_remove_checked(trigger: On, mut world: DeferredWorld) { let mut entity = world.entity_mut(trigger.target()); if let Some(mut accessibility) = entity.get_mut::() { accessibility.set_toggled(accesskit::Toggled::False); diff --git a/crates/bevy_ui/src/lib.rs b/crates/bevy_ui/src/lib.rs index ac70897d06..47d396b201 100644 --- a/crates/bevy_ui/src/lib.rs +++ b/crates/bevy_ui/src/lib.rs @@ -39,7 +39,7 @@ mod ui_node; pub use focus::*; pub use geometry::*; pub use gradients::*; -pub use interaction_states::{Checked, InteractionDisabled, Pressed}; +pub use interaction_states::{Checkable, Checked, InteractionDisabled, Pressed}; pub use layout::*; pub use measurement::*; pub use render::*; @@ -323,8 +323,10 @@ fn build_text_interop(app: &mut App) { app.add_observer(interaction_states::on_add_disabled) .add_observer(interaction_states::on_remove_disabled) - .add_observer(interaction_states::on_insert_is_checked) - .add_observer(interaction_states::on_remove_is_checked); + .add_observer(interaction_states::on_add_checkable) + .add_observer(interaction_states::on_remove_checkable) + .add_observer(interaction_states::on_add_checked) + .add_observer(interaction_states::on_remove_checked); app.configure_sets( PostUpdate, diff --git a/examples/ui/core_widgets.rs b/examples/ui/core_widgets.rs index 27884855fa..fbd4dcd718 100644 --- a/examples/ui/core_widgets.rs +++ b/examples/ui/core_widgets.rs @@ -3,8 +3,8 @@ use bevy::{ color::palettes::basic::*, core_widgets::{ - CoreButton, CoreSlider, CoreSliderThumb, CoreWidgetsPlugin, SliderRange, SliderValue, - TrackClick, + CoreButton, CoreCheckbox, CoreSlider, CoreSliderThumb, CoreWidgetsPlugin, SliderRange, + SliderValue, TrackClick, }, ecs::system::SystemId, input_focus::{ @@ -13,7 +13,7 @@ use bevy::{ }, picking::hover::Hovered, prelude::*, - ui::{InteractionDisabled, Pressed}, + ui::{Checked, InteractionDisabled, Pressed}, winit::WinitSettings, }; @@ -32,6 +32,8 @@ fn main() { update_button_style2, update_slider_style.after(update_widget_values), update_slider_style2.after(update_widget_values), + update_checkbox_style.after(update_widget_values), + update_checkbox_style2.after(update_widget_values), toggle_disabled, ), ) @@ -43,6 +45,8 @@ const HOVERED_BUTTON: Color = Color::srgb(0.25, 0.25, 0.25); const PRESSED_BUTTON: Color = Color::srgb(0.35, 0.75, 0.35); const SLIDER_TRACK: Color = Color::srgb(0.05, 0.05, 0.05); const SLIDER_THUMB: Color = Color::srgb(0.35, 0.75, 0.35); +const CHECKBOX_OUTLINE: Color = Color::srgb(0.45, 0.45, 0.45); +const CHECKBOX_CHECK: Color = Color::srgb(0.35, 0.75, 0.35); /// Marker which identifies buttons with a particular style, in this case the "Demo style". #[derive(Component)] @@ -56,6 +60,10 @@ struct DemoSlider; #[derive(Component, Default)] struct DemoSliderThumb; +/// Marker which identifies checkboxes with a particular style. +#[derive(Component, Default)] +struct DemoCheckbox; + /// A struct to hold the state of various widgets shown in the demo. /// /// While it is possible to use the widget's own state components as the source of truth, @@ -67,128 +75,6 @@ struct DemoWidgetStates { slider_value: f32, } -fn update_button_style( - mut buttons: Query< - ( - Has, - &Hovered, - Has, - &mut BackgroundColor, - &mut BorderColor, - &Children, - ), - ( - Or<( - Changed, - Changed, - Added, - )>, - With, - ), - >, - mut text_query: Query<&mut Text>, -) { - for (pressed, hovered, disabled, mut color, mut border_color, children) in &mut buttons { - let mut text = text_query.get_mut(children[0]).unwrap(); - set_button_style( - disabled, - hovered.get(), - pressed, - &mut color, - &mut border_color, - &mut text, - ); - } -} - -/// Supplementary system to detect removed marker components -fn update_button_style2( - mut buttons: Query< - ( - Has, - &Hovered, - Has, - &mut BackgroundColor, - &mut BorderColor, - &Children, - ), - With, - >, - mut removed_depressed: RemovedComponents, - mut removed_disabled: RemovedComponents, - mut text_query: Query<&mut Text>, -) { - removed_depressed.read().for_each(|entity| { - if let Ok((pressed, hovered, disabled, mut color, mut border_color, children)) = - buttons.get_mut(entity) - { - let mut text = text_query.get_mut(children[0]).unwrap(); - set_button_style( - disabled, - hovered.get(), - pressed, - &mut color, - &mut border_color, - &mut text, - ); - } - }); - removed_disabled.read().for_each(|entity| { - if let Ok((pressed, hovered, disabled, mut color, mut border_color, children)) = - buttons.get_mut(entity) - { - let mut text = text_query.get_mut(children[0]).unwrap(); - set_button_style( - disabled, - hovered.get(), - pressed, - &mut color, - &mut border_color, - &mut text, - ); - } - }); -} - -fn set_button_style( - disabled: bool, - hovered: bool, - pressed: bool, - color: &mut BackgroundColor, - border_color: &mut BorderColor, - text: &mut Text, -) { - match (disabled, hovered, pressed) { - // Disabled button - (true, _, _) => { - **text = "Disabled".to_string(); - *color = NORMAL_BUTTON.into(); - border_color.set_all(GRAY); - } - - // Pressed and hovered button - (false, true, true) => { - **text = "Press".to_string(); - *color = PRESSED_BUTTON.into(); - border_color.set_all(RED); - } - - // Hovered, unpressed button - (false, true, false) => { - **text = "Hover".to_string(); - *color = HOVERED_BUTTON.into(); - border_color.set_all(WHITE); - } - - // Unhovered button (either pressed or not). - (false, false, _) => { - **text = "Button".to_string(); - *color = NORMAL_BUTTON.into(); - border_color.set_all(BLACK); - } - } -} - /// Update the widget states based on the changing resource. fn update_widget_values( res: Res, @@ -243,6 +129,7 @@ fn demo_root( children![ button(asset_server, on_click), slider(0.0, 100.0, 50.0, Some(on_change_value)), + checkbox(asset_server, "Checkbox", None), Text::new("Press 'D' to toggle widget disabled states"), ], ) @@ -280,6 +167,116 @@ fn button(asset_server: &AssetServer, on_click: SystemId) -> impl Bundle { ) } +fn update_button_style( + mut buttons: Query< + ( + Has, + &Hovered, + Has, + &mut BackgroundColor, + &mut BorderColor, + &Children, + ), + ( + Or<( + Changed, + Changed, + Added, + )>, + With, + ), + >, + mut text_query: Query<&mut Text>, +) { + for (pressed, hovered, disabled, mut color, mut border_color, children) in &mut buttons { + let mut text = text_query.get_mut(children[0]).unwrap(); + set_button_style( + disabled, + hovered.get(), + pressed, + &mut color, + &mut border_color, + &mut text, + ); + } +} + +/// Supplementary system to detect removed marker components +fn update_button_style2( + mut buttons: Query< + ( + Has, + &Hovered, + Has, + &mut BackgroundColor, + &mut BorderColor, + &Children, + ), + With, + >, + mut removed_depressed: RemovedComponents, + mut removed_disabled: RemovedComponents, + mut text_query: Query<&mut Text>, +) { + removed_depressed + .read() + .chain(removed_disabled.read()) + .for_each(|entity| { + if let Ok((pressed, hovered, disabled, mut color, mut border_color, children)) = + buttons.get_mut(entity) + { + let mut text = text_query.get_mut(children[0]).unwrap(); + set_button_style( + disabled, + hovered.get(), + pressed, + &mut color, + &mut border_color, + &mut text, + ); + } + }); +} + +fn set_button_style( + disabled: bool, + hovered: bool, + pressed: bool, + color: &mut BackgroundColor, + border_color: &mut BorderColor, + text: &mut Text, +) { + match (disabled, hovered, pressed) { + // Disabled button + (true, _, _) => { + **text = "Disabled".to_string(); + *color = NORMAL_BUTTON.into(); + border_color.set_all(GRAY); + } + + // Pressed and hovered button + (false, true, true) => { + **text = "Press".to_string(); + *color = PRESSED_BUTTON.into(); + border_color.set_all(RED); + } + + // Hovered, unpressed button + (false, true, false) => { + **text = "Hover".to_string(); + *color = HOVERED_BUTTON.into(); + border_color.set_all(WHITE); + } + + // Unhovered button (either pressed or not). + (false, false, _) => { + **text = "Button".to_string(); + *color = NORMAL_BUTTON.into(); + border_color.set_all(BLACK); + } + } +} + /// Create a demo slider fn slider(min: f32, max: f32, value: f32, on_change: Option>>) -> impl Bundle { ( @@ -412,21 +409,208 @@ fn thumb_color(disabled: bool, hovered: bool) -> Color { } } +/// Create a demo checkbox +fn checkbox( + asset_server: &AssetServer, + caption: &str, + on_change: Option>>, +) -> impl Bundle { + ( + Node { + display: Display::Flex, + flex_direction: FlexDirection::Row, + justify_content: JustifyContent::FlexStart, + align_items: AlignItems::Center, + align_content: AlignContent::Center, + column_gap: Val::Px(4.0), + ..default() + }, + Name::new("Checkbox"), + Hovered::default(), + DemoCheckbox, + CoreCheckbox { on_change }, + TabIndex(0), + Children::spawn(( + Spawn(( + // Checkbox outer + Node { + display: Display::Flex, + width: Val::Px(16.0), + height: Val::Px(16.0), + border: UiRect::all(Val::Px(2.0)), + ..default() + }, + BorderColor::all(CHECKBOX_OUTLINE), // Border color for the checkbox + BorderRadius::all(Val::Px(3.0)), + children![ + // Checkbox inner + ( + Node { + display: Display::Flex, + width: Val::Px(8.0), + height: Val::Px(8.0), + position_type: PositionType::Absolute, + left: Val::Px(2.0), + top: Val::Px(2.0), + ..default() + }, + BackgroundColor(CHECKBOX_CHECK), + ), + ], + )), + Spawn(( + Text::new(caption), + TextFont { + font: asset_server.load("fonts/FiraSans-Bold.ttf"), + font_size: 20.0, + ..default() + }, + )), + )), + ) +} + +// Update the checkbox's styles. +fn update_checkbox_style( + mut q_checkbox: Query< + (Has, &Hovered, Has, &Children), + ( + With, + Or<( + Added, + Changed, + Added, + Added, + )>, + ), + >, + mut q_border_color: Query<(&mut BorderColor, &mut Children), Without>, + mut q_bg_color: Query<&mut BackgroundColor, (Without, Without)>, +) { + for (checked, Hovered(is_hovering), is_disabled, children) in q_checkbox.iter_mut() { + let Some(border_id) = children.first() else { + continue; + }; + + let Ok((mut border_color, border_children)) = q_border_color.get_mut(*border_id) else { + continue; + }; + + let Some(mark_id) = border_children.first() else { + warn!("Checkbox does not have a mark entity."); + continue; + }; + + let Ok(mut mark_bg) = q_bg_color.get_mut(*mark_id) else { + warn!("Checkbox mark entity lacking a background color."); + continue; + }; + + set_checkbox_style( + is_disabled, + *is_hovering, + checked, + &mut border_color, + &mut mark_bg, + ); + } +} + +fn update_checkbox_style2( + mut q_checkbox: Query< + (Has, &Hovered, Has, &Children), + With, + >, + mut q_border_color: Query<(&mut BorderColor, &mut Children), Without>, + mut q_bg_color: Query<&mut BackgroundColor, (Without, Without)>, + mut removed_checked: RemovedComponents, + mut removed_disabled: RemovedComponents, +) { + removed_checked + .read() + .chain(removed_disabled.read()) + .for_each(|entity| { + if let Ok((checked, Hovered(is_hovering), is_disabled, children)) = + q_checkbox.get_mut(entity) + { + let Some(border_id) = children.first() else { + return; + }; + + let Ok((mut border_color, border_children)) = q_border_color.get_mut(*border_id) + else { + return; + }; + + let Some(mark_id) = border_children.first() else { + warn!("Checkbox does not have a mark entity."); + return; + }; + + let Ok(mut mark_bg) = q_bg_color.get_mut(*mark_id) else { + warn!("Checkbox mark entity lacking a background color."); + return; + }; + + set_checkbox_style( + is_disabled, + *is_hovering, + checked, + &mut border_color, + &mut mark_bg, + ); + } + }); +} + +fn set_checkbox_style( + disabled: bool, + hovering: bool, + checked: bool, + border_color: &mut BorderColor, + mark_bg: &mut BackgroundColor, +) { + let color: Color = if disabled { + // If the checkbox is disabled, use a lighter color + CHECKBOX_OUTLINE.with_alpha(0.2) + } else if hovering { + // If hovering, use a lighter color + CHECKBOX_OUTLINE.lighter(0.2) + } else { + // Default color for the checkbox + CHECKBOX_OUTLINE + }; + + // Update the background color of the check mark + border_color.set_all(color); + + let mark_color: Color = match (disabled, checked) { + (true, true) => CHECKBOX_CHECK.with_alpha(0.5), + (false, true) => CHECKBOX_CHECK, + (_, false) => Srgba::NONE.into(), + }; + + if mark_bg.0 != mark_color { + // Update the color of the check mark + mark_bg.0 = mark_color; + } +} + fn toggle_disabled( input: Res>, mut interaction_query: Query< (Entity, Has), - Or<(With, With)>, + Or<(With, With, With)>, >, mut commands: Commands, ) { if input.just_pressed(KeyCode::KeyD) { for (entity, disabled) in &mut interaction_query { if disabled { - info!("Widgets enabled"); + info!("Widget enabled"); commands.entity(entity).remove::(); } else { - info!("Widgets disabled"); + info!("Widget disabled"); commands.entity(entity).insert(InteractionDisabled); } } diff --git a/examples/ui/core_widgets_observers.rs b/examples/ui/core_widgets_observers.rs index c2f7315ba9..c3451fe700 100644 --- a/examples/ui/core_widgets_observers.rs +++ b/examples/ui/core_widgets_observers.rs @@ -3,7 +3,8 @@ use bevy::{ color::palettes::basic::*, core_widgets::{ - CoreButton, CoreSlider, CoreSliderThumb, CoreWidgetsPlugin, SliderRange, SliderValue, + CoreButton, CoreCheckbox, CoreSlider, CoreSliderThumb, CoreWidgetsPlugin, SliderRange, + SliderValue, }, ecs::system::SystemId, input_focus::{ @@ -12,7 +13,7 @@ use bevy::{ }, picking::hover::Hovered, prelude::*, - ui::{InteractionDisabled, Pressed}, + ui::{Checked, InteractionDisabled, Pressed}, winit::WinitSettings, }; @@ -33,6 +34,11 @@ fn main() { .add_observer(slider_on_change_hover) .add_observer(slider_on_change_value) .add_observer(slider_on_change_range) + .add_observer(checkbox_on_add_disabled) + .add_observer(checkbox_on_remove_disabled) + .add_observer(checkbox_on_change_hover) + .add_observer(checkbox_on_add_checked) + .add_observer(checkbox_on_remove_checked) .add_systems(Update, (update_widget_values, toggle_disabled)) .run(); } @@ -42,6 +48,8 @@ const HOVERED_BUTTON: Color = Color::srgb(0.25, 0.25, 0.25); const PRESSED_BUTTON: Color = Color::srgb(0.35, 0.75, 0.35); const SLIDER_TRACK: Color = Color::srgb(0.05, 0.05, 0.05); const SLIDER_THUMB: Color = Color::srgb(0.35, 0.75, 0.35); +const CHECKBOX_OUTLINE: Color = Color::srgb(0.45, 0.45, 0.45); +const CHECKBOX_CHECK: Color = Color::srgb(0.35, 0.75, 0.35); /// Marker which identifies buttons with a particular style, in this case the "Demo style". #[derive(Component)] @@ -55,6 +63,10 @@ struct DemoSlider; #[derive(Component, Default)] struct DemoSliderThumb; +/// Marker which identifies checkboxes with a particular style. +#[derive(Component, Default)] +struct DemoCheckbox; + /// A struct to hold the state of various widgets shown in the demo. /// /// While it is possible to use the widget's own state components as the source of truth, @@ -66,6 +78,83 @@ struct DemoWidgetStates { slider_value: f32, } +fn setup(mut commands: Commands, assets: Res) { + // System to print a value when the button is clicked. + let on_click = commands.register_system(|| { + info!("Button clicked!"); + }); + + // System to update a resource when the slider value changes. Note that we could have + // updated the slider value directly, but we want to demonstrate externalizing the state. + let on_change_value = commands.register_system( + |value: In, mut widget_states: ResMut| { + widget_states.slider_value = *value; + }, + ); + + // ui camera + commands.spawn(Camera2d); + commands.spawn(demo_root(&assets, on_click, on_change_value)); +} + +fn demo_root( + asset_server: &AssetServer, + on_click: SystemId, + on_change_value: SystemId>, +) -> impl Bundle { + ( + Node { + width: Val::Percent(100.0), + height: Val::Percent(100.0), + align_items: AlignItems::Center, + justify_content: JustifyContent::Center, + display: Display::Flex, + flex_direction: FlexDirection::Column, + row_gap: Val::Px(10.0), + ..default() + }, + TabGroup::default(), + children![ + button(asset_server, on_click), + slider(0.0, 100.0, 50.0, Some(on_change_value)), + checkbox(asset_server, "Checkbox", None), + Text::new("Press 'D' to toggle widget disabled states"), + ], + ) +} + +fn button(asset_server: &AssetServer, on_click: SystemId) -> impl Bundle { + ( + Node { + width: Val::Px(150.0), + height: Val::Px(65.0), + border: UiRect::all(Val::Px(5.0)), + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + ..default() + }, + DemoButton, + CoreButton { + on_click: Some(on_click), + }, + Hovered::default(), + TabIndex(0), + BorderColor::all(Color::BLACK), + BorderRadius::MAX, + BackgroundColor(NORMAL_BUTTON), + children![( + Text::new("Button"), + TextFont { + font: asset_server.load("fonts/FiraSans-Bold.ttf"), + font_size: 33.0, + ..default() + }, + TextColor(Color::srgb(0.9, 0.9, 0.9)), + TextShadow::default(), + )], + ) +} + fn button_on_add_pressed( trigger: On, mut buttons: Query< @@ -256,6 +345,74 @@ fn set_button_style( } } +/// Create a demo slider +fn slider(min: f32, max: f32, value: f32, on_change: Option>>) -> impl Bundle { + ( + Node { + display: Display::Flex, + flex_direction: FlexDirection::Column, + justify_content: JustifyContent::Center, + align_items: AlignItems::Stretch, + justify_items: JustifyItems::Center, + column_gap: Val::Px(4.0), + height: Val::Px(12.0), + width: Val::Percent(30.0), + ..default() + }, + Name::new("Slider"), + Hovered::default(), + DemoSlider, + CoreSlider { + on_change, + ..default() + }, + SliderValue(value), + SliderRange::new(min, max), + TabIndex(0), + Children::spawn(( + // Slider background rail + Spawn(( + Node { + height: Val::Px(6.0), + ..default() + }, + BackgroundColor(SLIDER_TRACK), // Border color for the checkbox + BorderRadius::all(Val::Px(3.0)), + )), + // Invisible track to allow absolute placement of thumb entity. This is narrower than + // the actual slider, which allows us to position the thumb entity using simple + // percentages, without having to measure the actual width of the slider thumb. + Spawn(( + Node { + display: Display::Flex, + position_type: PositionType::Absolute, + left: Val::Px(0.0), + // Track is short by 12px to accommodate the thumb. + right: Val::Px(12.0), + top: Val::Px(0.0), + bottom: Val::Px(0.0), + ..default() + }, + children![( + // Thumb + DemoSliderThumb, + CoreSliderThumb, + Node { + display: Display::Flex, + width: Val::Px(12.0), + height: Val::Px(12.0), + position_type: PositionType::Absolute, + left: Val::Percent(0.0), // This will be updated by the slider's value + ..default() + }, + BorderRadius::MAX, + BackgroundColor(SLIDER_THUMB), + )], + )), + )), + ) +} + fn slider_on_add_disabled( trigger: On, sliders: Query<(Entity, &Hovered), With>, @@ -351,6 +508,208 @@ fn thumb_color(disabled: bool, hovered: bool) -> Color { } } +/// Create a demo checkbox +fn checkbox( + asset_server: &AssetServer, + caption: &str, + on_change: Option>>, +) -> impl Bundle { + ( + Node { + display: Display::Flex, + flex_direction: FlexDirection::Row, + justify_content: JustifyContent::FlexStart, + align_items: AlignItems::Center, + align_content: AlignContent::Center, + column_gap: Val::Px(4.0), + ..default() + }, + Name::new("Checkbox"), + Hovered::default(), + DemoCheckbox, + CoreCheckbox { on_change }, + TabIndex(0), + Children::spawn(( + Spawn(( + // Checkbox outer + Node { + display: Display::Flex, + width: Val::Px(16.0), + height: Val::Px(16.0), + border: UiRect::all(Val::Px(2.0)), + ..default() + }, + BorderColor::all(CHECKBOX_OUTLINE), // Border color for the checkbox + BorderRadius::all(Val::Px(3.0)), + children![ + // Checkbox inner + ( + Node { + display: Display::Flex, + width: Val::Px(8.0), + height: Val::Px(8.0), + position_type: PositionType::Absolute, + left: Val::Px(2.0), + top: Val::Px(2.0), + ..default() + }, + BackgroundColor(Srgba::NONE.into()), + ), + ], + )), + Spawn(( + Text::new(caption), + TextFont { + font: asset_server.load("fonts/FiraSans-Bold.ttf"), + font_size: 20.0, + ..default() + }, + )), + )), + ) +} + +fn checkbox_on_add_disabled( + trigger: On, + checkboxes: Query<(&Hovered, Has, &Children), With>, + mut borders: Query<(&mut BorderColor, &mut Children), Without>, + mut marks: Query<&mut BackgroundColor, (Without, Without)>, +) { + if let Ok((hovered, checked, children)) = checkboxes.get(trigger.target()) { + set_checkbox_style(children, &mut borders, &mut marks, true, hovered.0, checked); + } +} + +fn checkbox_on_remove_disabled( + trigger: On, + checkboxes: Query<(&Hovered, Has, &Children), With>, + mut borders: Query<(&mut BorderColor, &mut Children), Without>, + mut marks: Query<&mut BackgroundColor, (Without, Without)>, +) { + if let Ok((hovered, checked, children)) = checkboxes.get(trigger.target()) { + set_checkbox_style( + children, + &mut borders, + &mut marks, + false, + hovered.0, + checked, + ); + } +} + +fn checkbox_on_change_hover( + trigger: On, + checkboxes: Query< + (&Hovered, Has, Has, &Children), + With, + >, + mut borders: Query<(&mut BorderColor, &mut Children), Without>, + mut marks: Query<&mut BackgroundColor, (Without, Without)>, +) { + if let Ok((hovered, disabled, checked, children)) = checkboxes.get(trigger.target()) { + set_checkbox_style( + children, + &mut borders, + &mut marks, + disabled, + hovered.0, + checked, + ); + } +} + +fn checkbox_on_add_checked( + trigger: On, + checkboxes: Query< + (&Hovered, Has, Has, &Children), + With, + >, + mut borders: Query<(&mut BorderColor, &mut Children), Without>, + mut marks: Query<&mut BackgroundColor, (Without, Without)>, +) { + if let Ok((hovered, disabled, checked, children)) = checkboxes.get(trigger.target()) { + set_checkbox_style( + children, + &mut borders, + &mut marks, + disabled, + hovered.0, + checked, + ); + } +} + +fn checkbox_on_remove_checked( + trigger: On, + checkboxes: Query<(&Hovered, Has, &Children), With>, + mut borders: Query<(&mut BorderColor, &mut Children), Without>, + mut marks: Query<&mut BackgroundColor, (Without, Without)>, +) { + if let Ok((hovered, disabled, children)) = checkboxes.get(trigger.target()) { + set_checkbox_style( + children, + &mut borders, + &mut marks, + disabled, + hovered.0, + false, + ); + } +} + +fn set_checkbox_style( + children: &Children, + borders: &mut Query<(&mut BorderColor, &mut Children), Without>, + marks: &mut Query<&mut BackgroundColor, (Without, Without)>, + disabled: bool, + hovering: bool, + checked: bool, +) { + let Some(border_id) = children.first() else { + return; + }; + + let Ok((mut border_color, border_children)) = borders.get_mut(*border_id) else { + return; + }; + + let Some(mark_id) = border_children.first() else { + warn!("Checkbox does not have a mark entity."); + return; + }; + + let Ok(mut mark_bg) = marks.get_mut(*mark_id) else { + warn!("Checkbox mark entity lacking a background color."); + return; + }; + + let color: Color = if disabled { + // If the checkbox is disabled, use a lighter color + CHECKBOX_OUTLINE.with_alpha(0.2) + } else if hovering { + // If hovering, use a lighter color + CHECKBOX_OUTLINE.lighter(0.2) + } else { + // Default color for the checkbox + CHECKBOX_OUTLINE + }; + + // Update the background color of the check mark + border_color.set_all(color); + + let mark_color: Color = match (disabled, checked) { + (true, true) => CHECKBOX_CHECK.with_alpha(0.5), + (false, true) => CHECKBOX_CHECK, + (_, false) => Srgba::NONE.into(), + }; + + if mark_bg.0 != mark_color { + // Update the color of the check mark + mark_bg.0 = mark_color; + } +} + /// Update the widget states based on the changing resource. fn update_widget_values( res: Res, @@ -366,165 +725,21 @@ fn update_widget_values( } } -fn setup(mut commands: Commands, assets: Res) { - // System to print a value when the button is clicked. - let on_click = commands.register_system(|| { - info!("Button clicked!"); - }); - - // System to update a resource when the slider value changes. Note that we could have - // updated the slider value directly, but we want to demonstrate externalizing the state. - let on_change_value = commands.register_system( - |value: In, mut widget_states: ResMut| { - widget_states.slider_value = *value; - }, - ); - - // ui camera - commands.spawn(Camera2d); - commands.spawn(demo_root(&assets, on_click, on_change_value)); -} - -fn demo_root( - asset_server: &AssetServer, - on_click: SystemId, - on_change_value: SystemId>, -) -> impl Bundle { - ( - Node { - width: Val::Percent(100.0), - height: Val::Percent(100.0), - align_items: AlignItems::Center, - justify_content: JustifyContent::Center, - display: Display::Flex, - flex_direction: FlexDirection::Column, - row_gap: Val::Px(10.0), - ..default() - }, - TabGroup::default(), - children![ - button(asset_server, on_click), - slider(0.0, 100.0, 50.0, Some(on_change_value)), - Text::new("Press 'D' to toggle widget disabled states"), - ], - ) -} - -fn button(asset_server: &AssetServer, on_click: SystemId) -> impl Bundle { - ( - Node { - width: Val::Px(150.0), - height: Val::Px(65.0), - border: UiRect::all(Val::Px(5.0)), - justify_content: JustifyContent::Center, - align_items: AlignItems::Center, - ..default() - }, - DemoButton, - CoreButton { - on_click: Some(on_click), - }, - Hovered::default(), - TabIndex(0), - BorderColor::all(Color::BLACK), - BorderRadius::MAX, - BackgroundColor(NORMAL_BUTTON), - children![( - Text::new("Button"), - TextFont { - font: asset_server.load("fonts/FiraSans-Bold.ttf"), - font_size: 33.0, - ..default() - }, - TextColor(Color::srgb(0.9, 0.9, 0.9)), - TextShadow::default(), - )], - ) -} - -/// Create a demo slider -fn slider(min: f32, max: f32, value: f32, on_change: Option>>) -> impl Bundle { - ( - Node { - display: Display::Flex, - flex_direction: FlexDirection::Column, - justify_content: JustifyContent::Center, - align_items: AlignItems::Stretch, - justify_items: JustifyItems::Center, - column_gap: Val::Px(4.0), - height: Val::Px(12.0), - width: Val::Percent(30.0), - ..default() - }, - Name::new("Slider"), - Hovered::default(), - DemoSlider, - CoreSlider { - on_change, - ..default() - }, - SliderValue(value), - SliderRange::new(min, max), - TabIndex(0), - Children::spawn(( - // Slider background rail - Spawn(( - Node { - height: Val::Px(6.0), - ..default() - }, - BackgroundColor(SLIDER_TRACK), // Border color for the checkbox - BorderRadius::all(Val::Px(3.0)), - )), - // Invisible track to allow absolute placement of thumb entity. This is narrower than - // the actual slider, which allows us to position the thumb entity using simple - // percentages, without having to measure the actual width of the slider thumb. - Spawn(( - Node { - display: Display::Flex, - position_type: PositionType::Absolute, - left: Val::Px(0.0), - // Track is short by 12px to accommodate the thumb. - right: Val::Px(12.0), - top: Val::Px(0.0), - bottom: Val::Px(0.0), - ..default() - }, - children![( - // Thumb - DemoSliderThumb, - CoreSliderThumb, - Node { - display: Display::Flex, - width: Val::Px(12.0), - height: Val::Px(12.0), - position_type: PositionType::Absolute, - left: Val::Percent(0.0), // This will be updated by the slider's value - ..default() - }, - BorderRadius::MAX, - BackgroundColor(SLIDER_THUMB), - )], - )), - )), - ) -} - fn toggle_disabled( input: Res>, mut interaction_query: Query< (Entity, Has), - Or<(With, With)>, + Or<(With, With, With)>, >, mut commands: Commands, ) { if input.just_pressed(KeyCode::KeyD) { for (entity, disabled) in &mut interaction_query { if disabled { - info!("Widgets enabled"); + info!("Widget enabled"); commands.entity(entity).remove::(); } else { - info!("Widgets disabled"); + info!("Widget disabled"); commands.entity(entity).insert(InteractionDisabled); } } diff --git a/release-content/release-notes/headless-widgets.md b/release-content/release-notes/headless-widgets.md index b65cae4119..6fc82648cc 100644 --- a/release-content/release-notes/headless-widgets.md +++ b/release-content/release-notes/headless-widgets.md @@ -1,7 +1,7 @@ --- title: Headless Widgets authors: ["@viridia"] -pull_requests: [19366, 19584] +pull_requests: [19366, 19584, 19665] --- Bevy's `Button` and `Interaction` components have been around for a long time. Unfortunately @@ -34,6 +34,7 @@ sliders, checkboxes and radio buttons. - `CoreButton` is a push button. It emits an activation event when clicked. - `CoreSlider` is a standard slider, which lets you edit an `f32` value in a given range. +- `CoreCheckbox` can be used for checkboxes and toggle switches. ## Widget Interaction States From ffd6c9e1c93c3d96ae2a951ef924aca87a4f43fa Mon Sep 17 00:00:00 2001 From: Jan Hohenheim Date: Fri, 20 Jun 2025 18:43:14 +0200 Subject: [PATCH 06/21] Rewrite camera shake example (#19724) # Objective - Alternative to #19721 - The old implementation had several issues: - underexplained - bit complicated in places - did not follow the source as described - camera moves away - camera does not use noise - camera nudges back after shake ends, which looks cinematic, but not what you want in practice All in all: the old implementation did not really show a typical implementation IMO ## Solution - Rewrite it :D - I believe the current implementation is a robust thing you can learn from or just copy-paste into your project ## Testing https://github.com/user-attachments/assets/bfe74fb6-c428-4d5a-9c9c-cd4a034ba176 --------- Co-authored-by: Rob Parrett --- examples/camera/2d_screen_shake.rs | 376 +++++++++++++++++++---------- 1 file changed, 250 insertions(+), 126 deletions(-) diff --git a/examples/camera/2d_screen_shake.rs b/examples/camera/2d_screen_shake.rs index dcdcd68811..0a0aaa780d 100644 --- a/examples/camera/2d_screen_shake.rs +++ b/examples/camera/2d_screen_shake.rs @@ -1,70 +1,216 @@ -//! This example showcases a 2D screen shake using concept in this video: `` +//! This example showcases how to implement 2D screen shake. +//! It follows the GDC talk ["Math for Game Programmers: Juicing Your Cameras With Math"](https://www.youtube.com/watch?v=tu-Qe66AvtY) by Squirrel Eiserloh +//! +//! The key features are: +//! - Camera shake is dependent on a "trauma" value between 0.0 and 1.0. The more trauma, the stronger the shake. +//! - Trauma automatically decays over time. +//! - The camera shake will always only affect the camera `Transform` up to a maximum displacement. +//! - The camera's `Transform` is only affected by the shake for the rendering. The `Transform` stays "normal" for the rest of the game logic. +//! - All displacements are governed by a noise function, guaranteeing that the shake is smooth and continuous. +//! This means that the camera won't jump around wildly. //! //! ## Controls //! -//! | Key Binding | Action | -//! |:-------------|:---------------------| -//! | Space | Trigger screen shake | +//! | Key Binding | Action | +//! |:---------------------------------|:---------------------------| +//! | Space (pressed repeatedly) | Increase camera trauma | -use bevy::{prelude::*, render::camera::SubCameraView, sprite::MeshMaterial2d}; -use rand::{Rng, SeedableRng}; -use rand_chacha::ChaCha8Rng; +use bevy::{ + input::common_conditions::input_just_pressed, math::ops::powf, prelude::*, + sprite::MeshMaterial2d, +}; -const CAMERA_DECAY_RATE: f32 = 0.9; // Adjust this for smoother or snappier decay -const TRAUMA_DECAY_SPEED: f32 = 0.5; // How fast trauma decays -const TRAUMA_INCREMENT: f32 = 1.0; // Increment of trauma per frame when holding space +// Before we implement the code, let's quickly introduce the underlying constants. +// They are later encoded in a `CameraShakeConfig` component, but introduced here so we can easily tweak them. +// Try playing around with them and see how the shake behaves! -// screen_shake parameters, maximum addition by frame not actual maximum overall values -const MAX_ANGLE: f32 = 0.5; -const MAX_OFFSET: f32 = 500.0; +/// The trauma decay rate controls how quickly the trauma decays. +/// 0.5 means that a full trauma of 1.0 will decay to 0.0 in 2 seconds. +const TRAUMA_DECAY_PER_SECOND: f32 = 0.5; -#[derive(Component)] -struct Player; +/// The trauma exponent controls how the trauma affects the shake. +/// Camera shakes don't feel punchy when they go up linearly, so we use an exponent of 2.0. +/// The higher the exponent, the more abrupt is the transition between no shake and full shake. +const TRAUMA_EXPONENT: f32 = 2.0; + +/// The maximum angle the camera can rotate on full trauma. +/// 10.0 degrees is a somewhat high but still reasonable shake. Try bigger values for something more silly and wiggly. +const MAX_ANGLE: f32 = 10.0_f32.to_radians(); + +/// The maximum translation the camera will move on full trauma in both the x and y directions. +/// 20.0 px is a low enough displacement to not be distracting. Try higher values for an effect that looks like the camera is wandering around. +const MAX_TRANSLATION: f32 = 20.0; + +/// How much we are traversing the noise function in arbitrary units per second. +/// This dictates how fast the camera shakes. +/// 20.0 is a fairly fast shake. Try lower values for a more dreamy effect. +const NOISE_SPEED: f32 = 20.0; + +/// How much trauma we add per press of the space key. +/// A value of 1.0 would mean that a single press would result in a maximum trauma, i.e. 1.0. +const TRAUMA_PER_PRESS: f32 = 0.4; fn main() { App::new() .add_plugins(DefaultPlugins) .add_systems(Startup, (setup_scene, setup_instructions, setup_camera)) - .add_systems(Update, (screen_shake, trigger_shake_on_space)) + // At the start of the frame, restore the camera's transform to its unshaken state. + .add_systems(PreUpdate, reset_transform) + .add_systems( + Update, + // Increase trauma when the space key is pressed. + increase_trauma.run_if(input_just_pressed(KeyCode::Space)), + ) + // Just before the end of the frame, apply the shake. + // This is ordered so that the transform propagation produces correct values for the global transform, which is used by Bevy's rendering. + .add_systems(PostUpdate, shake_camera.before(TransformSystems::Propagate)) .run(); } +/// Let's start with the core mechanic: how do we shake the camera? +/// This system runs right at the end of the frame, so that we can sneak in the shake effect before rendering kicks in. +fn shake_camera( + camera_shake: Single<(&mut CameraShakeState, &CameraShakeConfig, &mut Transform)>, + time: Res