diff --git a/_release-content/release-notes/more_widgets.md b/_release-content/release-notes/more_widgets.md index b7db7590cd06a..7813a6d63ec01 100644 --- a/_release-content/release-notes/more_widgets.md +++ b/_release-content/release-notes/more_widgets.md @@ -1,12 +1,13 @@ --- title: "Moar widgets!" authors: ["@viridia"] -pull_requests: [23645, 23707, 23788, 23787, 23804] +pull_requests: [23645, 23707, 23788, 23787, 23804, 23842] --- Bevy Feathers, the opinionated UI widget collection, has added several new widgets: - text input +- number input - dropdown menu buttons - icon (displays an image) - label (displays a text string in the standard font) diff --git a/crates/bevy_app/src/propagate.rs b/crates/bevy_app/src/propagate.rs index 1149568bb3d47..d4dd140c0a5a0 100644 --- a/crates/bevy_app/src/propagate.rs +++ b/crates/bevy_app/src/propagate.rs @@ -74,12 +74,12 @@ pub struct Propagate(pub C); /// Stops the output component being added to this entity. /// Relationship targets will still inherit the component from this entity or its parents. -#[derive(Component)] +#[derive(Component, Clone)] #[cfg_attr(feature = "bevy_reflect", derive(Reflect), reflect(Component))] pub struct PropagateOver(PhantomData C>); /// Stops the propagation at this entity. Children will not inherit the component. -#[derive(Component)] +#[derive(Component, Clone)] #[cfg_attr(feature = "bevy_reflect", derive(Reflect), reflect(Component))] pub struct PropagateStop(PhantomData C>); diff --git a/crates/bevy_feathers/Cargo.toml b/crates/bevy_feathers/Cargo.toml index 7a2106c371488..59468127ae46c 100644 --- a/crates/bevy_feathers/Cargo.toml +++ b/crates/bevy_feathers/Cargo.toml @@ -17,6 +17,7 @@ bevy_camera = { path = "../bevy_camera", version = "0.19.0-dev" } bevy_color = { path = "../bevy_color", version = "0.19.0-dev" } bevy_ui_widgets = { path = "../bevy_ui_widgets", version = "0.19.0-dev" } bevy_ecs = { path = "../bevy_ecs", version = "0.19.0-dev" } +bevy_input = { path = "../bevy_input", version = "0.19.0-dev" } bevy_input_focus = { path = "../bevy_input_focus", version = "0.19.0-dev" } bevy_log = { path = "../bevy_log", version = "0.19.0-dev" } bevy_math = { path = "../bevy_math", version = "0.19.0-dev" } diff --git a/crates/bevy_feathers/src/constants.rs b/crates/bevy_feathers/src/constants.rs index 1d2d76662290b..49404170f16d8 100644 --- a/crates/bevy_feathers/src/constants.rs +++ b/crates/bevy_feathers/src/constants.rs @@ -55,4 +55,7 @@ pub mod size { /// Small font size pub const SMALL_FONT: FontSize = FontSize::Px(12.0); + + /// Extra-small font size + pub const EXTRA_SMALL_FONT: FontSize = FontSize::Px(11.0); } diff --git a/crates/bevy_feathers/src/containers/group.rs b/crates/bevy_feathers/src/containers/group.rs index 149cec012cc39..baaf6e211ca1c 100644 --- a/crates/bevy_feathers/src/containers/group.rs +++ b/crates/bevy_feathers/src/containers/group.rs @@ -6,7 +6,7 @@ use crate::{ constants::{fonts, size}, font_styles::InheritableFont, rounded_corners::RoundedCorners, - theme::{ThemeBackgroundColor, ThemeBorderColor, ThemeFontColor}, + theme::{InheritableThemeTextColor, ThemeBackgroundColor, ThemeBorderColor}, tokens, }; @@ -42,7 +42,7 @@ pub fn group_header() -> impl Scene { } ThemeBackgroundColor(tokens::GROUP_HEADER_BG) ThemeBorderColor(tokens::GROUP_HEADER_BORDER) - ThemeFontColor(tokens::GROUP_HEADER_TEXT) + InheritableThemeTextColor(tokens::GROUP_HEADER_TEXT) InheritableFont { font: fonts::REGULAR, font_size: size::MEDIUM_FONT, diff --git a/crates/bevy_feathers/src/containers/pane.rs b/crates/bevy_feathers/src/containers/pane.rs index 99d74d90cdab9..5de5016998b07 100644 --- a/crates/bevy_feathers/src/containers/pane.rs +++ b/crates/bevy_feathers/src/containers/pane.rs @@ -10,7 +10,7 @@ use crate::{ constants::{fonts, size}, font_styles::InheritableFont, rounded_corners::RoundedCorners, - theme::{ThemeBackgroundColor, ThemeBorderColor, ThemeFontColor}, + theme::{InheritableThemeTextColor, ThemeBackgroundColor, ThemeBorderColor}, tokens, }; @@ -46,7 +46,7 @@ pub fn pane_header() -> impl Scene { } ThemeBackgroundColor(tokens::PANE_HEADER_BG) ThemeBorderColor(tokens::PANE_HEADER_BORDER) - ThemeFontColor(tokens::PANE_HEADER_TEXT) + InheritableThemeTextColor(tokens::PANE_HEADER_TEXT) InheritableFont { font: fonts::REGULAR, font_size: size::MEDIUM_FONT, diff --git a/crates/bevy_feathers/src/containers/subpane.rs b/crates/bevy_feathers/src/containers/subpane.rs index 3883f43f4426c..635d9d1818c63 100644 --- a/crates/bevy_feathers/src/containers/subpane.rs +++ b/crates/bevy_feathers/src/containers/subpane.rs @@ -6,7 +6,7 @@ use crate::{ constants::{fonts, size}, font_styles::InheritableFont, rounded_corners::RoundedCorners, - theme::{ThemeBackgroundColor, ThemeBorderColor, ThemeFontColor}, + theme::{InheritableThemeTextColor, ThemeBackgroundColor, ThemeBorderColor}, tokens, }; @@ -42,7 +42,7 @@ pub fn subpane_header() -> impl Scene { } ThemeBackgroundColor(tokens::SUBPANE_HEADER_BG) ThemeBorderColor(tokens::SUBPANE_HEADER_BORDER) - ThemeFontColor(tokens::SUBPANE_HEADER_TEXT) + InheritableThemeTextColor(tokens::SUBPANE_HEADER_TEXT) InheritableFont { font: fonts::REGULAR, font_size: size::MEDIUM_FONT, diff --git a/crates/bevy_feathers/src/controls/button.rs b/crates/bevy_feathers/src/controls/button.rs index a85e15660d351..ae2bfe10d98c1 100644 --- a/crates/bevy_feathers/src/controls/button.rs +++ b/crates/bevy_feathers/src/controls/button.rs @@ -25,7 +25,7 @@ use crate::{ focus::FocusIndicator, font_styles::InheritableFont, rounded_corners::RoundedCorners, - theme::{ThemeBackgroundColor, ThemeFontColor}, + theme::{InheritableThemeTextColor, ThemeBackgroundColor}, tokens, }; @@ -92,7 +92,7 @@ pub fn button(props: ButtonProps) -> impl Scene { TabIndex(0) FocusIndicator ThemeBackgroundColor(tokens::BUTTON_BG) - ThemeFontColor(tokens::BUTTON_TEXT) + InheritableThemeTextColor(tokens::BUTTON_TEXT) InheritableFont { font: fonts::REGULAR, font_size: size::MEDIUM_FONT, @@ -168,7 +168,7 @@ pub fn button_bundle + Send + Sync + 'static, B: Bundl TabIndex(0), FocusIndicator, ThemeBackgroundColor(tokens::BUTTON_BG), - ThemeFontColor(tokens::BUTTON_TEXT), + InheritableThemeTextColor(tokens::BUTTON_TEXT), InheritableFont { font_size: size::MEDIUM_FONT, weight: FontWeight::NORMAL, @@ -187,7 +187,7 @@ fn update_button_styles( Has, &Hovered, &ThemeBackgroundColor, - &ThemeFontColor, + &InheritableThemeTextColor, ), Or<( Changed, @@ -221,7 +221,7 @@ fn update_button_styles_remove( Has, &Hovered, &ThemeBackgroundColor, - &ThemeFontColor, + &InheritableThemeTextColor, )>, mut removed_disabled: RemovedComponents, mut removed_pressed: RemovedComponents, @@ -255,7 +255,7 @@ fn set_button_styles( pressed: bool, hovered: bool, bg_color: &ThemeBackgroundColor, - font_color: &ThemeFontColor, + font_color: &InheritableThemeTextColor, commands: &mut Commands, ) { let bg_token = match (variant, disabled, pressed, hovered) { @@ -296,7 +296,7 @@ fn set_button_styles( if font_color.0 != font_color_token { commands .entity(button_ent) - .insert(ThemeFontColor(font_color_token)); + .insert(InheritableThemeTextColor(font_color_token)); } // Change cursor shape diff --git a/crates/bevy_feathers/src/controls/checkbox.rs b/crates/bevy_feathers/src/controls/checkbox.rs index 098eb46e3d95a..55ebfb621fa77 100644 --- a/crates/bevy_feathers/src/controls/checkbox.rs +++ b/crates/bevy_feathers/src/controls/checkbox.rs @@ -30,7 +30,7 @@ use crate::{ cursor::EntityCursor, focus::FocusIndicator, font_styles::InheritableFont, - theme::{ThemeBackgroundColor, ThemeBorderColor, ThemeFontColor}, + theme::{InheritableThemeTextColor, ThemeBackgroundColor, ThemeBorderColor}, tokens, }; @@ -84,7 +84,7 @@ pub fn checkbox(props: CheckboxProps) -> impl Scene { Hovered EntityCursor::System(bevy_window::SystemCursorIcon::Pointer) TabIndex(0) - ThemeFontColor(tokens::CHECKBOX_TEXT) + InheritableThemeTextColor(tokens::CHECKBOX_TEXT) InheritableFont { font: fonts::REGULAR, font_size: size::MEDIUM_FONT, @@ -150,7 +150,7 @@ pub fn checkbox_bundle + Send + Sync + 'static, B: Bun Hovered::default(), EntityCursor::System(bevy_window::SystemCursorIcon::Pointer), TabIndex(0), - ThemeFontColor(tokens::CHECKBOX_TEXT), + InheritableThemeTextColor(tokens::CHECKBOX_TEXT), InheritableFont { font_size: size::MEDIUM_FONT, weight: FontWeight::NORMAL, @@ -204,7 +204,7 @@ fn update_checkbox_styles( Has, Has, &Hovered, - &ThemeFontColor, + &InheritableThemeTextColor, ), ( With, @@ -265,7 +265,7 @@ fn update_checkbox_styles_remove( Has, Has, &Hovered, - &ThemeFontColor, + &InheritableThemeTextColor, ), With, >, @@ -339,7 +339,7 @@ fn set_checkbox_styles( outline_bg: &ThemeBackgroundColor, outline_border: &ThemeBorderColor, mark_color: &ThemeBorderColor, - font_color: &ThemeFontColor, + font_color: &InheritableThemeTextColor, commands: &mut Commands, ) { let outline_border_token = if checked { @@ -432,7 +432,7 @@ fn set_checkbox_styles( if font_color.0 != font_color_token { commands .entity(checkbox_ent) - .insert(ThemeFontColor(font_color_token)); + .insert(InheritableThemeTextColor(font_color_token)); } // Change cursor shape diff --git a/crates/bevy_feathers/src/controls/color_plane.rs b/crates/bevy_feathers/src/controls/color_plane.rs index 7100550270a07..2d1dcdb24959e 100644 --- a/crates/bevy_feathers/src/controls/color_plane.rs +++ b/crates/bevy_feathers/src/controls/color_plane.rs @@ -317,6 +317,7 @@ fn on_pointer_press( commands.trigger(ValueChange { source: parent.0, value: new_value, + is_final: false, }); } } @@ -368,6 +369,7 @@ fn on_drag( commands.trigger(ValueChange { source: parent.0, value: new_value, + is_final: false, }); } } @@ -375,13 +377,38 @@ fn on_drag( fn on_drag_end( mut drag_end: On>, - mut q_color_planes: Query<&mut ColorPlaneDragState, With>, - q_color_plane_inner: Query<&ChildOf, With>, + mut q_color_planes: Query< + (&mut ColorPlaneDragState, Has), + With, + >, + q_color_plane_inner: Query< + ( + &ComputedNode, + &ComputedUiRenderTargetInfo, + &UiGlobalTransform, + &ChildOf, + ), + With, + >, + ui_scale: Res, + mut commands: Commands, ) { - if let Ok(parent) = q_color_plane_inner.get(drag_end.entity) - && let Ok(mut state) = q_color_planes.get_mut(parent.0) + if let Ok((node, node_target, transform, parent)) = q_color_plane_inner.get(drag_end.entity) + && let Ok((mut state, disabled)) = q_color_planes.get_mut(parent.0) { drag_end.propagate(false); + if state.0 && !disabled { + let local_pos = transform.try_inverse().unwrap().transform_point2( + drag_end.pointer_location.position * node_target.scale_factor() / ui_scale.0, + ); + let pos = local_pos / node.size() + Vec2::splat(0.5); + let new_value = pos.clamp(Vec2::ZERO, Vec2::ONE); + commands.trigger(ValueChange { + source: parent.0, + value: new_value, + is_final: true, + }); + } state.0 = false; } } diff --git a/crates/bevy_feathers/src/controls/menu.rs b/crates/bevy_feathers/src/controls/menu.rs index 9d05a67d05c09..dcb5f3475cbda 100644 --- a/crates/bevy_feathers/src/controls/menu.rs +++ b/crates/bevy_feathers/src/controls/menu.rs @@ -32,7 +32,7 @@ use crate::{ display::icon, font_styles::InheritableFont, rounded_corners::RoundedCorners, - theme::{ThemeBackgroundColor, ThemeBorderColor, ThemeFontColor}, + theme::{InheritableThemeTextColor, ThemeBackgroundColor, ThemeBorderColor}, tokens, }; use bevy_input_focus::{ @@ -268,7 +268,7 @@ pub fn menu_item(props: MenuItemProps) -> impl Scene { EntityCursor::System(bevy_window::SystemCursorIcon::Pointer) TabIndex(0) ThemeBackgroundColor(tokens::MENU_BG) // Same as menu - ThemeFontColor(tokens::MENUITEM_TEXT) + InheritableThemeTextColor(tokens::MENUITEM_TEXT) InheritableFont { font: fonts::REGULAR, font_size: size::MEDIUM_FONT, @@ -288,7 +288,7 @@ fn update_menuitem_styles( Has, &Hovered, &ThemeBackgroundColor, - &ThemeFontColor, + &InheritableThemeTextColor, ), ( With, @@ -321,7 +321,7 @@ fn update_menuitem_styles_remove( Has, &Hovered, &ThemeBackgroundColor, - &ThemeFontColor, + &InheritableThemeTextColor, ), With, >, @@ -360,7 +360,7 @@ fn update_menuitem_styles_focus_changed( Has, &Hovered, &ThemeBackgroundColor, - &ThemeFontColor, + &InheritableThemeTextColor, ), With, >, @@ -391,7 +391,7 @@ fn set_menuitem_colors( hovered: bool, focused: bool, bg_color: &ThemeBackgroundColor, - font_color: &ThemeFontColor, + font_color: &InheritableThemeTextColor, commands: &mut Commands, ) { let bg_token = match (focused, pressed, hovered) { @@ -417,7 +417,7 @@ fn set_menuitem_colors( if font_color.0 != font_color_token { commands .entity(button_ent) - .insert(ThemeFontColor(font_color_token)); + .insert(InheritableThemeTextColor(font_color_token)); } } diff --git a/crates/bevy_feathers/src/controls/mod.rs b/crates/bevy_feathers/src/controls/mod.rs index 88cdb6549346e..af2656a859a67 100644 --- a/crates/bevy_feathers/src/controls/mod.rs +++ b/crates/bevy_feathers/src/controls/mod.rs @@ -8,6 +8,7 @@ mod color_slider; mod color_swatch; mod disclosure_toggle; mod menu; +mod number_input; mod radio; mod slider; mod text_input; @@ -21,6 +22,7 @@ pub use color_slider::*; pub use color_swatch::*; pub use disclosure_toggle::*; pub use menu::*; +pub use number_input::*; pub use radio::*; pub use slider::*; pub use text_input::*; diff --git a/crates/bevy_feathers/src/controls/number_input.rs b/crates/bevy_feathers/src/controls/number_input.rs new file mode 100644 index 0000000000000..c68ca1e3ca3df --- /dev/null +++ b/crates/bevy_feathers/src/controls/number_input.rs @@ -0,0 +1,354 @@ +use bevy_app::PropagateOver; +use bevy_asset::AssetServer; +use bevy_ecs::{ + component::Component, + entity::Entity, + event::EntityEvent, + hierarchy::{ChildOf, Children}, + observer::On, + query::With, + relationship::Relationship, + system::{Commands, Query, Res}, + template::template, +}; +use bevy_input::keyboard::{KeyCode, KeyboardInput}; +use bevy_input_focus::{FocusLost, FocusedInput, InputFocus}; +use bevy_log::warn; +use bevy_scene::prelude::*; +use bevy_text::{ + EditableText, EditableTextFilter, FontSource, FontWeight, TextEdit, TextEditChange, TextFont, +}; +use bevy_ui::{px, widget::Text, AlignItems, AlignSelf, Display, JustifyContent, Node, UiRect}; +use bevy_ui_widgets::ValueChange; + +use crate::{ + constants::{fonts, size}, + controls::{text_input, text_input_container, TextInputProps}, + theme::{ThemeBackgroundColor, ThemeBorderColor, ThemeTextColor, ThemeToken}, + tokens, +}; + +/// Marker to indicate a number input widget with feathers styling. +#[derive(Component, Default, Clone)] +struct FeathersNumberInput; + +/// Used to indicate what format of numbers we are editing. This primarily affects the type +/// of [`ValueChange`] event that is emitted. +#[derive(Component, Default, Clone, Copy)] +pub enum NumberFormat { + /// A 32-bit float + #[default] + F32, + /// A 64-bit float + F64, + /// A 32-bit integer + I32, + /// A 64-bit integer + I64, +} + +/// Parameters for the text input template, passed to [`number_input`] function. +pub struct NumberInputProps { + /// The "sigil" is a colored strip along the left edge of the input, which is used to + /// distinguish between different axes. The default is transparent (no sigil). + pub sigil_color: ThemeToken, + /// A caption to be placed on the left side of the input, next to the colored stripe. + /// Usually one of "X", "Y" or "Z". + pub label_text: Option<&'static str>, + /// Indicate what size numbers we are editing. + pub number_format: NumberFormat, +} + +impl Default for NumberInputProps { + fn default() -> Self { + Self { + sigil_color: tokens::TEXT_INPUT_BG, + label_text: None, + number_format: NumberFormat::F32, + } + } +} + +/// Represents numbers in different formats. +#[derive(Debug, PartialEq, Clone, Copy)] +pub enum NumberInputValue { + /// An f32 value + F32(f32), + /// An f64 value + F64(f64), + /// An i32 value + I32(i32), + /// An i64 value + I64(i64), +} + +impl core::fmt::Display for NumberInputValue { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + NumberInputValue::F32(v) => write!(f, "{}", v), + NumberInputValue::F64(v) => write!(f, "{}", v), + NumberInputValue::I32(v) => write!(f, "{}", v), + NumberInputValue::I64(v) => write!(f, "{}", v), + } + } +} + +/// Event which can be sent to the number input widget to update the displayed value. +#[derive(Clone, EntityEvent)] +pub struct UpdateNumberInput { + /// Target widget + pub entity: Entity, + + /// Value to change to + pub value: NumberInputValue, +} + +/// Widget that permits text entry of floating-point numbers. This widget implements two-way +/// synchronization: +/// * when the widget has focus, it emits values (via a [`ValueChange`]) event as the user types. +/// The type of ``T`` will be ``f32``, ``f64``, ``i32``, or ``i64`` depending on the +/// ``number_format`` parameter. +/// * when the widget does not have focus, it listens for [`UpdateNumberInput`] events, and replaces +/// the contents of the text buffer based on the value in that event. +/// +/// To avoid excessive updating, you should only update the number value when there is an actual +/// change, that is, when the new value is different from the current value. +/// +/// In most cases, the actual source of truth for the numeric value will be external, that is, +/// some property in an app-specific data structure. It's the responsibility of the app to +/// synchronize this value with the [`number_input`] widget in both directions: +/// * When a [`ValueChange`] event is received, update the app-specific property. +/// * When the app-specific property changes - either in response to a [`ValueChange`] event, or +/// because of some other action, trigger an [`UpdateNumberInput`] entity event to update the +/// displayed value. +// TODO: Add text_input field validation when it becomes available. +pub fn number_input(props: NumberInputProps) -> impl Scene { + bsn! { + :text_input_container() + ThemeBorderColor({props.sigil_color.clone()}) + FeathersNumberInput + template_value(props.number_format) + on(number_input_on_update) + Children [ + { + match props.label_text { + Some(text) => Box::new(bsn_list!( + Node { + display: Display::Flex, + align_items: AlignItems::Center, + align_self: AlignSelf::Stretch, + justify_content: JustifyContent::Center, + padding: UiRect::axes(px(6), px(0)), + } + ThemeBackgroundColor(tokens::TEXT_INPUT_LABEL_BG) + Children [ + Text::new(text.to_string()) + template(|ctx| { + Ok(TextFont { + font: FontSource::Handle(ctx.resource::().load(fonts::REGULAR)), + font_size: size::COMPACT_FONT, + weight: FontWeight::NORMAL, + ..Default::default() + }) + }) + PropagateOver + ThemeTextColor(tokens::TEXT_INPUT_TEXT) + ] + )) as Box, + None => Box::new(bsn_list!()) as Box + } + } + text_input(TextInputProps { + visible_width: None, + max_characters: Some(20), + }) + on(number_input_on_text_change) + on(number_input_on_enter_key) + on(number_input_on_focus_loss) + EditableTextFilter::new(|c| { + c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E') + }), + ] + } +} + +fn number_input_on_text_change( + change: On, + q_parent: Query<&ChildOf>, + q_number_input: Query<&NumberFormat, With>, + q_text_input: Query<&EditableText>, + mut commands: Commands, +) { + let Ok(parent) = q_parent.get(change.event_target()) else { + return; + }; + + let Ok(number_format) = q_number_input.get(parent.get()) else { + return; + }; + + let Ok(editable_text) = q_text_input.get(change.event_target()) else { + return; + }; + + let text_value = editable_text.value().to_string(); + emit_value_change(text_value, *number_format, parent.0, &mut commands, false); +} + +fn number_input_on_update( + update: On, + q_children: Query<&Children>, + q_number_input: Query<(), With>, + mut q_text_input: Query<&mut EditableText>, + focus: Res, +) { + if !q_number_input.contains(update.event_target()) { + return; + }; + + let Ok(children) = q_children.get(update.event_target()) else { + return; + }; + + for child_id in children.iter() { + if focus.get() != Some(*child_id) + && let Ok(mut editable_text) = q_text_input.get_mut(*child_id) + { + let new_digits = update.value.to_string(); + let old_digits = editable_text.value().to_string(); + if old_digits != new_digits { + editable_text.queue_edit(TextEdit::SelectAll); + editable_text.queue_edit(TextEdit::Insert(new_digits.into())); + } + break; + } + } +} + +fn number_input_on_enter_key( + key_input: On>, + q_parent: Query<&ChildOf>, + q_number_input: Query<&NumberFormat, With>, + q_text_input: Query<&EditableText>, + mut commands: Commands, +) { + if key_input.input.key_code != KeyCode::Enter { + return; + } + + let Ok(parent) = q_parent.get(key_input.event_target()) else { + return; + }; + + let Ok(number_format) = q_number_input.get(parent.get()) else { + return; + }; + + let Ok(editable_text) = q_text_input.get(key_input.event_target()) else { + return; + }; + + let text_value = editable_text.value().to_string(); + emit_value_change(text_value, *number_format, parent.0, &mut commands, true); +} + +fn number_input_on_focus_loss( + focus_lost: On, + q_parent: Query<&ChildOf>, + q_number_input: Query<&NumberFormat, With>, + mut q_text_input: Query<&mut EditableText>, + mut commands: Commands, +) { + let editable_text_id = focus_lost.event_target(); + + let Ok(parent) = q_parent.get(editable_text_id) else { + return; + }; + + let Ok(number_format) = q_number_input.get(parent.get()) else { + return; + }; + + let Ok(editable_text) = q_text_input.get_mut(editable_text_id) else { + return; + }; + + let text_value = editable_text.value().to_string(); + emit_value_change(text_value, *number_format, parent.0, &mut commands, true); +} + +fn emit_value_change( + text_value: String, + format: NumberFormat, + source: Entity, + commands: &mut Commands, + is_final: bool, +) { + let text_value = text_value.trim(); + if text_value.is_empty() { + return; + } + + match format { + NumberFormat::F32 => { + match text_value.parse::() { + Ok(new_value) => { + commands.trigger(ValueChange { + source, + value: new_value, + is_final, + }); + } + Err(_) => { + // TODO: Emit a validation error once these are defined + warn!("Invalid floating-point number in text edit"); + } + } + } + NumberFormat::F64 => { + match text_value.parse::() { + Ok(new_value) => { + commands.trigger(ValueChange { + source, + value: new_value, + is_final, + }); + } + Err(_) => { + // TODO: Emit a validation error once these are defined + warn!("Invalid floating-point number in text edit"); + } + } + } + NumberFormat::I32 => { + match text_value.parse::() { + Ok(new_value) => { + commands.trigger(ValueChange { + source, + value: new_value, + is_final, + }); + } + Err(_) => { + // TODO: Emit a validation error once these are defined + warn!("Invalid integer number in text edit"); + } + } + } + NumberFormat::I64 => { + match text_value.parse::() { + Ok(new_value) => { + commands.trigger(ValueChange { + source, + value: new_value, + is_final, + }); + } + Err(_) => { + // TODO: Emit a validation error once these are defined + warn!("Invalid integer number in text edit"); + } + } + } + } +} diff --git a/crates/bevy_feathers/src/controls/radio.rs b/crates/bevy_feathers/src/controls/radio.rs index eaf4c31ed2be2..c24a29f9de611 100644 --- a/crates/bevy_feathers/src/controls/radio.rs +++ b/crates/bevy_feathers/src/controls/radio.rs @@ -29,7 +29,7 @@ use crate::{ cursor::EntityCursor, focus::FocusIndicator, font_styles::InheritableFont, - theme::{ThemeBackgroundColor, ThemeBorderColor, ThemeFontColor}, + theme::{InheritableThemeTextColor, ThemeBackgroundColor, ThemeBorderColor}, tokens, }; @@ -78,7 +78,7 @@ pub fn radio(props: RadioProps) -> impl Scene { Hovered EntityCursor::System(bevy_window::SystemCursorIcon::Pointer) TabIndex(0) - ThemeFontColor(tokens::RADIO_TEXT) + InheritableThemeTextColor(tokens::RADIO_TEXT) InheritableFont { font: fonts::REGULAR, font_size: size::MEDIUM_FONT, @@ -139,7 +139,7 @@ pub fn radio_bundle + Send + Sync + 'static, B: Bundle Hovered::default(), EntityCursor::System(bevy_window::SystemCursorIcon::Pointer), TabIndex(0), - ThemeFontColor(tokens::RADIO_TEXT), + InheritableThemeTextColor(tokens::RADIO_TEXT), InheritableFont { font_size: size::MEDIUM_FONT, weight: FontWeight::NORMAL, @@ -187,7 +187,7 @@ fn update_radio_styles( Has, Has, &Hovered, - &ThemeFontColor, + &InheritableThemeTextColor, ), ( With, @@ -247,7 +247,7 @@ fn update_radio_styles_remove( Has, Has, &Hovered, - &ThemeFontColor, + &InheritableThemeTextColor, ), With, >, @@ -319,7 +319,7 @@ fn set_radio_styles( activate_on_press: bool, outline_border: &ThemeBorderColor, mark_color: &ThemeBackgroundColor, - font_color: &ThemeFontColor, + font_color: &InheritableThemeTextColor, commands: &mut Commands, ) { let outline_border_token = if checked { @@ -388,7 +388,7 @@ fn set_radio_styles( if font_color.0 != font_color_token { commands .entity(radio_ent) - .insert(ThemeFontColor(font_color_token)); + .insert(InheritableThemeTextColor(font_color_token)); } // Change cursor shape diff --git a/crates/bevy_feathers/src/controls/slider.rs b/crates/bevy_feathers/src/controls/slider.rs index c09e8eaae7e98..c1cc0c38d4066 100644 --- a/crates/bevy_feathers/src/controls/slider.rs +++ b/crates/bevy_feathers/src/controls/slider.rs @@ -34,7 +34,7 @@ use crate::{ focus::FocusIndicator, font_styles::InheritableFont, rounded_corners::RoundedCorners, - theme::{ThemeFontColor, ThemedText, UiTheme}, + theme::{InheritableThemeTextColor, ThemedText, UiTheme}, tokens, }; @@ -121,7 +121,7 @@ pub fn slider(props: SliderProps) -> impl Scene { align_items: AlignItems::Center, justify_content: JustifyContent::Center, } - ThemeFontColor(tokens::SLIDER_TEXT) + InheritableThemeTextColor(tokens::SLIDER_TEXT) InheritableFont { font: fonts::MONO, font_size: size::SMALL_FONT, @@ -189,7 +189,7 @@ pub fn slider_bundle(props: SliderProps, overrides: B) -> impl Bundle justify_content: JustifyContent::Center, ..Default::default() }, - ThemeFontColor(tokens::SLIDER_TEXT), + InheritableThemeTextColor(tokens::SLIDER_TEXT), InheritableFont { font_size: size::SMALL_FONT, weight: FontWeight::NORMAL, diff --git a/crates/bevy_feathers/src/controls/text_input.rs b/crates/bevy_feathers/src/controls/text_input.rs index 65c0f06cf2c18..41e26ae3a55fa 100644 --- a/crates/bevy_feathers/src/controls/text_input.rs +++ b/crates/bevy_feathers/src/controls/text_input.rs @@ -1,28 +1,31 @@ -use bevy_app::{Plugin, PreUpdate}; +use bevy_app::{Plugin, PreUpdate, PropagateOver}; +use bevy_asset::AssetServer; use bevy_ecs::{ - change_detection::{DetectChanges, DetectChangesMut}, + change_detection::DetectChanges, component::Component, entity::Entity, - hierarchy::ChildOf, lifecycle::RemovedComponents, query::{Added, Has, With}, schedule::IntoScheduleConfigs, system::{Commands, Query, Res}, + template::template, }; -use bevy_input_focus::{tab_navigation::TabIndex, InputFocus, InputFocusVisible}; +use bevy_input_focus::tab_navigation::TabIndex; use bevy_picking::PickingSystems; use bevy_scene::prelude::*; -use bevy_text::{EditableText, FontWeight, LineBreak, TextCursorStyle, TextLayout}; +use bevy_text::{ + EditableText, FontSource, FontWeight, LineBreak, TextCursorStyle, TextFont, TextLayout, +}; use bevy_ui::{ - px, AlignItems, BorderColor, BorderRadius, Display, InteractionDisabled, JustifyContent, Node, - UiRect, Val, + px, AlignItems, BorderRadius, Display, InteractionDisabled, JustifyContent, Node, UiRect, }; use crate::{ constants::{fonts, size}, cursor::EntityCursor, + focus::FocusWithinIndicator, font_styles::InheritableFont, - theme::{ThemeBackgroundColor, ThemeBorderColor, ThemeFontColor, ThemedText, UiTheme}, + theme::{InheritableThemeTextColor, ThemeBackgroundColor, UiTheme}, tokens, }; @@ -35,6 +38,7 @@ struct FeathersTextInputContainer; struct FeathersTextInput; /// Parameters for the text input template, passed to [`text_input`] function. +#[derive(Default, Clone)] pub struct TextInputProps { /// Visible width pub visible_width: Option, @@ -51,16 +55,20 @@ pub fn text_input_container() -> impl Scene { display: Display::Flex, justify_content: JustifyContent::Center, align_items: AlignItems::Center, - padding: UiRect::axes(Val::Px(4.0), Val::Px(0.)), - border: UiRect::all(Val::Px(2.0)), + padding: UiRect { + right: px(3.0), + }, + border: UiRect { + left: px(3.0) + }, flex_grow: 1.0, border_radius: {BorderRadius::all(px(4.0))}, column_gap: px(4), } FeathersTextInputContainer - ThemeBorderColor(tokens::TEXT_INPUT_BG) + FocusWithinIndicator ThemeBackgroundColor(tokens::TEXT_INPUT_BG) - ThemeFontColor(tokens::TEXT_INPUT_TEXT) + InheritableThemeTextColor(tokens::TEXT_INPUT_TEXT) InheritableFont { font: fonts::REGULAR, font_size: size::COMPACT_FONT, @@ -102,7 +110,15 @@ pub fn text_input(props: TextInputProps) -> impl Scene { linebreak: LineBreak::NoWrap, } TabIndex(0) - ThemedText + template(|ctx| { + Ok(TextFont { + font: FontSource::Handle(ctx.resource::().load(fonts::REGULAR)), + font_size: size::COMPACT_FONT, + weight: FontWeight::NORMAL, + ..Default::default() + }) + }) + PropagateOver EntityCursor::System(bevy_window::SystemCursorIcon::Text) TextCursorStyle::default() } @@ -122,7 +138,7 @@ fn update_text_cursor_color( fn update_text_input_styles( q_inputs: Query< - (Entity, Has, &ThemeFontColor), + (Entity, Has, &InheritableThemeTextColor), (With, Added), >, mut commands: Commands, @@ -133,7 +149,10 @@ fn update_text_input_styles( } fn update_text_input_styles_remove( - q_inputs: Query<(Entity, Has, &ThemeFontColor), With>, + q_inputs: Query< + (Entity, Has, &InheritableThemeTextColor), + With, + >, mut removed_disabled: RemovedComponents, mut commands: Commands, ) { @@ -144,43 +163,10 @@ fn update_text_input_styles_remove( }); } -fn update_text_input_focus( - q_inputs: Query<(), With>, - q_input_containers: Query<(Entity, &mut BorderColor), With>, - parents: Query<&ChildOf>, - focus: Res, - focus_visible: Res, - theme: Res, -) { - // We're not using FocusIndicator here because (a) the focus ring is inset rather than - // an outline, and (b) we want to detect focus on a descendant rather than an ancestor. - if focus.is_changed() { - let focus_parent = focus.get().and_then(|focus_ent| { - if focus_visible.0 && q_inputs.contains(focus_ent) { - parents - .iter_ancestors(focus_ent) - .find(|ent| q_input_containers.contains(*ent)) - } else { - None - } - }); - - for (container, mut border_color) in q_input_containers { - let new_border_color = if Some(container) == focus_parent { - theme.color(&tokens::FOCUS_RING) - } else { - theme.color(&tokens::TEXT_INPUT_BG) - }; - - border_color.set_if_neq(BorderColor::all(new_border_color)); - } - } -} - fn set_text_input_styles( input_ent: Entity, disabled: bool, - font_color: &ThemeFontColor, + font_color: &InheritableThemeTextColor, commands: &mut Commands, ) { let font_color_token = match disabled { @@ -197,7 +183,7 @@ fn set_text_input_styles( if font_color.0 != font_color_token { commands .entity(input_ent) - .insert(ThemeFontColor(font_color_token)); + .insert(InheritableThemeTextColor(font_color_token)); } // Change cursor shape @@ -217,7 +203,6 @@ impl Plugin for TextInputPlugin { update_text_cursor_color, update_text_input_styles, update_text_input_styles_remove, - update_text_input_focus, ) .in_set(PickingSystems::Last), ); diff --git a/crates/bevy_feathers/src/dark_theme.rs b/crates/bevy_feathers/src/dark_theme.rs index 701a762802ca6..2da212caafc23 100644 --- a/crates/bevy_feathers/src/dark_theme.rs +++ b/crates/bevy_feathers/src/dark_theme.rs @@ -251,6 +251,7 @@ pub fn create_dark_theme() -> ThemeProps { ), // Text Input (tokens::TEXT_INPUT_BG, palette::GRAY_1), + (tokens::TEXT_INPUT_LABEL_BG, palette::GRAY_3), (tokens::TEXT_INPUT_TEXT, palette::WHITE), ( tokens::TEXT_INPUT_TEXT_DISABLED, @@ -258,6 +259,9 @@ pub fn create_dark_theme() -> ThemeProps { ), (tokens::TEXT_INPUT_CURSOR, palette::ACCENT.lighter(0.2)), (tokens::TEXT_INPUT_SELECTION, palette::ACCENT), + (tokens::TEXT_INPUT_X_AXIS, palette::X_AXIS), + (tokens::TEXT_INPUT_Y_AXIS, palette::Y_AXIS), + (tokens::TEXT_INPUT_Z_AXIS, palette::Z_AXIS), // Pane (tokens::PANE_HEADER_BG, palette::GRAY_0), (tokens::PANE_HEADER_BORDER, palette::WARM_GRAY_1), diff --git a/crates/bevy_feathers/src/display/label.rs b/crates/bevy_feathers/src/display/label.rs index 8956a16e72e6e..d98f77cac5c8e 100644 --- a/crates/bevy_feathers/src/display/label.rs +++ b/crates/bevy_feathers/src/display/label.rs @@ -1,46 +1,64 @@ //! BSN scene function for displaying a plain text string in the correct font. -use bevy_ecs::hierarchy::Children; +use bevy_app::PropagateOver; +use bevy_asset::AssetServer; +use bevy_ecs::template::template; use bevy_scene::{bsn, Scene}; -use bevy_text::FontWeight; -use bevy_ui::{widget::Text, Node}; +use bevy_text::{FontSource, FontWeight, TextFont}; +use bevy_ui::widget::Text; use crate::{ constants::{fonts, size}, - font_styles::InheritableFont, - theme::{ThemeFontColor, ThemedText}, + theme::ThemeTextColor, tokens, }; /// A text label. pub fn label(text: impl Into) -> impl Scene { bsn! { - Node - ThemeFontColor(tokens::TEXT_MAIN) - InheritableFont { - font: fonts::REGULAR, - font_size: size::MEDIUM_FONT, - weight: FontWeight::NORMAL, - } - Children [ - Text(text) - ThemedText - ] + Text(text) + template(|ctx| { + Ok(TextFont { + font: FontSource::Handle(ctx.resource::().load(fonts::REGULAR)), + font_size: size::MEDIUM_FONT, + weight: FontWeight::NORMAL, + ..Default::default() + }) + }) + PropagateOver + ThemeTextColor(tokens::TEXT_MAIN) } } /// A text label with a dimmed color. pub fn label_dim(text: impl Into) -> impl Scene { bsn! { - Node - ThemeFontColor(tokens::TEXT_DIM) - InheritableFont { - font: fonts::REGULAR, - font_size: size::MEDIUM_FONT, - weight: FontWeight::NORMAL, - } - Children [ - Text(text) - ThemedText - ] + Text(text) + template(|ctx| { + Ok(TextFont { + font: FontSource::Handle(ctx.resource::().load(fonts::REGULAR)), + font_size: size::MEDIUM_FONT, + weight: FontWeight::NORMAL, + ..Default::default() + }) + }) + PropagateOver + ThemeTextColor(tokens::TEXT_DIM) + } +} + +/// A small text label, used for field captions. +pub fn label_small(text: impl Into) -> impl Scene { + bsn! { + Text(text) + template(|ctx| { + Ok(TextFont { + font: FontSource::Handle(ctx.resource::().load(fonts::REGULAR)), + font_size: size::EXTRA_SMALL_FONT, + weight: FontWeight::NORMAL, + ..Default::default() + }) + }) + PropagateOver + ThemeTextColor(tokens::TEXT_MAIN) } } diff --git a/crates/bevy_feathers/src/focus.rs b/crates/bevy_feathers/src/focus.rs index 828eb98eda1c6..3f677967b91c2 100644 --- a/crates/bevy_feathers/src/focus.rs +++ b/crates/bevy_feathers/src/focus.rs @@ -4,7 +4,7 @@ use bevy_ecs::{ change_detection::DetectChanges, component::Component, entity::Entity, - hierarchy::Children, + hierarchy::{ChildOf, Children}, query::With, reflect::ReflectComponent, schedule::IntoScheduleConfigs, @@ -24,12 +24,21 @@ use crate::{theme::UiTheme, tokens}; #[reflect(Component, Clone, Default)] pub struct FocusIndicator; +/// A marker component which indicates that this entity should display a visible focus outline +/// when either it, or any descendant, are focused. Insert this into a widget on the entity that +/// you wish to display a focus outline. +#[derive(Component, Default, Clone, Reflect)] +#[reflect(Component, Clone, Default)] +pub struct FocusWithinIndicator; + fn manage_focus_indicators( mut commands: Commands, input_focus: Res, input_focus_visible: Res, q_indicators: Query>, + q_within_indicators: Query>, q_children: Query<&Children>, + q_parents: Query<&ChildOf>, theme: Res, ) { if !input_focus.is_changed() && !input_focus_visible.is_changed() && !theme.is_changed() { @@ -40,6 +49,7 @@ fn manage_focus_indicators( if let Some(focus) = input_focus.get() && input_focus_visible.0 { + // Look for focus in descendants for entity in q_children .iter_descendants(focus) .chain(core::iter::once(focus)) @@ -53,9 +63,24 @@ fn manage_focus_indicators( visited.insert(entity); } } + + // Look for focus in ancestors + for entity in q_parents + .iter_ancestors(focus) + .chain(core::iter::once(focus)) + { + if q_within_indicators.contains(entity) { + commands.entity(entity).insert(Outline { + color: theme.color(&tokens::FOCUS_RING), + width: Val::Px(2.0), + offset: Val::Px(2.0), + }); + visited.insert(entity); + } + } } - for entity in q_indicators.iter() { + for entity in q_indicators.iter().chain(q_within_indicators.iter()) { if !visited.contains(&entity) { commands.entity(entity).remove::(); } diff --git a/crates/bevy_feathers/src/lib.rs b/crates/bevy_feathers/src/lib.rs index 310e06581adbd..9410534a23ae1 100644 --- a/crates/bevy_feathers/src/lib.rs +++ b/crates/bevy_feathers/src/lib.rs @@ -106,6 +106,7 @@ impl Plugin for FeathersCorePlugin { .add_observer(theme::on_changed_background) .add_observer(theme::on_changed_border) .add_observer(theme::on_changed_font_color) + .add_observer(theme::on_changed_text_color) .add_observer(font_styles::on_changed_font); app.init_resource::(); diff --git a/crates/bevy_feathers/src/theme.rs b/crates/bevy_feathers/src/theme.rs index 05a3bf4df855e..2dbd11f083493 100644 --- a/crates/bevy_feathers/src/theme.rs +++ b/crates/bevy_feathers/src/theme.rs @@ -106,7 +106,19 @@ pub struct ThemeBorderColor(pub ThemeToken); #[derive(Reflect)] #[reflect(Component, Clone)] #[require(ThemedText, PropagateOver::)] -pub struct ThemeFontColor(pub ThemeToken); +pub struct InheritableThemeTextColor(pub ThemeToken); + +/// Component which causes the color of a text span to be set based on a theme color. Unlike +/// [`InheritableThemeTextColor`], this can work when set directly on the text span entity, and is +/// not inherited. +// TODO: This is necessary because an entity with Propagate doesn't update itself, only its +// descendants. +#[derive(Component, Clone, Default)] +#[component(immutable)] +#[derive(Reflect)] +#[reflect(Component, Clone)] +#[require(ThemedText, PropagateOver::)] +pub struct ThemeTextColor(pub ThemeToken); /// A marker component that is used to indicate that the text entity wants to opt-in to using /// inherited text styles. @@ -117,6 +129,7 @@ pub struct ThemedText; pub(crate) fn update_theme( mut q_background: Query<(&mut BackgroundColor, &ThemeBackgroundColor)>, mut q_border: Query<(&mut BorderColor, &ThemeBorderColor)>, + mut q_text_color: Query<(&mut TextColor, &ThemeTextColor)>, theme: Res, ) { if theme.is_changed() { @@ -129,6 +142,11 @@ pub(crate) fn update_theme( for (mut border, theme_border) in q_border.iter_mut() { border.set_all(theme.color(&theme_border.0)); } + + // Update all direct text span colors + for (mut text_color, theme_text_color) in q_text_color.iter_mut() { + text_color.0 = theme.color(&theme_text_color.0); + } } } @@ -157,11 +175,22 @@ pub(crate) fn on_changed_border( } } -/// An observer which looks for changes to the [`ThemeFontColor`] component on an entity, and -/// propagates downward the text color to all participating text entities. +pub(crate) fn on_changed_text_color( + insert: On, + mut q_span: Query<(&mut TextColor, &ThemeTextColor), Changed>, + theme: Res, +) { + // Update background colors where the design token has changed. + if let Ok((mut text_color, theme_text_color)) = q_span.get_mut(insert.entity) { + text_color.0 = theme.color(&theme_text_color.0); + } +} + +/// An observer which looks for changes to the [`InheritableThemeTextColor`] component on an entity, +/// and propagates downward the text color to all participating text entities. pub(crate) fn on_changed_font_color( - insert: On, - font_color: Query<&ThemeFontColor>, + insert: On, + font_color: Query<&InheritableThemeTextColor>, theme: Res, mut commands: Commands, ) { diff --git a/crates/bevy_feathers/src/tokens.rs b/crates/bevy_feathers/src/tokens.rs index f35a96d5e49c7..3a53f1d31d1b9 100644 --- a/crates/bevy_feathers/src/tokens.rs +++ b/crates/bevy_feathers/src/tokens.rs @@ -319,6 +319,14 @@ pub const TEXT_INPUT_TEXT_DISABLED: ThemeToken = pub const TEXT_INPUT_CURSOR: ThemeToken = ThemeToken::new_static("feathers.textinput.cursor"); /// Selection color for text input pub const TEXT_INPUT_SELECTION: ThemeToken = ThemeToken::new_static("feathers.textinput.selection"); +/// Background color for label text +pub const TEXT_INPUT_LABEL_BG: ThemeToken = ThemeToken::new_static("feathers.textinput.label.bg"); +/// Sigil color for X +pub const TEXT_INPUT_X_AXIS: ThemeToken = ThemeToken::new_static("feathers.textinput.axis.x"); +/// Sigil color for Y +pub const TEXT_INPUT_Y_AXIS: ThemeToken = ThemeToken::new_static("feathers.textinput.axis.y"); +/// Sigil color for Z +pub const TEXT_INPUT_Z_AXIS: ThemeToken = ThemeToken::new_static("feathers.textinput.axis.z"); // Pane diff --git a/crates/bevy_ui_widgets/src/checkbox.rs b/crates/bevy_ui_widgets/src/checkbox.rs index e8293556b05b0..fd67d2702c46a 100644 --- a/crates/bevy_ui_widgets/src/checkbox.rs +++ b/crates/bevy_ui_widgets/src/checkbox.rs @@ -48,6 +48,7 @@ fn checkbox_on_key_input( commands.trigger(ValueChange { source: ev.focused_entity, value: !is_checked, + is_final: true, }); } } @@ -67,6 +68,7 @@ fn checkbox_on_pointer_click( commands.trigger(ValueChange { source: click.entity, value: !is_checked, + is_final: true, }); } } @@ -107,6 +109,7 @@ fn checkbox_on_pointer_down( commands.trigger(ValueChange { source: press.entity, value: !checked, + is_final: true, }); } } @@ -219,6 +222,7 @@ fn checkbox_on_set_checked( commands.trigger(ValueChange { source: set_checked.entity, value: will_be_checked, + is_final: true, }); } } @@ -237,6 +241,7 @@ fn checkbox_on_toggle_checked( commands.trigger(ValueChange { source: toggle_checked.entity, value: !is_checked, + is_final: true, }); } } diff --git a/crates/bevy_ui_widgets/src/lib.rs b/crates/bevy_ui_widgets/src/lib.rs index f7abf98cc8c9b..56d4d03850c80 100644 --- a/crates/bevy_ui_widgets/src/lib.rs +++ b/crates/bevy_ui_widgets/src/lib.rs @@ -84,4 +84,8 @@ pub struct ValueChange { pub source: Entity, /// The new value. pub value: T, + /// If false, it means that we are in the middle of an interaction (slider being dragged, + /// user typing), while if true it means that the user's interaction is finished (mouse button + /// released, drag ended, input lost focus). + pub is_final: bool, } diff --git a/crates/bevy_ui_widgets/src/radio.rs b/crates/bevy_ui_widgets/src/radio.rs index 88ba38b074368..cb17f21dfb22f 100644 --- a/crates/bevy_ui_widgets/src/radio.rs +++ b/crates/bevy_ui_widgets/src/radio.rs @@ -142,11 +142,13 @@ fn radio_group_on_key_input( commands.trigger(ValueChange:: { source: next_id, value: true, + is_final: true, }); // Trigger the `ValueChange` event for the newly checked radio button on radio group commands.trigger(ValueChange:: { source: ev.focused_entity, value: next_id, + is_final: true, }); } } @@ -299,6 +301,7 @@ fn trigger_radio_button_and_radio_group_value_change( commands.trigger(ValueChange:: { source: radio_button, value: true, + is_final: true, }); // Find if radio button is inside radio group @@ -312,6 +315,7 @@ fn trigger_radio_button_and_radio_group_value_change( commands.trigger(ValueChange:: { source: radio_group, value: radio_button, + is_final: true, }); } } diff --git a/crates/bevy_ui_widgets/src/slider.rs b/crates/bevy_ui_widgets/src/slider.rs index 6b619425a563a..dc85764e13ec9 100644 --- a/crates/bevy_ui_widgets/src/slider.rs +++ b/crates/bevy_ui_widgets/src/slider.rs @@ -353,6 +353,7 @@ pub(crate) fn slider_on_pointer_down( commands.trigger(ValueChange { source: press.entity, value: new_value, + is_final: false, }); } } @@ -379,14 +380,14 @@ pub(crate) fn slider_on_drag_start( pub(crate) fn slider_on_drag( mut event: On>, - mut q_slider: Query< + q_slider: Query< ( &Slider, &ComputedNode, &SliderRange, Option<&SliderPrecision>, &UiGlobalTransform, - &mut CoreSliderDragState, + &CoreSliderDragState, Has, ), With, @@ -397,72 +398,139 @@ pub(crate) fn slider_on_drag( ui_scale: Res, ) { if let Ok((slider, node, range, precision, transform, drag, disabled)) = - q_slider.get_mut(event.entity) + q_slider.get(event.entity) { event.propagate(false); if drag.dragging && !disabled { - let is_vertical = slider.orientation.is_vertical(node); - - let mut distance = event.distance / ui_scale.0; - distance.y *= -1.; - let distance = transform.transform_vector2(distance); - - // Find thumb size by searching descendants for the first entity with SliderThumb - let thumb_size = q_children - .iter_descendants(event.entity) - .find_map(|child_id| { - q_thumb.get(child_id).ok().map(|thumb| { - if is_vertical { - thumb.size().y - } else { - thumb.size().x - } - }) - }) - .unwrap_or(0.0); - - let slider_size = if is_vertical { - ((node.size().y - thumb_size) * node.inverse_scale_factor).max(1.0) - } else { - ((node.size().x - thumb_size) * node.inverse_scale_factor).max(1.0) - }; - - let drag_distance = if is_vertical { distance.y } else { distance.x }; - - let span = range.span(); - let new_value = if span > 0. { - drag.offset + (drag_distance * span) / slider_size - } else { - range.start() + span * 0.5 - }; - let rounded_value = range.clamp( - precision - .map(|prec| prec.round(new_value)) - .unwrap_or(new_value), + emit_slider_drag_value_change( + &mut commands, + event.entity, + slider, + node, + range, + precision, + transform, + drag, + &q_thumb, + &q_children, + &ui_scale, + event.distance, + false, ); - - commands.trigger(ValueChange { - source: event.entity, - value: rounded_value, - }); } } } pub(crate) fn slider_on_drag_end( mut drag_end: On>, - mut q_slider: Query<(Entity, &Slider, &mut CoreSliderDragState)>, + mut q_slider: Query< + ( + Entity, + &Slider, + &ComputedNode, + &SliderRange, + Option<&SliderPrecision>, + &UiGlobalTransform, + &mut CoreSliderDragState, + Has, + ), + With, + >, + q_thumb: Query<&ComputedNode, With>, + q_children: Query<&Children>, mut commands: Commands, + ui_scale: Res, ) { - if let Ok((slider_ent, _slider, mut drag)) = q_slider.get_mut(drag_end.entity) { + if let Ok((slider_ent, slider, node, range, precision, transform, mut drag, disabled)) = + q_slider.get_mut(drag_end.entity) + { drag_end.propagate(false); - commands.entity(slider_ent).remove::(); if drag.dragging { + if !disabled { + emit_slider_drag_value_change( + &mut commands, + drag_end.entity, + slider, + node, + range, + precision, + transform, + &drag, + &q_thumb, + &q_children, + &ui_scale, + drag_end.distance, + true, + ); + } + commands.entity(slider_ent).remove::(); drag.dragging = false; } } } +fn emit_slider_drag_value_change( + commands: &mut Commands, + entity: Entity, + slider: &Slider, + node: &ComputedNode, + range: &SliderRange, + precision: Option<&SliderPrecision>, + transform: &UiGlobalTransform, + drag: &CoreSliderDragState, + q_thumb: &Query<&ComputedNode, With>, + q_children: &Query<&Children>, + ui_scale: &UiScale, + distance: bevy_math::Vec2, + is_final: bool, +) { + let is_vertical = slider.orientation.is_vertical(node); + + let mut distance = distance / ui_scale.0; + distance.y *= -1.; + let distance = transform.transform_vector2(distance); + + // Find thumb size by searching descendants for the first entity with SliderThumb + let thumb_size = q_children + .iter_descendants(entity) + .find_map(|child_id| { + q_thumb.get(child_id).ok().map(|thumb| { + if is_vertical { + thumb.size().y + } else { + thumb.size().x + } + }) + }) + .unwrap_or(0.0); + + let slider_size = if is_vertical { + ((node.size().y - thumb_size) * node.inverse_scale_factor).max(1.0) + } else { + ((node.size().x - thumb_size) * node.inverse_scale_factor).max(1.0) + }; + + let drag_distance = if is_vertical { distance.y } else { distance.x }; + + let span = range.span(); + let new_value = if span > 0. { + drag.offset + (drag_distance * span) / slider_size + } else { + range.start() + span * 0.5 + }; + let rounded_value = range.clamp( + precision + .map(|prec| prec.round(new_value)) + .unwrap_or(new_value), + ); + + commands.trigger(ValueChange { + source: entity, + value: rounded_value, + is_final, + }); +} + fn slider_on_pointer_up( mut release: On>, mut q_slider: Query<(Entity, Has, Has), With>, @@ -518,6 +586,7 @@ fn slider_on_key_input( commands.trigger(ValueChange { source: focused_input.focused_entity, value: new_value, + is_final: true, }); } } @@ -630,6 +699,7 @@ fn slider_on_set_value( commands.trigger(ValueChange { source: set_slider_value.entity, value: new_value, + is_final: true, }); } } diff --git a/examples/ui/widgets/feathers_gallery.rs b/examples/ui/widgets/feathers_gallery.rs index b5590617fd5d3..3f6272b58baf8 100644 --- a/examples/ui/widgets/feathers_gallery.rs +++ b/examples/ui/widgets/feathers_gallery.rs @@ -10,16 +10,18 @@ use bevy::{ }, controls::{ button, checkbox, color_plane, color_slider, color_swatch, disclosure_toggle, menu, - menu_button, menu_divider, menu_item, menu_popup, radio, slider, text_input, - text_input_container, toggle_switch, tool_button, ButtonProps, ButtonVariant, - CheckboxProps, ColorChannel, ColorPlane, ColorPlaneValue, ColorSlider, + menu_button, menu_divider, menu_item, menu_popup, number_input, radio, slider, + text_input, text_input_container, toggle_switch, tool_button, ButtonProps, + ButtonVariant, CheckboxProps, ColorChannel, ColorPlane, ColorPlaneValue, ColorSlider, ColorSliderProps, ColorSwatch, ColorSwatchValue, MenuButtonProps, MenuItemProps, - RadioProps, SliderBaseColor, SliderProps, TextInputProps, + NumberInputProps, NumberInputValue, RadioProps, SliderBaseColor, SliderProps, + TextInputProps, UpdateNumberInput, }, cursor::{EntityCursor, OverrideCursor}, dark_theme::create_dark_theme, - display::{icon, label, label_dim}, + display::{icon, label, label_dim, label_small}, font_styles::InheritableFont, + palette, rounded_corners::RoundedCorners, theme::{ThemeBackgroundColor, ThemedText, UiTheme}, tokens, FeathersPlugins, @@ -40,6 +42,8 @@ use bevy::{ struct DemoWidgetStates { rgb_color: Srgba, hsl_color: Hsla, + scalar_prop: f32, + vec3_prop: Vec3, } #[derive(Component, Clone, Copy, PartialEq, FromTemplate)] @@ -55,6 +59,17 @@ struct HexColorInput; #[derive(Component, Clone, Copy, Default)] struct DemoDisabledButton; +#[derive(Component, Clone, Copy, Default)] +struct DemoScalarField; + +#[derive(Component, Clone, Copy, Default)] +enum DemoVec3Field { + #[default] + X, + Y, + Z, +} + fn main() { App::new() .add_plugins((DefaultPlugins, FeathersPlugins)) @@ -62,6 +77,8 @@ fn main() { .insert_resource(DemoWidgetStates { rgb_color: palettes::tailwind::EMERALD_800.with_alpha(0.7), hsl_color: palettes::tailwind::AMBER_800.into(), + scalar_prop: 7.0, + vec3_prop: Vec3::new(10.1, 7.124, 100.0), }) .add_systems(Startup, scene.spawn()) .add_systems(Update, update_colors) @@ -462,7 +479,7 @@ fn demo_column_1() -> impl Scene { :flex_spacer, // Text input ( - :text_input_container + :text_input_container() Node { flex_grow: 0. padding: { px(4.).left().with_right(px(0.)) }, @@ -637,12 +654,111 @@ fn demo_column_2() -> impl Scene { ], :subpane_body Children [ :label_dim("A standard sub-pane"), - :group Children [ + :group + Children [ :group_header Children [ (Text("Group") ThemedText), ], - :group_body Children [ - :label_dim("A standard group"), + :group_body + Children [ + :label("A standard group"), + :label_small("Scalar property"), + ( + :number_input(NumberInputProps::default()) + DemoScalarField + Node { + flex_grow: 1.0, + max_width: px(100), + } + on( + |value_change: On>, + mut states: ResMut| { + if value_change.is_final { + states.scalar_prop = value_change.value; + } + }) + ), + :label_small("Scalar property (copy)"), + ( + :number_input(NumberInputProps::default()) + DemoScalarField + Node { + flex_grow: 1.0, + max_width: px(100), + } + on( + |value_change: On>, + mut states: ResMut| { + if value_change.is_final { + states.scalar_prop = value_change.value; + } + }) + ), + :label_small("Vec3 property"), + Node { + display: Display::Flex, + flex_direction: FlexDirection::Row, + column_gap: px(6), + align_items: AlignItems::Center, + justify_content: JustifyContent::SpaceBetween, + } + Children [ + ( + :number_input(NumberInputProps { + sigil_color: tokens::TEXT_INPUT_X_AXIS, + label_text: Some("X"), + ..default() + }) + template_value(DemoVec3Field::X) + Node { + flex_grow: 1.0, + } + BorderColor::all(palette::X_AXIS) + on( + |value_change: On>, + mut states: ResMut| { + if value_change.is_final { + states.vec3_prop.x = value_change.value; + } + }) + ), + ( + :number_input(NumberInputProps { + sigil_color: tokens::TEXT_INPUT_Y_AXIS, + label_text: Some("Y"), + ..default() + }) + template_value(DemoVec3Field::Y) + Node { + flex_grow: 1.0, + } + on( + |value_change: On>, + mut states: ResMut| { + if value_change.is_final { + states.vec3_prop.y = value_change.value; + } + }) + ), + ( + :number_input(NumberInputProps { + sigil_color: tokens::TEXT_INPUT_Z_AXIS, + label_text: Some("Z"), + ..default() + }) + template_value(DemoVec3Field::Z) + Node { + flex_grow: 1.0, + } + on( + |value_change: On>, + mut states: ResMut| { + if value_change.is_final { + states.vec3_prop.z = value_change.value; + } + }) + ), + ], ], ] ], @@ -656,73 +772,75 @@ fn demo_column_2() -> impl Scene { } fn update_colors( - colors: Res, + states: Res, mut sliders: Query<(Entity, &ColorSlider, &mut SliderBaseColor)>, mut swatches: Query<(&mut ColorSwatchValue, &SwatchType), With>, mut color_planes: Query<&mut ColorPlaneValue, With>, q_text_input: Single<(Entity, &mut EditableText), With>, + q_scalar_input: Query>, + q_vec3_input: Query<(Entity, &DemoVec3Field)>, mut commands: Commands, focus: Res, ) { - if colors.is_changed() { + if states.is_changed() { for (slider_ent, slider, mut base) in sliders.iter_mut() { match slider.channel { ColorChannel::Red => { - base.0 = colors.rgb_color.into(); + base.0 = states.rgb_color.into(); commands .entity(slider_ent) - .insert(SliderValue(colors.rgb_color.red)); + .insert(SliderValue(states.rgb_color.red)); } ColorChannel::Green => { - base.0 = colors.rgb_color.into(); + base.0 = states.rgb_color.into(); commands .entity(slider_ent) - .insert(SliderValue(colors.rgb_color.green)); + .insert(SliderValue(states.rgb_color.green)); } ColorChannel::Blue => { - base.0 = colors.rgb_color.into(); + base.0 = states.rgb_color.into(); commands .entity(slider_ent) - .insert(SliderValue(colors.rgb_color.blue)); + .insert(SliderValue(states.rgb_color.blue)); } ColorChannel::HslHue => { - base.0 = colors.hsl_color.into(); + base.0 = states.hsl_color.into(); commands .entity(slider_ent) - .insert(SliderValue(colors.hsl_color.hue)); + .insert(SliderValue(states.hsl_color.hue)); } ColorChannel::HslSaturation => { - base.0 = colors.hsl_color.into(); + base.0 = states.hsl_color.into(); commands .entity(slider_ent) - .insert(SliderValue(colors.hsl_color.saturation)); + .insert(SliderValue(states.hsl_color.saturation)); } ColorChannel::HslLightness => { - base.0 = colors.hsl_color.into(); + base.0 = states.hsl_color.into(); commands .entity(slider_ent) - .insert(SliderValue(colors.hsl_color.lightness)); + .insert(SliderValue(states.hsl_color.lightness)); } ColorChannel::Alpha => { - base.0 = colors.rgb_color.into(); + base.0 = states.rgb_color.into(); commands .entity(slider_ent) - .insert(SliderValue(colors.rgb_color.alpha)); + .insert(SliderValue(states.rgb_color.alpha)); } } } for (mut swatch_value, swatch_type) in swatches.iter_mut() { swatch_value.0 = match swatch_type { - SwatchType::Rgb => colors.rgb_color.into(), - SwatchType::Hsl => colors.hsl_color.into(), + SwatchType::Rgb => states.rgb_color.into(), + SwatchType::Hsl => states.hsl_color.into(), }; } for mut plane_value in color_planes.iter_mut() { - plane_value.0.x = colors.rgb_color.red; - plane_value.0.y = colors.rgb_color.blue; - plane_value.0.z = colors.rgb_color.green; + plane_value.0.x = states.rgb_color.red; + plane_value.0.y = states.rgb_color.blue; + plane_value.0.z = states.rgb_color.green; } // Only update the hex input field when it's not focused, otherwise it interferes @@ -730,7 +848,27 @@ fn update_colors( let (input_ent, mut editable_text) = q_text_input.into_inner(); if Some(input_ent) != focus.get() { editable_text.queue_edit(TextEdit::SelectAll); - editable_text.queue_edit(TextEdit::Insert(colors.rgb_color.to_hex().into())); + editable_text.queue_edit(TextEdit::Insert(states.rgb_color.to_hex().into())); + } + + for scalar_input_ent in q_scalar_input.iter() { + commands.trigger(UpdateNumberInput { + entity: scalar_input_ent, + value: NumberInputValue::F32(states.scalar_prop), + }); + } + + for (vec3_input_ent, axis) in q_vec3_input.iter() { + let new_value = match axis { + DemoVec3Field::X => states.vec3_prop.x, + DemoVec3Field::Y => states.vec3_prop.y, + DemoVec3Field::Z => states.vec3_prop.z, + }; + + commands.trigger(UpdateNumberInput { + entity: vec3_input_ent, + value: NumberInputValue::F32(new_value), + }); } } }