diff --git a/crates/bevy_feathers/src/controls/number_input.rs b/crates/bevy_feathers/src/controls/number_input.rs index 1a28935f81d7d..ecbae2c7aef29 100644 --- a/crates/bevy_feathers/src/controls/number_input.rs +++ b/crates/bevy_feathers/src/controls/number_input.rs @@ -1,25 +1,17 @@ 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, + component::Component, entity::Entity, event::EntityEvent, hierarchy::Children, observer::On, + query::With, system::Query, 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_text::{EditableText, FontSource, FontWeight, TextFont}; use bevy_ui::{px, widget::Text, AlignItems, AlignSelf, Display, JustifyContent, Node, UiRect}; -use bevy_ui_widgets::{SelectAllOnFocus, ValueChange}; +use bevy_ui_widgets::{ + NumberInput as CoreNumberInput, RangedNumberInput, RangedNumberInputValueInput, + SelectAllOnFocus, SetNumberInputValue, SetRangedNumberInputValue, SliderOrientation, + SliderPrecision, SliderRange, SliderValue, TrackClick, +}; use crate::{ constants::{fonts, size}, @@ -28,24 +20,15 @@ use crate::{ tokens, }; -/// Marker to indicate a number input widget with feathers styling. +pub use bevy_ui_widgets::{NumberFormat, NumberInputValue}; + +/// 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, -} +/// Marker to indicate a Feathers ranged number input. +#[derive(Component, Default, Clone)] +struct FeathersRangedNumberInput; /// Parameters for the text input template, passed to [`number_input`] function. pub struct NumberInputProps { @@ -69,287 +52,251 @@ impl Default for NumberInputProps { } } -/// 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), +/// Parameters for [`ranged_number_input`]. +pub struct RangedNumberInputProps { + /// Current value. + pub value: f32, + /// Minimum value. + pub min: f32, + /// Maximum value. + pub max: f32, + /// Decimal precision displayed after drag/programmatic updates. + pub precision: i32, + /// Optional colored left strip. + pub sigil_color: ThemeToken, + /// Optional axis/caption label. + pub label_text: Option<&'static str>, } -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), +impl Default for RangedNumberInputProps { + fn default() -> Self { + Self { + value: 0.0, + min: 0.0, + max: 1.0, + precision: 3, + sigil_color: tokens::TEXT_INPUT_BG, + label_text: None, } } } -/// Event which can be sent to the number input widget to update the displayed value. +/// Event which can be sent to the styled Feathers number input to update the displayed value. #[derive(Clone, EntityEvent)] pub struct UpdateNumberInput { - /// Target widget + /// Target widget. + #[event_target] pub entity: Entity, - /// Value to change to + /// 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. +/// Event sent to the styled Feathers ranged number input to update the displayed value. +#[derive(Clone, EntityEvent)] +pub struct UpdateRangedNumberInput { + /// Target widget. + #[event_target] + pub entity: Entity, + /// Value to change to. + pub value: f32, +} + +/// Styled Feathers wrapper around the core [`bevy_ui_widgets::NumberInput`]. /// -/// 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. +/// Numeric parsing, filtering, and typed value-change behavior are handled by the core widget. pub fn number_input(props: NumberInputProps) -> impl Scene { + let number_format = props.number_format; + 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 + { number_label(props.label_text) }, + ( + text_input(TextInputProps { + visible_width: None, + max_characters: Some(20), + }) + CoreNumberInput { + format: number_format, } - } - text_input(TextInputProps { - visible_width: None, - max_characters: Some(20), - }) - SelectAllOnFocus, - 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') - }), + SelectAllOnFocus + ), ] } } -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; - }; +fn number_label(label_text: Option<&'static str>) -> Box { + match 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, + } +} - let text_value = editable_text.value().to_string(); - emit_value_change(text_value, *number_format, parent.0, &mut commands, false); +/// Styled Feathers wrapper for a bounded, whole-field draggable numeric input. +pub fn ranged_number_input(props: RangedNumberInputProps) -> impl Scene { + bsn! { + :text_input_container() + ThemeBorderColor({props.sigil_color.clone()}) + FeathersRangedNumberInput + RangedNumberInput + SliderValue({props.value}) + SliderRange::new(props.min, props.max) + SliderPrecision({props.precision}) + bevy_ui_widgets::Slider { + track_click: TrackClick::Drag, + orientation: SliderOrientation::Horizontal, + } + on(ranged_number_input_on_update) + Children [ + { number_label(props.label_text) }, + ( + text_input(TextInputProps { + visible_width: None, + max_characters: Some(20), + }) + CoreNumberInput { + format: NumberFormat::F32, + } + RangedNumberInputValueInput + SelectAllOnFocus + EditableText::new(props.value.to_string()) + ), + ] + } } fn number_input_on_update( update: On, + q_feathers: Query<(), With>, q_children: Query<&Children>, - q_number_input: Query<(), With>, - mut q_text_input: Query<&mut EditableText>, - focus: Res, + q_core_input: Query<(), With>, + mut commands: bevy_ecs::system::Commands, ) { - if !q_number_input.contains(update.event_target()) { + if !q_feathers.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())); - } + for child in q_children.iter_descendants(update.event_target()) { + if q_core_input.contains(child) { + commands.trigger(SetNumberInputValue { + entity: child, + value: update.value, + }); 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, +fn ranged_number_input_on_update( + update: On, + q_feathers: Query<(), With>, + mut commands: bevy_ecs::system::Commands, ) { - if key_input.input.key_code != KeyCode::Enter { + if !q_feathers.contains(update.event_target()) { return; } - let Ok(parent) = q_parent.get(key_input.event_target()) else { - return; - }; + commands.trigger(SetRangedNumberInputValue { + entity: update.event_target(), + value: update.value, + }); +} - let Ok(number_format) = q_number_input.get(parent.get()) else { - return; - }; +#[cfg(test)] +mod tests { + use super::*; + use bevy_app::App; + use bevy_ecs::{observer::On, prelude::*}; - let Ok(editable_text) = q_text_input.get(key_input.event_target()) else { - return; - }; + #[derive(Resource, Default)] + struct ForwardedNumberUpdates(Vec<(Entity, NumberInputValue)>); - let text_value = editable_text.value().to_string(); - emit_value_change(text_value, *number_format, parent.0, &mut commands, true); -} + #[derive(Resource, Default)] + struct ForwardedRangedUpdates(Vec<(Entity, f32)>); -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(); + #[test] + fn update_number_input_forwards_to_core_update() { + let mut app = App::new(); + app.init_resource::().add_observer( + |update: On, mut forwarded: ResMut| { + forwarded.0.push((update.event_target(), update.value)); + }, + ); - let Ok(parent) = q_parent.get(editable_text_id) else { - return; - }; + let root = app + .world_mut() + .spawn(FeathersNumberInput) + .observe(number_input_on_update) + .id(); + let input = app + .world_mut() + .spawn((CoreNumberInput::default(), ChildOf(root))) + .id(); - let Ok(number_format) = q_number_input.get(parent.get()) else { - return; - }; + app.world_mut().commands().trigger(UpdateNumberInput { + entity: root, + value: NumberInputValue::I32(9), + }); + app.update(); - let Ok(editable_text) = q_text_input.get_mut(editable_text_id) else { - return; - }; + assert_eq!( + app.world().resource::().0, + vec![(input, NumberInputValue::I32(9))] + ); + } - let text_value = editable_text.value().to_string(); - emit_value_change(text_value, *number_format, parent.0, &mut commands, true); -} + #[test] + fn update_ranged_number_input_forwards_to_core_update() { + let mut app = App::new(); + app.init_resource::(); -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; - } + let root = app + .world_mut() + .spawn(FeathersRangedNumberInput) + .observe(ranged_number_input_on_update) + .observe( + |update: On, + mut forwarded: ResMut| { + forwarded.0.push((update.event_target(), update.value)); + }, + ) + .id(); - 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"); - } - } - } + app.world_mut().commands().trigger(UpdateRangedNumberInput { + entity: root, + value: 0.75, + }); + app.update(); + + assert_eq!( + app.world().resource::().0, + vec![(root, 0.75)] + ); } } diff --git a/crates/bevy_ui_widgets/src/lib.rs b/crates/bevy_ui_widgets/src/lib.rs index ca1b10b0f533c..928847af2347a 100644 --- a/crates/bevy_ui_widgets/src/lib.rs +++ b/crates/bevy_ui_widgets/src/lib.rs @@ -29,9 +29,11 @@ mod button; mod checkbox; mod menu; +mod number_input; mod observe; pub mod popover; mod radio; +mod ranged_number_input; mod scrollbar; mod slider; mod text_input; @@ -39,8 +41,10 @@ mod text_input; pub use button::*; pub use checkbox::*; pub use menu::*; +pub use number_input::*; pub use observe::*; pub use radio::*; +pub use ranged_number_input::*; pub use scrollbar::*; pub use slider::*; pub use text_input::*; @@ -65,7 +69,9 @@ impl PluginGroup for UiWidgetsPlugins { .add(RadioGroupPlugin) .add(ScrollbarPlugin) .add(SliderPlugin) + .add(RangedNumberInputPlugin) .add(EditableTextInputPlugin) + .add(NumberInputPlugin) } } diff --git a/crates/bevy_ui_widgets/src/number_input.rs b/crates/bevy_ui_widgets/src/number_input.rs new file mode 100644 index 0000000000000..e1df9e8eb5bc5 --- /dev/null +++ b/crates/bevy_ui_widgets/src/number_input.rs @@ -0,0 +1,557 @@ +use bevy_app::{App, Plugin}; +use bevy_ecs::{ + component::Component, + entity::Entity, + event::EntityEvent, + lifecycle::Insert, + observer::On, + query::{Has, With}, + system::{Commands, Query, Res}, + world::DeferredWorld, +}; +use bevy_input::keyboard::{KeyCode, KeyboardInput}; +use bevy_input::ButtonState; +use bevy_input_focus::{FocusLost, FocusedInput, InputFocus}; +use bevy_log::warn; +use bevy_text::{EditableText, EditableTextFilter, TextEdit, TextEditChange}; + +use crate::ValueChange; + +/// Defines what numeric type a [`NumberInput`] edits. +#[derive(Debug, Default, PartialEq, Eq, 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, +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub(crate) enum NumberInputParseError { + Empty, + InvalidFloat, + InvalidInteger, +} + +impl core::fmt::Display for NumberInputParseError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + NumberInputParseError::Empty => write!(f, "empty numeric input"), + NumberInputParseError::InvalidFloat => write!(f, "invalid floating-point number"), + NumberInputParseError::InvalidInteger => write!(f, "invalid integer number"), + } + } +} + +impl NumberFormat { + pub(crate) fn parse(self, text: &str) -> Result { + let text = text.trim(); + if text.is_empty() { + return Err(NumberInputParseError::Empty); + } + + match self { + NumberFormat::F32 => text + .parse::() + .map(NumberInputValue::F32) + .map_err(|_| NumberInputParseError::InvalidFloat), + NumberFormat::F64 => text + .parse::() + .map(NumberInputValue::F64) + .map_err(|_| NumberInputParseError::InvalidFloat), + NumberFormat::I32 => text + .parse::() + .map(NumberInputValue::I32) + .map_err(|_| NumberInputParseError::InvalidInteger), + NumberFormat::I64 => text + .parse::() + .map(NumberInputValue::I64) + .map_err(|_| NumberInputParseError::InvalidInteger), + } + } +} + +/// Headless numeric text input. +/// +/// This is the numeric-specialized layer on top of [`EditableText`]. The widget keeps the text +/// buffer local to the input entity, but emits typed [`ValueChange`] events for external state +/// management. +/// +/// Add this component to the same entity as [`EditableText`]. A numeric character filter is +/// inserted automatically if the entity does not already have an [`EditableTextFilter`]. +/// +/// ```ignore +/// use bevy_ecs::prelude::*; +/// use bevy_text::{EditableText, TextCursorStyle}; +/// use bevy_ui_widgets::{NumberFormat, NumberInput, SelectAllOnFocus, ValueChange}; +/// +/// commands.spawn(( +/// NumberInput { +/// format: NumberFormat::F32, +/// }, +/// EditableText::new("1.0"), +/// TextCursorStyle::default(), +/// SelectAllOnFocus, +/// )).observe(|change: On>| { +/// info!("new number: {}", change.value); +/// }); +/// ``` +#[derive(Component, Debug, Default, Clone, Copy)] +#[require(EditableText)] +pub struct NumberInput { + /// The numeric type edited by this input. + pub format: NumberFormat, +} + +/// Represents a concrete numeric value for programmatic updates. +#[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 NumberInputValue { + pub(crate) fn emit(self, source: Entity, is_final: bool, commands: &mut Commands) { + match self { + NumberInputValue::F32(value) => { + commands.trigger(ValueChange { + source, + value, + is_final, + }); + } + NumberInputValue::F64(value) => { + commands.trigger(ValueChange { + source, + value, + is_final, + }); + } + NumberInputValue::I32(value) => { + commands.trigger(ValueChange { + source, + value, + is_final, + }); + } + NumberInputValue::I64(value) => { + commands.trigger(ValueChange { + source, + value, + is_final, + }); + } + } + } + + pub(crate) fn as_f32(self) -> f32 { + match self { + NumberInputValue::F32(value) => value, + NumberInputValue::F64(value) => value as f32, + NumberInputValue::I32(value) => value as f32, + NumberInputValue::I64(value) => value as f32, + } + } +} + +impl core::fmt::Display for NumberInputValue { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + NumberInputValue::F32(value) => write!(f, "{value}"), + NumberInputValue::F64(value) => write!(f, "{value}"), + NumberInputValue::I32(value) => write!(f, "{value}"), + NumberInputValue::I64(value) => write!(f, "{value}"), + } + } +} + +/// Programmatically replace the displayed value of a [`NumberInput`]. +/// +/// If the input currently has focus, the update is ignored so it does not interfere with typing. +#[derive(Clone, EntityEvent)] +pub struct SetNumberInputValue { + /// The target input. + #[event_target] + pub entity: Entity, + /// The value to display. + pub value: NumberInputValue, +} + +// Programmatic text refreshes still trigger `TextEditChange` on the next frame. Mark them so the +// next change event is ignored instead of echoed back as a user-originated `ValueChange`. +#[derive(Component, Default)] +pub(crate) struct IgnoreNextNumberInputChange; + +fn number_input_allows_char(c: char) -> bool { + c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E') +} + +fn number_input_on_insert(insert: On, mut world: DeferredWorld) { + if !world.entity(insert.entity).contains::() { + world + .commands() + .entity(insert.entity) + .insert(EditableTextFilter::new(number_input_allows_char)); + } +} + +fn number_input_on_text_change( + change: On, + q_number_input: Query< + ( + &NumberInput, + &EditableText, + Has, + ), + With, + >, + mut commands: Commands, +) { + let Ok((number_input, editable_text, suppress)) = q_number_input.get(change.event_target()) + else { + return; + }; + + if suppress { + commands + .entity(change.event_target()) + .remove::(); + return; + } + + let text_value = editable_text.value().to_string(); + emit_value_change( + &text_value, + number_input.format, + change.event_target(), + false, + &mut commands, + ); +} + +// Single-line numeric inputs do not lose focus on Enter, so treat Enter as an explicit commit. +fn number_input_on_submit_key( + key_input: On>, + q_number_input: Query<(&NumberInput, &EditableText)>, + mut commands: Commands, +) { + if key_input.input.key_code != KeyCode::Enter + || key_input.input.state != ButtonState::Pressed + || key_input.input.repeat + { + return; + } + + let Ok((number_input, editable_text)) = q_number_input.get(key_input.focused_entity) else { + return; + }; + + let text_value = editable_text.value().to_string(); + emit_value_change( + &text_value, + number_input.format, + key_input.focused_entity, + true, + &mut commands, + ); +} + +fn number_input_on_focus_loss( + focus_lost: On, + q_number_input: Query<(&NumberInput, &EditableText)>, + mut commands: Commands, +) { + let Ok((number_input, editable_text)) = q_number_input.get(focus_lost.event_target()) else { + return; + }; + + let text_value = editable_text.value().to_string(); + emit_value_change( + &text_value, + number_input.format, + focus_lost.event_target(), + true, + &mut commands, + ); +} + +fn number_input_on_set_value( + set_value: On, + mut q_number_input: Query<&mut EditableText, With>, + focus: Option>, + mut commands: Commands, +) { + queue_number_input_value_update_if_unfocused( + set_value.event_target(), + set_value.value, + &mut q_number_input, + focus.as_deref(), + &mut commands, + ); +} + +fn emit_value_change( + text_value: &str, + format: NumberFormat, + source: Entity, + is_final: bool, + commands: &mut Commands, +) { + match format.parse(text_value) { + Ok(value) => value.emit(source, is_final, commands), + Err(NumberInputParseError::Empty) => {} + Err(error) => warn!("{error} in text edit"), + } +} + +pub(crate) fn queue_number_input_value_update( + entity: Entity, + value: NumberInputValue, + editable_text: &mut EditableText, + commands: &mut Commands, +) -> bool { + let new_digits = value.to_string(); + let old_digits = editable_text.value().to_string(); + if old_digits == new_digits { + return false; + } + + commands.entity(entity).insert(IgnoreNextNumberInputChange); + editable_text.queue_edit(TextEdit::SelectAll); + editable_text.queue_edit(TextEdit::Insert(new_digits.into())); + true +} + +pub(crate) fn queue_number_input_value_update_if_unfocused( + entity: Entity, + value: NumberInputValue, + q_number_input: &mut Query<&mut EditableText, With>, + focus: Option<&InputFocus>, + commands: &mut Commands, +) -> bool { + if focus.is_some_and(|focus| focus.get() == Some(entity)) { + return false; + } + + let Ok(mut editable_text) = q_number_input.get_mut(entity) else { + return false; + }; + + queue_number_input_value_update(entity, value, &mut editable_text, commands) +} + +/// Plugin that adds observers for [`NumberInput`]. +pub struct NumberInputPlugin; + +impl Plugin for NumberInputPlugin { + fn build(&self, app: &mut App) { + app.add_observer(number_input_on_insert) + .add_observer(number_input_on_text_change) + .add_observer(number_input_on_submit_key) + .add_observer(number_input_on_focus_loss) + .add_observer(number_input_on_set_value); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bevy_app::App; + use bevy_ecs::{observer::On, prelude::*}; + use bevy_input::{ + keyboard::{Key, KeyCode, KeyboardInput}, + ButtonState, InputPlugin, + }; + use bevy_input_focus::{FocusLost, InputDispatchPlugin, InputFocus, InputFocusPlugin}; + use bevy_window::{PrimaryWindow, Window}; + + #[derive(Resource, Default)] + struct NumberInputEvents(Vec<(Entity, f32, bool)>); + + fn keyboard_input(key_code: KeyCode) -> KeyboardInput { + KeyboardInput { + key_code, + logical_key: match key_code { + KeyCode::Enter => Key::Enter, + _ => unreachable!(), + }, + state: ButtonState::Pressed, + text: None, + repeat: false, + window: Entity::PLACEHOLDER, + } + } + + #[test] + fn number_input_parses_values() { + assert_eq!( + NumberFormat::F32.parse("1.5"), + Ok(NumberInputValue::F32(1.5)) + ); + assert_eq!(NumberFormat::I32.parse("-7"), Ok(NumberInputValue::I32(-7))); + assert_eq!( + NumberFormat::I32.parse("1.5"), + Err(NumberInputParseError::InvalidInteger) + ); + assert_eq!( + NumberFormat::I32.parse(" "), + Err(NumberInputParseError::Empty) + ); + } + + #[test] + fn focus_loss_emits_final_value_change() { + let mut app = App::new(); + app.init_resource::() + .add_plugins(NumberInputPlugin) + .add_observer( + |change: On>, mut events: ResMut| { + events + .0 + .push((change.source, change.value, change.is_final)); + }, + ); + + let entity = app + .world_mut() + .spawn((NumberInput::default(), EditableText::new("12.5"))) + .id(); + + app.world_mut().commands().trigger(FocusLost { entity }); + app.update(); + + assert_eq!( + app.world().resource::().0, + vec![(entity, 12.5, true)] + ); + } + + #[test] + fn enter_key_emits_final_value_change() { + let mut app = App::new(); + app.init_resource::() + .add_plugins(( + InputPlugin, + InputFocusPlugin, + InputDispatchPlugin, + NumberInputPlugin, + )) + .add_observer( + |change: On>, mut events: ResMut| { + events + .0 + .push((change.source, change.value, change.is_final)); + }, + ); + app.world_mut().spawn((Window::default(), PrimaryWindow)); + app.update(); + + let entity = app + .world_mut() + .spawn((NumberInput::default(), EditableText::new("7.25"))) + .id(); + app.world_mut() + .insert_resource(InputFocus::from_entity(entity)); + + app.world_mut() + .write_message(keyboard_input(KeyCode::Enter)) + .unwrap(); + app.update(); + + assert_eq!( + app.world().resource::().0, + vec![(entity, 7.25, true)] + ); + } + + #[test] + fn invalid_focus_loss_does_not_emit_value_change() { + let mut app = App::new(); + app.init_resource::() + .add_plugins(NumberInputPlugin) + .add_observer( + |change: On>, mut events: ResMut| { + events + .0 + .push((change.source, change.value, change.is_final)); + }, + ); + + let entity = app + .world_mut() + .spawn((NumberInput::default(), EditableText::new("abc"))) + .id(); + + app.world_mut().commands().trigger(FocusLost { entity }); + app.update(); + + assert!(app.world().resource::().0.is_empty()); + } + + #[test] + fn set_number_input_value_marks_unfocused_input_for_refresh() { + let mut app = App::new(); + app.add_plugins(NumberInputPlugin); + + let entity = app + .world_mut() + .spawn(( + NumberInput { + format: NumberFormat::I32, + }, + EditableText::new("4"), + )) + .id(); + + app.world_mut().commands().trigger(SetNumberInputValue { + entity, + value: NumberInputValue::I32(9), + }); + app.update(); + + assert!(app + .world() + .entity(entity) + .contains::()); + } + + #[test] + fn set_number_input_value_ignores_focused_input() { + let mut app = App::new(); + app.add_plugins(NumberInputPlugin); + + let entity = app + .world_mut() + .spawn(( + NumberInput { + format: NumberFormat::I32, + }, + EditableText::new("4"), + )) + .id(); + app.world_mut() + .insert_resource(InputFocus::from_entity(entity)); + + app.world_mut().commands().trigger(SetNumberInputValue { + entity, + value: NumberInputValue::I32(9), + }); + app.update(); + + assert!(!app + .world() + .entity(entity) + .contains::()); + } +} diff --git a/crates/bevy_ui_widgets/src/ranged_number_input.rs b/crates/bevy_ui_widgets/src/ranged_number_input.rs new file mode 100644 index 0000000000000..32bd363456872 --- /dev/null +++ b/crates/bevy_ui_widgets/src/ranged_number_input.rs @@ -0,0 +1,430 @@ +use bevy_app::{App, Plugin}; +use bevy_ecs::{ + component::Component, + entity::Entity, + event::EntityEvent, + hierarchy::{ChildOf, Children}, + observer::On, + query::{Has, With}, + system::{Commands, Query, Res}, +}; +use bevy_input_focus::InputFocus; +use bevy_picking::events::{Drag, DragEnd, DragStart, Pointer}; +use bevy_text::EditableText; +use bevy_ui::{ComputedNode, InteractionDisabled, Pressed, UiScale}; + +use crate::{ + number_input::{ + queue_number_input_value_update_if_unfocused, NumberFormat, NumberInput, + NumberInputParseError, NumberInputValue, + }, + Slider, SliderDragState, SliderPrecision, SliderRange, SliderValue, ValueChange, +}; + +/// Bounded editor-style numeric input. +/// +/// This is a composite root for a whole-field draggable numeric value. The embedded text editor is +/// marked with [`RangedNumberInputValueInput`]. Dragging emits root-level [`ValueChange`] +/// events; text edits from the embedded [`NumberInput`] are clamped and forwarded from the root. +#[derive(Component, Debug, Default, Clone)] +#[require(Slider)] +pub struct RangedNumberInput; + +/// Marks the embedded numeric text field used by a [`RangedNumberInput`]. +#[derive(Component, Debug, Default, Clone, Copy)] +#[require(NumberInput)] +pub struct RangedNumberInputValueInput; + +/// Programmatically replace the displayed value of a [`RangedNumberInput`]. +/// +/// Like [`crate::SetNumberInputValue`], the embedded text is not overwritten while focused. +#[derive(Clone, EntityEvent)] +pub struct SetRangedNumberInputValue { + /// The target ranged number input. + #[event_target] + pub entity: Entity, + /// The value to display. It is clamped to the input range. + pub value: f32, +} + +fn find_ranged_input_value_input( + root: Entity, + q_children: &Query<&Children>, + q_value_input: &Query<(), With>, +) -> Option { + q_children + .iter_descendants(root) + .find(|child| q_value_input.contains(*child)) +} + +fn find_ranged_input_ancestor( + mut entity: Entity, + q_parent: &Query<&ChildOf>, + q_root: &Query<(), With>, +) -> Option { + loop { + if q_root.contains(entity) { + return Some(entity); + } + entity = q_parent.get(entity).ok()?.parent(); + } +} + +fn parse_embedded_f32( + root: Entity, + q_children: &Query<&Children>, + q_value_input: &Query<(), With>, + q_number_input: &Query<(&NumberInput, &EditableText)>, +) -> Result, NumberInputParseError> { + let Some(input) = find_ranged_input_value_input(root, q_children, q_value_input) else { + return Ok(None); + }; + let Ok((number_input, editable_text)) = q_number_input.get(input) else { + return Ok(None); + }; + number_input + .format + .parse(&editable_text.value().to_string()) + .map(|value| Some(value.as_f32())) +} + +fn ranged_number_input_on_drag_start( + mut drag_start: On>, + mut q_root: Query< + (&SliderValue, &mut SliderDragState, Has), + With, + >, + q_root_marker: Query<(), With>, + q_parent: Query<&ChildOf>, + q_children: Query<&Children>, + q_value_input: Query<(), With>, + q_number_input: Query<(&NumberInput, &EditableText)>, + mut commands: Commands, +) { + let Some(root) = find_ranged_input_ancestor(drag_start.entity, &q_parent, &q_root_marker) + else { + return; + }; + + drag_start.propagate(false); + let Ok((value, mut drag, disabled)) = q_root.get_mut(root) else { + return; + }; + if disabled { + return; + } + + let start_value = match parse_embedded_f32(root, &q_children, &q_value_input, &q_number_input) { + Ok(Some(value)) => value, + Ok(None) => value.0, + Err(_) => return, + }; + + drag.dragging = true; + drag.offset = start_value; + commands.entity(root).insert(Pressed); +} + +fn ranged_number_input_on_drag( + mut drag: On>, + q_root: Query< + ( + &ComputedNode, + &SliderRange, + Option<&SliderPrecision>, + &SliderDragState, + Has, + ), + With, + >, + q_root_marker: Query<(), With>, + q_parent: Query<&ChildOf>, + mut commands: Commands, + ui_scale: Res, +) { + let Some(root) = find_ranged_input_ancestor(drag.entity, &q_parent, &q_root_marker) else { + return; + }; + + drag.propagate(false); + let Ok((node, range, precision, drag_state, disabled)) = q_root.get(root) else { + return; + }; + if !drag_state.dragging || disabled { + return; + } + + emit_ranged_drag_change( + &mut commands, + root, + node, + range, + precision, + drag_state, + drag.distance.x / ui_scale.0, + false, + ); +} + +fn ranged_number_input_on_drag_end( + mut drag_end: On>, + mut q_root: Query< + ( + Entity, + &ComputedNode, + &SliderRange, + Option<&SliderPrecision>, + &mut SliderDragState, + Has, + ), + With, + >, + q_root_marker: Query<(), With>, + q_parent: Query<&ChildOf>, + mut commands: Commands, + ui_scale: Res, +) { + let Some(root) = find_ranged_input_ancestor(drag_end.entity, &q_parent, &q_root_marker) else { + return; + }; + + drag_end.propagate(false); + let Ok((root, node, range, precision, mut drag_state, disabled)) = q_root.get_mut(root) else { + return; + }; + if drag_state.dragging { + if !disabled { + emit_ranged_drag_change( + &mut commands, + root, + node, + range, + precision, + &drag_state, + drag_end.distance.x / ui_scale.0, + true, + ); + } + drag_state.dragging = false; + commands.entity(root).remove::(); + } +} + +fn emit_ranged_drag_change( + commands: &mut Commands, + root: Entity, + node: &ComputedNode, + range: &SliderRange, + precision: Option<&SliderPrecision>, + drag: &SliderDragState, + distance_x: f32, + is_final: bool, +) { + let size = (node.size().x * node.inverse_scale_factor).max(1.0); + let value = if range.span() > 0.0 { + drag.offset + distance_x * range.span() / size + } else { + range.center() + }; + let value = precision.map_or(value, |precision| precision.round(value)); + commands.trigger(ValueChange { + source: root, + value: range.clamp(value), + is_final, + }); +} + +fn ranged_number_input_on_number_change( + change: On>, + q_value_input: Query<(), With>, + q_parent: Query<&ChildOf>, + q_root_marker: Query<(), With>, + q_root: Query<&SliderRange>, + mut commands: Commands, +) { + if !q_value_input.contains(change.source) { + return; + } + + let Some(root) = find_ranged_input_ancestor(change.source, &q_parent, &q_root_marker) else { + return; + }; + let Ok(range) = q_root.get(root) else { + return; + }; + + commands.trigger(ValueChange { + source: root, + value: range.clamp(change.value), + is_final: change.is_final, + }); +} + +fn ranged_number_input_on_root_change( + change: On>, + q_root: Query<&SliderRange, With>, + q_children: Query<&Children>, + q_value_input: Query<(), With>, + mut q_number_input: Query<&mut EditableText, With>, + focus: Option>, + mut commands: Commands, +) { + let Ok(range) = q_root.get(change.source) else { + return; + }; + let value = range.clamp(change.value); + commands.entity(change.source).insert(SliderValue(value)); + + let Some(input) = find_ranged_input_value_input(change.source, &q_children, &q_value_input) + else { + return; + }; + queue_number_input_value_update_if_unfocused( + input, + NumberInputValue::F32(value), + &mut q_number_input, + focus.as_deref(), + &mut commands, + ); +} + +fn ranged_number_input_on_set_value( + set_value: On, + q_root: Query<&SliderRange, With>, + q_children: Query<&Children>, + q_value_input: Query<(), With>, + mut q_number_input: Query<&mut EditableText, With>, + focus: Option>, + mut commands: Commands, +) { + let Ok(range) = q_root.get(set_value.event_target()) else { + return; + }; + let value = range.clamp(set_value.value); + commands + .entity(set_value.event_target()) + .insert(SliderValue(value)); + + let Some(input) = + find_ranged_input_value_input(set_value.event_target(), &q_children, &q_value_input) + else { + return; + }; + queue_number_input_value_update_if_unfocused( + input, + NumberInputValue::F32(value), + &mut q_number_input, + focus.as_deref(), + &mut commands, + ); +} + +fn ranged_number_input_on_insert_value_input( + insert: On, + mut q_input: Query<&mut NumberInput, With>, +) { + if let Ok(mut input) = q_input.get_mut(insert.entity) { + input.format = NumberFormat::F32; + } +} + +/// Plugin that adds observers for [`RangedNumberInput`]. +pub struct RangedNumberInputPlugin; + +impl Plugin for RangedNumberInputPlugin { + fn build(&self, app: &mut App) { + app.add_observer(ranged_number_input_on_insert_value_input) + .add_observer(ranged_number_input_on_drag_start) + .add_observer(ranged_number_input_on_drag) + .add_observer(ranged_number_input_on_drag_end) + .add_observer(ranged_number_input_on_number_change) + .add_observer(ranged_number_input_on_root_change) + .add_observer(ranged_number_input_on_set_value); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bevy_app::App; + use bevy_ecs::{observer::On, prelude::*}; + + #[derive(Resource, Default)] + struct Events(Vec<(Entity, f32, bool)>); + + #[test] + fn text_edits_forward_from_root_and_clamp() { + let mut app = App::new(); + app.init_resource::() + .add_plugins(RangedNumberInputPlugin) + .add_observer(|change: On>, mut events: ResMut| { + events + .0 + .push((change.source, change.value, change.is_final)); + }); + + let root = app + .world_mut() + .spawn((RangedNumberInput, SliderRange::new(0.0, 1.0))) + .id(); + let input = app + .world_mut() + .spawn(( + RangedNumberInputValueInput, + NumberInput::default(), + EditableText::new("2.0"), + ChildOf(root), + )) + .id(); + + app.world_mut().commands().trigger(ValueChange { + source: input, + value: 2.0_f32, + is_final: false, + }); + app.update(); + + assert!(app + .world() + .resource::() + .0 + .contains(&(root, 1.0, false))); + } + + #[test] + fn set_value_updates_slider_value_and_text() { + let mut app = App::new(); + app.add_plugins(RangedNumberInputPlugin); + + let root = app + .world_mut() + .spawn((RangedNumberInput, SliderRange::new(0.0, 1.0))) + .id(); + let input = app + .world_mut() + .spawn(( + RangedNumberInputValueInput, + NumberInput::default(), + EditableText::new("0.0"), + ChildOf(root), + )) + .id(); + + app.world_mut() + .commands() + .trigger(SetRangedNumberInputValue { + entity: root, + value: 3.0, + }); + app.update(); + + assert_eq!( + app.world().entity(root).get::(), + Some(&SliderValue(1.0)) + ); + assert!(app + .world() + .entity(input) + .contains::()); + } +} diff --git a/crates/bevy_ui_widgets/src/slider.rs b/crates/bevy_ui_widgets/src/slider.rs index 5fa9a7ab29529..9316f44d025e9 100644 --- a/crates/bevy_ui_widgets/src/slider.rs +++ b/crates/bevy_ui_widgets/src/slider.rs @@ -228,7 +228,7 @@ impl Default for SliderStep { pub struct SliderPrecision(pub i32); impl SliderPrecision { - fn round(&self, value: f32) -> f32 { + pub(crate) fn round(&self, value: f32) -> f32 { let factor = ops::powf(10.0_f32, self.0 as f32); (value * factor).round() / factor } @@ -242,7 +242,7 @@ pub struct SliderDragState { pub dragging: bool, /// The value of the slider when dragging started. - offset: f32, + pub(crate) offset: f32, } pub(crate) fn slider_on_pointer_down( diff --git a/examples/ui/widgets/feathers_gallery.rs b/examples/ui/widgets/feathers_gallery.rs index 3f6272b58baf8..252efd051da95 100644 --- a/examples/ui/widgets/feathers_gallery.rs +++ b/examples/ui/widgets/feathers_gallery.rs @@ -10,12 +10,13 @@ use bevy::{ }, controls::{ button, checkbox, color_plane, color_slider, color_swatch, disclosure_toggle, menu, - 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, - NumberInputProps, NumberInputValue, RadioProps, SliderBaseColor, SliderProps, - TextInputProps, UpdateNumberInput, + menu_button, menu_divider, menu_item, menu_popup, number_input, radio, + ranged_number_input, slider, text_input, text_input_container, toggle_switch, + tool_button, ButtonProps, ButtonVariant, CheckboxProps, ColorChannel, ColorPlane, + ColorPlaneValue, ColorSlider, ColorSliderProps, ColorSwatch, ColorSwatchValue, + MenuButtonProps, MenuItemProps, NumberInputProps, NumberInputValue, RadioProps, + RangedNumberInputProps, SliderBaseColor, SliderProps, TextInputProps, + UpdateNumberInput, UpdateRangedNumberInput, }, cursor::{EntityCursor, OverrideCursor}, dark_theme::create_dark_theme, @@ -62,6 +63,9 @@ struct DemoDisabledButton; #[derive(Component, Clone, Copy, Default)] struct DemoScalarField; +#[derive(Component, Clone, Copy, Default)] +struct DemoAlphaField; + #[derive(Component, Clone, Copy, Default)] enum DemoVec3Field { #[default] @@ -544,6 +548,20 @@ fn demo_column_1() -> impl Scene { color.rgb_color.alpha = change.value; }) ), + ( + ranged_number_input(RangedNumberInputProps { + value: 0.7, + min: 0.0, + max: 1.0, + precision: 2, + label_text: Some("A"), + ..default() + }) + DemoAlphaField + on(|change: On>, mut color: ResMut| { + color.rgb_color.alpha = change.value; + }) + ), ( Node { display: Display::Flex, @@ -778,6 +796,7 @@ fn update_colors( mut color_planes: Query<&mut ColorPlaneValue, With>, q_text_input: Single<(Entity, &mut EditableText), With>, q_scalar_input: Query>, + q_alpha_input: Query>, q_vec3_input: Query<(Entity, &DemoVec3Field)>, mut commands: Commands, focus: Res, @@ -858,6 +877,13 @@ fn update_colors( }); } + for alpha_input_ent in q_alpha_input.iter() { + commands.trigger(UpdateRangedNumberInput { + entity: alpha_input_ent, + value: states.rgb_color.alpha, + }); + } + for (vec3_input_ent, axis) in q_vec3_input.iter() { let new_value = match axis { DemoVec3Field::X => states.vec3_prop.x,