From 7c6dd529fbabb7a6d8ab130d1f6bd3297e2fe29a Mon Sep 17 00:00:00 2001 From: Talin Date: Mon, 15 Jun 2026 13:27:18 -0700 Subject: [PATCH 1/7] Add scrubbing / dragging to number_input widget. --- Cargo.toml | 12 + .../src/controls/number_input.rs | 1188 ++++++++++++++--- .../bevy_feathers/src/controls/text_input.rs | 9 +- crates/bevy_feathers/src/dark_theme.rs | 2 +- examples/ui/widgets/feathers_gallery.rs | 42 +- examples/ui/widgets/feathers_number_input.rs | 257 ++++ 6 files changed, 1305 insertions(+), 205 deletions(-) create mode 100644 examples/ui/widgets/feathers_number_input.rs diff --git a/Cargo.toml b/Cargo.toml index e6712f2781b61..8737a1c6e02ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5486,6 +5486,18 @@ description = "Gallery of Feathers Widgets" category = "UI (User Interface)" wasm = true +[[example]] +name = "feathers_number_input" +path = "examples/ui/widgets/feathers_number_input.rs" +doc-scrape-examples = true +required-features = ["bevy_feathers"] + +[package.metadata.example.feathers_number_input] +name = "Feathers Number Input" +description = "Feathers Number Input Options" +category = "UI (User Interface)" +wasm = true + [[example]] name = "render_depth_to_texture" path = "examples/shader_advanced/render_depth_to_texture.rs" diff --git a/crates/bevy_feathers/src/controls/number_input.rs b/crates/bevy_feathers/src/controls/number_input.rs index 0dfd6dfbf0b5c..e1f30ac464ad9 100644 --- a/crates/bevy_feathers/src/controls/number_input.rs +++ b/crates/bevy_feathers/src/controls/number_input.rs @@ -1,40 +1,69 @@ +use std::{f32::consts::PI, ops::Range}; + use bevy_app::PropagateOver; +use bevy_color::Color; use bevy_ecs::{ component::Component, entity::Entity, event::EntityEvent, hierarchy::{ChildOf, Children}, + lifecycle::{Add, Insert, Remove}, observer::On, - query::With, - reflect::{ReflectComponent, ReflectEvent}, - relationship::Relationship, - system::{Commands, Query, Res}, + query::{Has, With}, + reflect::ReflectComponent, + system::{Commands, Query, Res, ResMut}, +}; +use bevy_input::{ + keyboard::{Key, KeyCode, KeyboardInput}, + ButtonInput, +}; +use bevy_input_focus::{ + AcquireFocus, FocusCause, FocusGained, FocusLost, FocusedInput, InputFocus, +}; +use bevy_log::{warn, warn_once}; +use bevy_math::ops; +use bevy_picking::{ + events::{Cancel, Drag, DragEnd, DragStart, Pointer, Press, Release}, + hover::Hovered, + pointer::PointerButton, }; -use bevy_input::keyboard::{KeyCode, KeyboardInput}; -use bevy_input_focus::{FocusLost, FocusedInput, InputFocus}; -use bevy_log::warn; use bevy_reflect::std_traits::ReflectDefault; use bevy_reflect::Reflect; use bevy_scene::prelude::*; use bevy_text::{ - EditableText, EditableTextFilter, FontSourceTemplate, TextEdit, TextEditChange, TextFont, + EditableText, EditableTextFilter, FontSourceTemplate, Justify, LineHeight, TextEdit, TextFont, + TextLayout, +}; +use bevy_ui::{ + percent, px, + widget::{Text, TextScroll}, + AlignItems, AlignSelf, BackgroundGradient, ColorStop, ComputedNode, ComputedUiRenderTargetInfo, + Display, Gradient, InteractionDisabled, InterpolationColorSpace, JustifyContent, + LinearGradient, Node, PositionType, UiGlobalTransform, UiRect, UiScale, }; -use bevy_ui::{px, widget::Text, AlignItems, AlignSelf, Display, JustifyContent, Node, UiRect}; -use bevy_ui_widgets::{SelectAllOnFocus, ValueChange}; +use bevy_ui_widgets::ValueChange; +use bevy_window::CursorOptions; use crate::{ constants::{fonts, size}, controls::{FeathersTextInput, FeathersTextInputContainer}, - theme::{ThemeBackgroundColor, ThemeBorderColor, ThemeTextColor, ThemeToken}, + cursor::EntityCursor, + rounded_corners::RoundedCorners, + theme::{ThemeBackgroundColor, ThemeBorderColor, ThemeTextColor, ThemeToken, UiTheme}, tokens, }; +/// Threshold used to distingush between a "click" and a "drag" gesture. +const DRAG_THRESHOLD_DISTANCE: f32 = 0.5; + +const BASE_DRAG_SPEED: f64 = 0.01f64; + /// 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. +/// * it emits values (via a [`ValueChange`]) event as the user types or drags. /// 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 +/// [`NumberInputValue`] component variant. +/// * it listens for the insertion of the [`NumberInputValue`] component, and replaces /// the contents of the text buffer based on the value in that event. /// /// This is spawnable by inheriting it as a "scene component" with optional [`FeathersNumberInputProps`]. @@ -47,13 +76,19 @@ use crate::{ /// synchronize this value with the [`FeathersNumberInput`] 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 +/// because of some other action, insert a [`NumberInputValue`] component to update the /// displayed value. -// TODO: Add text_input field validation when it becomes available. -#[derive(SceneComponent, Default, Clone)] +/// +/// The `is_final` boolean in [`ValueChange`] is set to false while dragging, however you should +/// still update the widget in response to these events, as otherwise the user won't be able to +/// see the updated value. +/// +/// Additional components can be inserted into this widget to customize the behavior: see +/// [`SoftLimit`], [`HardLimit`], [`NumberInputPrecision`], and [`NumberInputStep`]. +#[derive(SceneComponent, Default, Clone, Reflect)] #[scene(FeathersNumberInputProps)] -#[derive(Reflect)] #[reflect(Component, Default, Clone)] +#[require(NumberInputValue)] pub struct FeathersNumberInput; /// Props used to construct a [`FeathersNumberInput`] scene. @@ -64,8 +99,6 @@ pub struct FeathersNumberInputProps { /// 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 FeathersNumberInputProps { @@ -73,7 +106,6 @@ impl Default for FeathersNumberInputProps { Self { sigil_color: tokens::TEXT_INPUT_BG, label_text: None, - number_format: NumberFormat::F32, } } } @@ -82,14 +114,26 @@ impl FeathersNumberInput { fn scene(props: FeathersNumberInputProps) -> impl Scene { bsn! { @FeathersTextInputContainer + Node { + column_gap: px(0), + border: UiRect { + left: {if props.label_text.is_some() { px(3.0) } else { px(0.0) }}, + }, + padding: UiRect { + left: px(0.0), + right: px(0.0), + }, + } ThemeBorderColor({props.sigil_color}) FeathersNumberInput - template_value(props.number_format) - on(number_input_on_update) + on(number_input_on_insert_value) + on(number_input_on_insert_disabled) + on(number_input_on_remove_disabled) Children [ { - match props.label_text { - Some(text) => Box::new(bsn_list!( + // Label section + props.label_text.map(|text| { + bsn_list!( Node { display: Display::Flex, align_items: AlignItems::Center, @@ -107,29 +151,83 @@ impl FeathersNumberInput { PropagateOver ThemeTextColor(tokens::TEXT_INPUT_TEXT) ] - )) as Box, - None => Box::new(bsn_list!()) as Box + ) + }) + }, + + ( + // The editable text entity + @FeathersTextInput { + @max_characters: 20usize, } - } - @FeathersTextInput { - @max_characters: 20usize, - } - 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') - }), + Node { + flex_grow: 1.0, + align_items: AlignItems::Center, + align_self: AlignSelf::Stretch, + border_radius: { + if props.label_text.is_some() { + RoundedCorners::Right.to_border_radius(4.0) + } else { + RoundedCorners::All.to_border_radius(4.0) + } + }, + } + Hovered + EditableTextFilter::new(|c| { + c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E') + }) + template_value(LineHeight::Px(24.0)) // TODO: Make const for this + TextLayout { + justify: Justify::Center, + } + ThemeTextColor(tokens::TEXT_INPUT_TEXT) + // Use a gradient to draw the moving bar, this lets us round corners + BackgroundGradient(vec![Gradient::Linear(LinearGradient { + angle: PI * 0.5, + stops: vec![ + ColorStop::new(Color::WHITE, percent(0)), + ColorStop::new(Color::WHITE, percent(50)), + ColorStop::new(Color::NONE, percent(50)), + ColorStop::new(Color::NONE, percent(100)), + ], + color_space: InterpolationColorSpace::Srgba, + })]) + EntityCursor::System(bevy_window::SystemCursorIcon::ColResize) + on(number_input_init) + on(number_input_on_enter_key) + on(number_input_on_focus_gained) + on(number_input_on_focus_lost) + on(number_input_hovered) + Children [ + ( + // Invisible child on top of input field which intercepts drag + // events (conditionally) and handles scrubbing gestures. + ScrubberDragState + Node { + position_type: PositionType::Absolute, + left: px(0), + top: px(0), + bottom: px(0), + right: px(0), + } + on(scrubber_on_acquire_focus) + on(scrubber_on_press) + on(scrubber_on_release) + on(scrubber_on_drag_start) + on(scrubber_on_drag) + on(scrubber_on_drag_end) + on(scrubber_on_drag_cancel) + ), + ] + ), ] } } } -/// Used to indicate what format of numbers we are editing. This primarily affects the type +/// Used to indicate what format of numbers we are editing. This affects the type /// of [`ValueChange`] event that is emitted. -#[derive(Component, Default, Clone, Copy, Reflect)] -#[reflect(Component, Default, Clone)] +#[derive(Default, Clone, Copy, Reflect)] pub enum NumberFormat { /// A 32-bit float #[default] @@ -143,7 +241,8 @@ pub enum NumberFormat { } /// Represents numbers in different formats. -#[derive(Debug, PartialEq, Clone, Copy, Reflect)] +#[derive(Component, Debug, PartialEq, Clone, Copy, Reflect)] +#[component(immutable)] pub enum NumberInputValue { /// An `f32` value F32(f32), @@ -166,74 +265,437 @@ impl core::fmt::Display for NumberInputValue { } } -/// Event which can be sent to the number input widget to update the displayed value. -#[derive(Clone, EntityEvent, Reflect)] -#[reflect(Event, Clone)] -pub struct UpdateNumberInput { - /// Target widget - pub entity: Entity, +impl NumberInputValue { + fn format(&self) -> NumberFormat { + match self { + Self::F32(_) => NumberFormat::F32, + Self::F64(_) => NumberFormat::F64, + Self::I32(_) => NumberFormat::I32, + Self::I64(_) => NumberFormat::I64, + } + } - /// Value to change to - pub value: NumberInputValue, + fn parse_from(value: String, fmt: NumberFormat) -> Result { + match fmt { + NumberFormat::F32 => value + .parse::() + .map(NumberInputValue::F32) + .map_err(|_| format!("Could not parse '{}' as f32", value)), + NumberFormat::F64 => value + .parse::() + .map(NumberInputValue::F64) + .map_err(|_| format!("Could not parse '{}' as f64", value)), + NumberFormat::I32 => value + .parse::() + .map(NumberInputValue::I32) + .map_err(|_| format!("Could not parse '{}' as i32", value)), + NumberFormat::I64 => value + .parse::() + .map(NumberInputValue::I64) + .map_err(|_| format!("Could not parse '{}' as i64", value)), + } + } + + /// Offset this value by `delta` (in value units), preserving the variant. + fn offset_by(self, delta: f64) -> Self { + match self { + NumberInputValue::F32(v) => NumberInputValue::F32(v + delta as f32), + NumberInputValue::F64(v) => NumberInputValue::F64(v + delta), + NumberInputValue::I32(v) => { + NumberInputValue::I32(v.saturating_add(delta.round() as i32)) + } + NumberInputValue::I64(v) => { + NumberInputValue::I64(v.saturating_add(delta.round() as i64)) + } + } + } + + fn as_f64(&self) -> f64 { + match *self { + NumberInputValue::F32(v) => v as f64, + NumberInputValue::F64(v) => v, + NumberInputValue::I32(v) => v as f64, + NumberInputValue::I64(v) => v as f64, + } + } } -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, +impl Default for NumberInputValue { + fn default() -> Self { + Self::F32(0.0) + } +} + +/// Represents numeric limits in different number formats. +#[derive(Debug, PartialEq, Clone, Reflect)] +pub enum NumberInputRange { + /// An 'f32' range. + F32(Range), + /// An 'f64' range. + F64(Range), + /// An 'i32' range. + I32(Range), + /// An 'i64' range. + I64(Range), +} + +impl NumberInputRange { + /// Clamp a numeric value of varying type to be within this range. + pub fn clamp(&self, n: NumberInputValue) -> NumberInputValue { + match (self, n) { + (Self::F32(r), NumberInputValue::F32(v)) => { + NumberInputValue::F32(v.clamp(r.start, r.end)) + } + (Self::F64(r), NumberInputValue::F64(v)) => { + NumberInputValue::F64(v.clamp(r.start, r.end)) + } + (Self::I32(r), NumberInputValue::I32(v)) => { + NumberInputValue::I32(v.clamp(r.start, r.end)) + } + (Self::I64(r), NumberInputValue::I64(v)) => { + NumberInputValue::I64(v.clamp(r.start, r.end)) + } + (range, value) => { + warn_once!("Number input range type mismatch: {range:?} {value:?}"); + n + } + } + } + + /// Compute the position of the thumb on the slide bar, as a value between 0 and 1, taking + /// into account the proportion of the value between the minimum and maximum limits. + pub fn thumb_position(&self, value: NumberInputValue) -> f32 { + match (self, value) { + (Self::F32(range), NumberInputValue::F32(n)) => { + if range.end > range.start { + (n - range.start) / (range.end - range.start) + } else { + 0.5 + } + } + + (Self::F64(range), NumberInputValue::F64(n)) => { + if range.end > range.start { + ((n - range.start) / (range.end - range.start)) as f32 + } else { + 0.5 + } + } + + (Self::I32(range), NumberInputValue::I32(n)) => { + if range.end > range.start { + (n - range.start) as f32 / (range.end - range.start) as f32 + } else { + 0.5 + } + } + + (Self::I64(range), NumberInputValue::I64(n)) => { + if range.end > range.start { + (n - range.start) as f32 / (range.end - range.start) as f32 + } else { + 0.5 + } + } + + (range, value) => { + warn_once!("Number input range type mismatch: {range:?} {value:?}"); + 0.5 + } + } + } +} + +impl Default for NumberInputRange { + fn default() -> Self { + Self::F32(0.0..0.0) + } +} + +/// A soft limit represents the range of values that can be reached via dragging. Values outside +/// this range can still be entered by typing. +#[derive(Component, Default, Clone, Reflect)] +pub struct SoftLimit(pub NumberInputRange); + +impl SoftLimit { + /// Create a [`SoftLimit`] for `f32` values. + pub fn f32(range: Range) -> Self { + Self(NumberInputRange::F32(range)) + } + + /// Create a [`SoftLimit`] for `f64` values. + pub fn f64(range: Range) -> Self { + Self(NumberInputRange::F64(range)) + } + + /// Create a [`SoftLimit`] for `i32` values. + pub fn i32(range: Range) -> Self { + Self(NumberInputRange::I32(range)) + } + + /// Create a [`SoftLimit`] for `i64` values. + pub fn i64(range: Range) -> Self { + Self(NumberInputRange::I64(range)) + } +} + +/// A hard limit represents an absolute constraint on the value. Values outside this range will +/// be clamped within the range. +// Note: Similar in concept to `SliderRange`, but the latter only handles f32s. +#[derive(Component, Default, Clone, Reflect)] +pub struct HardLimit(pub NumberInputRange); + +impl HardLimit { + /// Create a [`HardLimit`] for `f32` values. + pub fn f32(range: Range) -> Self { + Self(NumberInputRange::F32(range)) + } + + /// Create a [`HardLimit`] for `f64` values. + pub fn f64(range: Range) -> Self { + Self(NumberInputRange::F64(range)) + } + + /// Create a [`HardLimit`] for `i32` values. + pub fn i32(range: Range) -> Self { + Self(NumberInputRange::I32(range)) + } + + /// Create a [`HardLimit`] for `i64` values. + pub fn i64(range: Range) -> Self { + Self(NumberInputRange::I64(range)) + } +} + +/// A component which controls the rounding of the number value during dragging. This is also used +/// as a heuristic to determine drag speed when there is no soft limit or step size specified. +/// +/// Stepping is not affected, although presumably the step size will be an integer multiple of the +/// rounding factor. This also doesn't prevent the edited value from being set to non-rounded values +/// by other means, such as manually entering digits via a numeric input field. +/// +/// The value in this component represents the number of decimal places of desired precision, so a +/// value of 2 would round to the nearest 1/100th. A value of -3 would round to the nearest +/// thousand. +#[derive(Component, Debug, Clone, Copy, Reflect)] +#[reflect(Component, Default)] +pub struct NumberInputPrecision(pub i32); + +impl NumberInputPrecision { + fn round_f32(&self, value: f32) -> f32 { + let factor = ops::powf(10.0_f32, self.0 as f32); + (value * factor).round() / factor + } + + fn round_f64(&self, value: f64) -> f64 { + let factor = f64::powf(10.0_f64, self.0 as f64); + (value * factor).round() / factor + } + + fn round(&self, value: NumberInputValue) -> NumberInputValue { + match value { + NumberInputValue::F32(v) => NumberInputValue::F32(self.round_f32(v)), + NumberInputValue::F64(v) => NumberInputValue::F64(self.round_f64(v)), + // Decimal-place rounding only affects integers at negative precision + // (round to 10/100/...); left as identity for now. + other => other, + } + } +} + +impl Default for NumberInputPrecision { + fn default() -> Self { + Self(2) + } +} + +/// A component which controls the step size when incrementing or decrementing the value. +/// This also is used as a heuristic to determine drag speed when there is no soft limit present. +#[derive(Component, Debug, Clone, Copy, Reflect)] +#[reflect(Component, Default)] +pub struct NumberInputStep(pub f64); + +impl Default for NumberInputStep { + fn default() -> Self { + Self(1.0f64) + } +} + +/// Component used to manage the state of a number during dragging ("scrubbing"). +#[derive(Component, Default, Clone, Reflect)] +#[reflect(Component)] +struct ScrubberDragState { + /// Whether the input is currently being dragged. + dragging: bool, + + /// Conversion factor from pixels dragged to value + drag_speed: f64, + + /// Similar to drag distance in the drag event, but includes scaling caused by modifier keys. + value_offset: f64, + + /// The maximum absolute distance during the drag - used to detect click vs drag gesture. + max_distance: f32, + + /// The value of the input when dragging started. + base_value: NumberInputValue, +} + +/// Observer which sets the text content of the field when the number value component changes. +fn number_input_on_insert_value( + update: On, + q_children: Query<&Children>, + q_number_input: Query<(&NumberInputValue, Option<&SoftLimit>), With>, + mut q_text_input: Query<(&mut EditableText, &mut BackgroundGradient)>, ) { - let Ok(parent) = q_parent.get(change.event_target()) else { - return; - }; + let text_input_id = q_children + .iter_descendants(update.event_target()) + .find(|e| q_text_input.contains(*e)); - let Ok(number_format) = q_number_input.get(parent.get()) else { - return; - }; + if let Ok((input_value, limit)) = q_number_input.get(update.event_target()) + && let Some(text_id) = text_input_id + { + let (mut editable_text, mut gradient) = q_text_input.get_mut(text_id).unwrap(); + let new_digits = input_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())); + } - let Ok(editable_text) = q_text_input.get(change.event_target()) else { - return; - }; + update_slider_pos(input_value, limit, &mut gradient); + } +} - let text_value = editable_text.value().to_string(); - emit_value_change(text_value, *number_format, parent.0, &mut commands, false); +/// Observer changes the colors based on disabled status. +fn number_input_on_insert_disabled( + insert: On, + q_children: Query<&Children>, + q_number_input: Query, With>, + mut q_text_input: Query<(&Hovered, &mut BackgroundGradient)>, + theme: Res, + mut commands: Commands, +) { + let text_input_id = q_children + .iter_descendants(insert.event_target()) + .find(|e| q_text_input.contains(*e)); + + if let Some(text_id) = text_input_id + && let Ok((&Hovered(hovered), mut gradient)) = q_text_input.get_mut(text_id) + && let Ok(is_disabled) = q_number_input.get(insert.event_target()) + { + set_slidebar_styles( + text_id, + &theme, + is_disabled, + false, + hovered, + &mut gradient, + &mut commands, + ); + } } -fn number_input_on_update( - update: On, +/// Observer changes the colors based on disabled status. +fn number_input_on_remove_disabled( + remove: On, q_children: Query<&Children>, - q_number_input: Query<(), With>, - mut q_text_input: Query<&mut EditableText>, - focus: Res, + q_number_input: Query, With>, + mut q_text_input: Query<(&Hovered, &mut BackgroundGradient)>, + theme: Res, + mut commands: Commands, ) { - if !q_number_input.contains(update.event_target()) { - return; - }; + let text_input_id = q_children + .iter_descendants(remove.event_target()) + .find(|e| q_text_input.contains(*e)); - let Ok(children) = q_children.get(update.event_target()) else { - return; - }; + if let Some(text_id) = text_input_id + && let Ok((&Hovered(hovered), mut gradient)) = q_text_input.get_mut(text_id) + && let Ok(is_disabled) = q_number_input.get(remove.event_target()) + { + set_slidebar_styles( + text_id, + &theme, + is_disabled, + false, + hovered, + &mut gradient, + &mut commands, + ); + } +} - 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; +/// Observer which initializes the text edit once it has completed spawning. +fn number_input_init( + insert: On, + q_parent: Query<&ChildOf>, + q_number_input: Query< + ( + &NumberInputValue, + Option<&SoftLimit>, + Has, + ), + With, + >, + mut q_text_input: Query<(&mut EditableText, &Hovered, &mut BackgroundGradient)>, + theme: Res, + mut commands: Commands, +) { + let text_id = insert.event_target(); + if let Ok((mut editable_text, &Hovered(hovered), mut gradient)) = q_text_input.get_mut(text_id) + && let Ok(&ChildOf(root_id)) = q_parent.get(text_id) + && let Ok((input_value, limit, is_disabled)) = q_number_input.get(root_id) + { + let new_digits = input_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())); } + + update_slider_pos(input_value, limit, &mut gradient); + set_slidebar_styles( + text_id, + &theme, + is_disabled, + false, + hovered, + &mut gradient, + &mut commands, + ); + } +} + +/// Observer which looks for changes in the hover state. +fn number_input_hovered( + insert: On, + q_parent: Query<&ChildOf>, + q_number_input: Query, With>, + mut q_text_input: Query<(&Hovered, &mut BackgroundGradient)>, + theme: Res, + mut commands: Commands, +) { + let text_id = insert.event_target(); + if let Ok((&Hovered(hovered), mut gradient)) = q_text_input.get_mut(text_id) + && let Ok(&ChildOf(root_id)) = q_parent.get(text_id) + && let Ok(is_disabled) = q_number_input.get(root_id) + { + set_slidebar_styles( + text_id, + &theme, + is_disabled, + false, + hovered, + &mut gradient, + &mut commands, + ); } } fn number_input_on_enter_key( key_input: On>, q_parent: Query<&ChildOf>, - q_number_input: Query<&NumberFormat, With>, + q_number_input: Query<(&NumberInputValue, Option<&HardLimit>), With>, q_text_input: Query<&EditableText>, mut commands: Commands, ) { @@ -241,51 +703,445 @@ fn number_input_on_enter_key( 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; - }; + if let Ok(&ChildOf(root)) = q_parent.get(key_input.event_target()) + && let Ok((input_value, hard_limit)) = q_number_input.get(root) + && let Ok(editable_text) = q_text_input.get(key_input.event_target()) + { + let text_value = editable_text.value().to_string(); + emit_value_change( + text_value, + input_value.format(), + root, + hard_limit, + &mut commands, + true, + ); + } +} - 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_gained(focus_gained: On, mut commands: Commands) { + // Change cursor to I-Beam. + let editable_text_id = focus_gained.event_target(); + commands + .entity(editable_text_id) + .insert(EntityCursor::System(bevy_window::SystemCursorIcon::Text)); } -fn number_input_on_focus_loss( +fn number_input_on_focus_lost( focus_lost: On, q_parent: Query<&ChildOf>, - q_number_input: Query<&NumberFormat, With>, + q_number_input: Query<(&NumberInputValue, Option<&HardLimit>), 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; - }; + if let Ok(&ChildOf(root)) = q_parent.get(editable_text_id) + && let Ok((input_value, hard_limit)) = q_number_input.get(root) + && let Ok(editable_text) = q_text_input.get_mut(editable_text_id) + { + let text_value = editable_text.value().to_string(); + emit_value_change( + text_value, + input_value.format(), + root, + hard_limit, + &mut commands, + true, + ); - let Ok(number_format) = q_number_input.get(parent.get()) else { - return; + // Restore cursor back to normal. + commands + .entity(editable_text_id) + .insert(EntityCursor::System( + bevy_window::SystemCursorIcon::ColResize, + )); + } +} + +/// Suppress the standard "click to focus" behavior, we want to handle this ourselves (focus +/// happens on release rather than press so we can detect drags). +fn scrubber_on_acquire_focus(mut acquire_focus: On) { + acquire_focus.propagate(false); +} + +fn scrubber_on_press( + mut press: On>, + mut q_scrubber: Query<&mut ScrubberDragState>, + q_parent: Query<&ChildOf>, + mut focus: ResMut, +) { + if let Ok(&ChildOf(text_id)) = q_parent.get(press.event_target()) + && let Ok(mut drag_state) = q_scrubber.get_mut(press.entity) + { + drag_state.max_distance = 0.0; + // If the text input has focus, then allow the press event to go through + if focus.get() != Some(text_id) { + // If some other widget has focus, clear it. + focus.clear(); + press.propagate(false); + } + } +} + +fn scrubber_on_release( + mut release: On>, + mut q_text: Query<( + &mut EditableText, + &ComputedNode, + &ComputedUiRenderTargetInfo, + &UiGlobalTransform, + &TextScroll, + )>, + q_scrubber: Query<(&ComputedNode, &UiGlobalTransform, &mut ScrubberDragState)>, + q_parent: Query<&ChildOf>, + mut input_focus: ResMut, + ui_scale: Res, +) { + if let Ok(&ChildOf(text_id)) = q_parent.get(release.event_target()) + && let Ok((mut editable_text, node, target, transform, text_scroll)) = + q_text.get_mut(text_id) + && let Ok((_, _, drag_state)) = q_scrubber.get(release.entity) + { + // If editable text has focus, then pass the event through. + if input_focus.get() == Some(text_id) { + return; + } + + release.propagate(false); + + // Copy of logic from EditableText / text_input, but done on pointer up instead of down. + if drag_state.max_distance <= DRAG_THRESHOLD_DISTANCE { + if release.button != PointerButton::Primary { + return; + } + + if editable_text.is_composing() { + // The IME is active; all input needs to be routed there, including pointer presses. + return; + } + + let Some(local_pos) = transform.try_inverse().map(|inverse| { + inverse.transform_point2( + release.pointer_location.position * target.scale_factor() / ui_scale.0, + ) - node.content_box().min + + text_scroll.0 + }) else { + return; + }; + + editable_text.queue_edit(TextEdit::MoveToPoint(local_pos)); + input_focus.set(text_id, FocusCause::Pressed); + } + } +} + +fn scrubber_on_drag_start( + mut drag_start: On>, + q_root: Query<( + &NumberInputValue, + Option<&SoftLimit>, + Option<&NumberInputPrecision>, + Option<&NumberInputStep>, + Has, + )>, + mut q_text_input: Query<&mut BackgroundGradient>, + mut q_scrubber: Query<(&ComputedNode, &mut ScrubberDragState)>, + q_parent: Query<&ChildOf>, + mut q_cursor_options: Query<&mut CursorOptions>, + focus: Res, + theme: Res, + mut commands: Commands, +) { + if let Ok(&ChildOf(text_id)) = q_parent.get(drag_start.event_target()) + && let Ok(&ChildOf(root_id)) = q_parent.get(text_id) + && let Ok((input_value, soft_limit, precision, step, disabled)) = q_root.get(root_id) + && let Ok(mut gradient) = q_text_input.get_mut(text_id) + && !disabled + && focus.get() != Some(text_id) + && let Ok((node, mut drag)) = q_scrubber.get_mut(drag_start.event_target()) + { + let slider_size = (node.size().x * node.inverse_scale_factor).max(1.0) as f64; + drag_start.propagate(false); + drag.dragging = true; + drag.base_value = *input_value; + drag.max_distance = 0.0; + drag.value_offset = 0.0f64; + // Use various heuristics to determine drag speed based on which components are present. + drag.drag_speed = if let Some(SoftLimit(nrange)) = soft_limit { + match nrange { + NumberInputRange::F32(range) => (range.end - range.start) as f64 / slider_size, + NumberInputRange::F64(range) => (range.end - range.start) / slider_size, + NumberInputRange::I32(range) => (range.end - range.start) as f64 / slider_size, + NumberInputRange::I64(range) => (range.end - range.start) as f64 / slider_size, + } + } else if let Some(NumberInputStep(step)) = step { + *step * BASE_DRAG_SPEED + } else if matches!(input_value.format(), NumberFormat::I32 | NumberFormat::I64) { + // Treat integers as having a step size of 1 + BASE_DRAG_SPEED + } else if let Some(prec) = precision { + // Derive from precision + 10.0_f64.powf(-(prec.0 as f64)) + } else { + // No clues present, so we'll have to guess. Use an adaptive algorithm based on + // present value; this determines the nearest power of 10 to the current magnitude. + let m = input_value.as_f64().abs(); + let decade = if m >= 1.0 { m.log10().floor() } else { 0.0 }; + BASE_DRAG_SPEED * 10f64.powf(decade) + }; + + set_slidebar_styles( + text_id, + &theme, + disabled, + true, + false, + &mut gradient, + &mut commands, + ); + + // Only hide the cursor if there's no slide bar. + if soft_limit.is_none() { + // TODO: Can simplify this by only changing the window that this ui element is on, + // but I've forgotten how to get the window from a computed node. + for mut options in q_cursor_options.iter_mut() { + options.visible = false; + } + } + } +} + +fn scrubber_on_drag( + mut drag: On>, + q_root: Query<( + Option<&SoftLimit>, + Option<&HardLimit>, + Option<&NumberInputPrecision>, + Has, + )>, + mut q_scrubber: Query<(&UiGlobalTransform, &mut ScrubberDragState)>, + q_parent: Query<&ChildOf>, + focus: Res, + mut commands: Commands, + ui_scale: Res, + keys: Res>, +) { + if let Ok(&ChildOf(text_id)) = q_parent.get(drag.event_target()) + && focus.get() != Some(text_id) + && let Ok(&ChildOf(root_id)) = q_parent.get(text_id) + && let Ok((soft_limit, hard_limit, precision, disabled)) = q_root.get(root_id) + && let Ok((transform, mut drag_state)) = q_scrubber.get_mut(drag.entity) + { + drag_state.max_distance = drag_state.max_distance.max(drag.distance.length()); + drag.propagate(false); + if drag_state.dragging && !disabled && drag_state.max_distance > DRAG_THRESHOLD_DISTANCE { + let drag_delta = transform.transform_vector2(drag.delta / ui_scale.0).x; + let mut delta = drag_delta as f64 * drag_state.drag_speed; + if keys.pressed(Key::Shift) { + delta *= 0.1; + } + drag_state.value_offset += delta; + emit_drag_value_change( + &mut commands, + root_id, + soft_limit, + hard_limit, + precision, + &mut drag_state, + false, + ); + } + } +} + +fn scrubber_on_drag_end( + mut drag_end: On>, + q_root: Query<( + Option<&SoftLimit>, + Option<&HardLimit>, + Option<&NumberInputPrecision>, + Has, + )>, + mut q_text_input: Query<(&Hovered, &mut BackgroundGradient)>, + mut q_scrubber: Query<&mut ScrubberDragState>, + q_parent: Query<&ChildOf>, + mut q_cursor_options: Query<&mut CursorOptions>, + focus: Res, + mut commands: Commands, + theme: Res, +) { + for mut options in q_cursor_options.iter_mut() { + options.visible = true; + } + + if let Ok(&ChildOf(text_id)) = q_parent.get(drag_end.event_target()) + && focus.get() != Some(text_id) + && let Ok(&ChildOf(root_id)) = q_parent.get(text_id) + && let Ok((soft_limit, hard_limit, precision, disabled)) = q_root.get(root_id) + && let Ok(mut drag_state) = q_scrubber.get_mut(drag_end.entity) + && let Ok((&Hovered(hovered), mut gradient)) = q_text_input.get_mut(text_id) + { + drag_end.propagate(false); + if drag_state.dragging { + if !disabled { + emit_drag_value_change( + &mut commands, + root_id, + soft_limit, + hard_limit, + precision, + &mut drag_state, + true, + ); + } + set_slidebar_styles( + text_id, + &theme, + disabled, + false, + hovered, + &mut gradient, + &mut commands, + ); + drag_state.dragging = false; + } + } +} + +fn scrubber_on_drag_cancel( + mut drag_cancel: On>, + q_parent: Query<&ChildOf>, + mut q_text_input: Query<(&Hovered, &mut BackgroundGradient)>, + mut q_scrubber: Query<&mut ScrubberDragState>, + mut q_cursor_options: Query<&mut CursorOptions>, + theme: Res, + mut commands: Commands, +) { + for mut options in q_cursor_options.iter_mut() { + options.visible = true; + } + + if let Ok(&ChildOf(text_id)) = q_parent.get(drag_cancel.event_target()) + && let Ok(mut drag_state) = q_scrubber.get_mut(drag_cancel.entity) + && let Ok((&Hovered(hovered), mut gradient)) = q_text_input.get_mut(text_id) + { + set_slidebar_styles( + text_id, + &theme, + false, + false, + hovered, + &mut gradient, + &mut commands, + ); + drag_cancel.propagate(false); + drag_state.dragging = false; + } +} + +fn update_slider_pos( + input_value: &NumberInputValue, + limit: Option<&SoftLimit>, + gradient: &mut BackgroundGradient, +) { + if let [Gradient::Linear(linear_gradient)] = &mut gradient.0[..] { + let percent_value = if let Some(SoftLimit(range)) = limit { + (range.thumb_position(*input_value) * 100.0).clamp(0.0, 100.0) + } else { + // If there's no soft limit, then don't show the slide bar. + 0.0 + }; + linear_gradient.stops[1].point = percent(percent_value); + linear_gradient.stops[2].point = percent(percent_value); + } +} + +fn set_slidebar_styles( + slidebar_id: Entity, + theme: &UiTheme, + disabled: bool, + pressed: bool, + hovered: bool, + gradient: &mut BackgroundGradient, + commands: &mut Commands, +) { + let bar_color = theme.color(&if disabled { + tokens::SLIDER_BAR_DISABLED + } else if pressed { + tokens::SLIDER_BAR_PRESSED + } else if hovered { + tokens::SLIDER_BAR_HOVER + } else { + tokens::SLIDER_BAR + }); + + let bg_color = theme.color(&if disabled { + tokens::SLIDER_BG_DISABLED + } else if pressed { + tokens::SLIDER_BG_PRESSED + } else if hovered { + tokens::SLIDER_BG_HOVER + } else { + tokens::TEXT_INPUT_BG + }); + + let font_color_token = match disabled { + true => tokens::TEXT_INPUT_TEXT_DISABLED, + false => tokens::TEXT_INPUT_TEXT, }; - let Ok(editable_text) = q_text_input.get_mut(editable_text_id) else { - return; + let cursor_shape = match disabled { + true => bevy_window::SystemCursorIcon::NotAllowed, + false => bevy_window::SystemCursorIcon::EwResize, }; - let text_value = editable_text.value().to_string(); - emit_value_change(text_value, *number_format, parent.0, &mut commands, true); + if let [Gradient::Linear(linear_gradient)] = &mut gradient.0[..] { + linear_gradient.stops[0].color = bar_color; + linear_gradient.stops[1].color = bar_color; + linear_gradient.stops[2].color = bg_color; + linear_gradient.stops[3].color = bg_color; + } + + // Change cursor shape and text color + commands + .entity(slidebar_id) + .insert(EntityCursor::System(cursor_shape)) + .insert(ThemeTextColor(font_color_token)); +} + +fn emit_drag_value_change( + commands: &mut Commands, + source: Entity, + soft_limit: Option<&SoftLimit>, + hard_limit: Option<&HardLimit>, + precision: Option<&NumberInputPrecision>, + drag_state: &mut ScrubberDragState, + is_final: bool, +) { + // Relative scrub: always measured from the value at drag start. + let mut value = drag_state.base_value.offset_by(drag_state.value_offset); + + // Dragging is confined to the soft range; typing can still exceed it. + if let Some(SoftLimit(range)) = soft_limit { + value = range.clamp(value); + } + if let Some(precision) = precision { + value = precision.round(value); + } + // Hard limit is absolute and always applied last. + if let Some(HardLimit(range)) = hard_limit { + value = range.clamp(value); + } + + trigger_value_change(commands, value, source, is_final); } fn emit_value_change( text_value: String, format: NumberFormat, source: Entity, + hard_limit: Option<&HardLimit>, commands: &mut Commands, is_final: bool, ) { @@ -294,66 +1150,48 @@ fn emit_value_change( 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"); - } - } - } + let Ok(new_value) = NumberInputValue::parse_from(text_value.to_owned(), format) else { + // TODO: should handle errors better than this + warn!("number input parsing failed, invalid format"); + return; + }; + + let clamped_value = match hard_limit { + Some(limit) => limit.0.clamp(new_value), + None => new_value, + }; + + trigger_value_change(commands, clamped_value, source, is_final); +} + +/// Decompose the input value enum and trigger a [`ValueChange`] with the appropriate generic +/// parameter type based on the enum variant. +fn trigger_value_change( + commands: &mut Commands, + value: NumberInputValue, + source: Entity, + is_final: bool, +) { + match value { + 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, + }), } } diff --git a/crates/bevy_feathers/src/controls/text_input.rs b/crates/bevy_feathers/src/controls/text_input.rs index 9c6048e8e58e8..81f969f7c820f 100644 --- a/crates/bevy_feathers/src/controls/text_input.rs +++ b/crates/bevy_feathers/src/controls/text_input.rs @@ -27,7 +27,7 @@ use crate::{ cursor::EntityCursor, focus::FocusWithinIndicator, font_styles::InheritableFont, - theme::{InheritableThemeTextColor, ThemeBackgroundColor, UiTheme}, + theme::{InheritableThemeTextColor, ThemeBackgroundColor, ThemedText, UiTheme}, tokens, }; @@ -49,9 +49,7 @@ impl FeathersTextInputContainer { align_items: AlignItems::Center, padding: UiRect { right: px(3.0), - }, - border: UiRect { - left: px(3.0) + left: px(3.0), }, flex_grow: 1.0, border_radius: {BorderRadius::all(px(4.0))}, @@ -75,7 +73,7 @@ impl FeathersTextInputContainer { /// This is spawnable by inheriting it as a "scene component" with optional [`FeathersTextInputProps`]. /// /// ```ignore -/// :FeathersTextInputContainer +/// @FeathersTextInputContainer /// Children [ /// :FeathersTextInput /// ] @@ -113,6 +111,7 @@ impl FeathersTextInput { visible_width: {props.visible_width}, max_characters: {props.max_characters}, } + ThemedText TextLayout { linebreak: LineBreak::NoWrap, } diff --git a/crates/bevy_feathers/src/dark_theme.rs b/crates/bevy_feathers/src/dark_theme.rs index e08bebc620a8b..9b9dec6604a40 100644 --- a/crates/bevy_feathers/src/dark_theme.rs +++ b/crates/bevy_feathers/src/dark_theme.rs @@ -49,7 +49,7 @@ pub fn create_dark_theme() -> ThemeProps { (tokens::SLIDER_BG_DISABLED, palette::GRAY_1), (tokens::SLIDER_BAR, palette::ACCENT), (tokens::SLIDER_BAR_HOVER, palette::ACCENT.lighter(0.05)), - (tokens::SLIDER_BAR_PRESSED, palette::ACCENT.lighter(0.1)), + (tokens::SLIDER_BAR_PRESSED, palette::ACCENT.lighter(0.06)), (tokens::SLIDER_BAR_DISABLED, palette::GRAY_2), (tokens::SLIDER_TEXT, palette::WHITE), (tokens::SLIDER_TEXT_DISABLED, palette::WHITE.with_alpha(0.5)), diff --git a/examples/ui/widgets/feathers_gallery.rs b/examples/ui/widgets/feathers_gallery.rs index 30fba98788052..30c1c6da30d05 100644 --- a/examples/ui/widgets/feathers_gallery.rs +++ b/examples/ui/widgets/feathers_gallery.rs @@ -625,6 +625,8 @@ fn demo_column_2() -> impl Scene { ( @FeathersNumberInput DemoScalarField + NumberInputPrecision(2) + HardLimit::f32(0.0..100.0) Node { flex_grow: 1.0, max_width: px(100), @@ -632,15 +634,14 @@ fn demo_column_2() -> impl Scene { on( |value_change: On>, mut states: ResMut| { - if value_change.is_final { - states.scalar_prop = value_change.value; - } + states.scalar_prop = value_change.value; }) ), label_small("Scalar property (copy)"), ( @FeathersNumberInput DemoScalarField + NumberInputPrecision(4) Node { flex_grow: 1.0, max_width: px(100), @@ -648,9 +649,7 @@ fn demo_column_2() -> impl Scene { on( |value_change: On>, mut states: ResMut| { - if value_change.is_final { - states.scalar_prop = value_change.value; - } + states.scalar_prop = value_change.value; }) ), label_small("Vec3 property"), @@ -667,6 +666,7 @@ fn demo_column_2() -> impl Scene { @sigil_color: tokens::TEXT_INPUT_X_AXIS, @label_text: "X", } + NumberInputPrecision(2) DemoVec3Field::X Node { flex_grow: 1.0, @@ -675,9 +675,7 @@ fn demo_column_2() -> impl Scene { on( |value_change: On>, mut states: ResMut| { - if value_change.is_final { - states.vec3_prop.x = value_change.value; - } + states.vec3_prop.x = value_change.value; }) ), ( @@ -685,6 +683,7 @@ fn demo_column_2() -> impl Scene { @sigil_color: tokens::TEXT_INPUT_Y_AXIS, @label_text: "Y", } + NumberInputPrecision(2) DemoVec3Field::Y Node { flex_grow: 1.0, @@ -692,9 +691,7 @@ fn demo_column_2() -> impl Scene { on( |value_change: On>, mut states: ResMut| { - if value_change.is_final { - states.vec3_prop.y = value_change.value; - } + states.vec3_prop.y = value_change.value; }) ), ( @@ -702,6 +699,7 @@ fn demo_column_2() -> impl Scene { @sigil_color: tokens::TEXT_INPUT_Z_AXIS, @label_text: "Z", } + NumberInputPrecision(2) DemoVec3Field::Z Node { flex_grow: 1.0, @@ -709,9 +707,7 @@ fn demo_column_2() -> impl Scene { on( |value_change: On>, mut states: ResMut| { - if value_change.is_final { - states.vec3_prop.z = value_change.value; - } + states.vec3_prop.z = value_change.value; }) ), ], @@ -830,15 +826,14 @@ fn update_colors( // with typing. 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::SelectAll); 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), - }); + commands + .entity(scalar_input_ent) + .insert(NumberInputValue::F32(states.scalar_prop)); } for (vec3_input_ent, axis) in q_vec3_input.iter() { @@ -848,10 +843,9 @@ fn update_colors( DemoVec3Field::Z => states.vec3_prop.z, }; - commands.trigger(UpdateNumberInput { - entity: vec3_input_ent, - value: NumberInputValue::F32(new_value), - }); + commands + .entity(vec3_input_ent) + .insert(NumberInputValue::F32(new_value)); } } } diff --git a/examples/ui/widgets/feathers_number_input.rs b/examples/ui/widgets/feathers_number_input.rs new file mode 100644 index 0000000000000..7cb51d011f56d --- /dev/null +++ b/examples/ui/widgets/feathers_number_input.rs @@ -0,0 +1,257 @@ +//! This example shows off the various Bevy Feathers widgets. + +use bevy::{ + feathers::{ + controls::*, + dark_theme::create_dark_theme, + display::label, + theme::{ThemeBackgroundColor, UiTheme}, + tokens, FeathersPlugins, + }, + input_focus::tab_navigation::TabGroup, + prelude::*, + ui::InteractionDisabled, + ui_widgets::ValueChange, +}; + +fn main() { + App::new() + .add_plugins((DefaultPlugins, FeathersPlugins)) + .insert_resource(UiTheme(create_dark_theme())) + .add_systems(Startup, scene.spawn()) + // .add_systems(Update, update_colors) + .run(); +} + +fn scene() -> impl SceneList { + bsn_list![Camera2d, demo_root()] +} + +fn demo_root() -> impl Scene { + bsn! { + Node { + width: percent(100), + height: percent(100), + align_items: AlignItems::Start, + justify_content: JustifyContent::Start, + display: Display::Flex, + flex_direction: FlexDirection::Column, + flex_wrap: FlexWrap::Wrap, + padding: px(8), + row_gap: px(8), + } + TabGroup + ThemeBackgroundColor(tokens::WINDOW_BG) + Children[ + demo_field_f32("none (bare)", 1.0, bsn!()), + demo_field_f32("soft limit", 2.0, bsn!( + template_value(SoftLimit(NumberInputRange::F32(0.0..10.0))) + )), + demo_field_f32("hard limit", 3.0, bsn!( + template_value(HardLimit(NumberInputRange::F32(-100.0..100.0))) + )), + demo_field_f32("soft + hard", 4.0, bsn!( + template_value(SoftLimit(NumberInputRange::F32(0.0..10.0))) + template_value(HardLimit(NumberInputRange::F32(-100.0..100.0))) + )), + demo_field_f32("precision(0)", 5.0, bsn!( + NumberInputPrecision(0) + )), + demo_field_f32("precision(2)", 6.0, bsn!( + NumberInputPrecision(2) + )), + demo_field_f32("precision(4)", 7.0, bsn!( + NumberInputPrecision(4) + )), + demo_field_f32("step(1.0)", 8.0, bsn!( + NumberInputStep(1.0f64) + )), + demo_field_f64("f64: soft limit", 1.0f64, bsn!( + template_value(SoftLimit(NumberInputRange::F64(0.0f64..10.0f64))) + )), + demo_field_f64("f64: soft limit + precision(2)", 1.0f64, bsn!( + template_value(SoftLimit(NumberInputRange::F64(0.0f64..10.0f64))) + NumberInputPrecision(2) + )), + demo_field_i32("i32: bare", 1, bsn!()), + demo_field_i32("i32: soft limit", 1, bsn!( + template_value(SoftLimit(NumberInputRange::I32(0..10))) + )), + demo_field_f32_with_sigil("precision(2) + sigil", 6.0, bsn!( + NumberInputPrecision(2) + )), + demo_field_f32("soft limit + disabled", 2.0, bsn!( + InteractionDisabled + template_value(SoftLimit(NumberInputRange::F32(0.0..10.0))) + )), + ] + } +} + +fn demo_field_f32(label_text: &str, value: f32, options: impl Scene) -> impl Scene { + bsn! { + Node { + display: Display::Flex, + flex_direction: FlexDirection::Column, + align_items: AlignItems::Start, + width: px(200), + row_gap: px(4), + } + Children [ + label(label_text), + Node { + display: Display::Flex, + flex_direction: FlexDirection::Row, + align_items: AlignItems::Center, + align_self: AlignSelf::Stretch, + justify_content: JustifyContent::SpaceBetween, + } + Children [ + ( + @FeathersNumberInput + template_value(NumberInputValue::F32(value)) + {options} + Node { + flex_grow: 1.0, + max_width: px(120), + } + on( + |value_change: On>, mut commands: Commands| { + commands.entity(value_change.event_target()) + .insert(NumberInputValue::F32(value_change.value)); + }) + ), + ( + #Output + label("-") + ) + ] + ] + } +} + +fn demo_field_f32_with_sigil(label_text: &str, value: f32, options: impl Scene) -> impl Scene { + bsn! { + Node { + display: Display::Flex, + flex_direction: FlexDirection::Column, + align_items: AlignItems::Start, + width: px(200), + } + Children [ + label(label_text), + Node { + display: Display::Flex, + flex_direction: FlexDirection::Row, + align_items: AlignItems::Center, + align_self: AlignSelf::Stretch, + justify_content: JustifyContent::SpaceBetween, + } + Children [ + ( + @FeathersNumberInput { + @sigil_color: tokens::TEXT_INPUT_X_AXIS, + @label_text: "X", + } + template_value(NumberInputValue::F32(value)) + {options} + Node { + flex_grow: 1.0, + max_width: px(120), + } + on( + |value_change: On>, mut commands: Commands| { + commands.entity(value_change.event_target()) + .insert(NumberInputValue::F32(value_change.value)); + }) + ), + ( + #Output + label("-") + ) + ] + ] + } +} + +fn demo_field_f64(label_text: &str, value: f64, options: impl Scene) -> impl Scene { + bsn! { + Node { + display: Display::Flex, + flex_direction: FlexDirection::Column, + align_items: AlignItems::Start, + width: px(200), + } + Children [ + label(label_text), + Node { + display: Display::Flex, + flex_direction: FlexDirection::Row, + align_items: AlignItems::Center, + align_self: AlignSelf::Stretch, + justify_content: JustifyContent::SpaceBetween, + } + Children [ + ( + @FeathersNumberInput + template_value(NumberInputValue::F64(value)) + {options} + Node { + flex_grow: 1.0, + max_width: px(120), + } + on( + |value_change: On>, mut commands: Commands| { + commands.entity(value_change.event_target()) + .insert(NumberInputValue::F64(value_change.value)); + }) + ), + ( + #Output + label("-") + ) + ] + ] + } +} + +fn demo_field_i32(label_text: &str, value: i32, options: impl Scene) -> impl Scene { + bsn! { + Node { + display: Display::Flex, + flex_direction: FlexDirection::Column, + align_items: AlignItems::Start, + width: px(200), + } + Children [ + label(label_text), + Node { + display: Display::Flex, + flex_direction: FlexDirection::Row, + align_items: AlignItems::Center, + align_self: AlignSelf::Stretch, + justify_content: JustifyContent::SpaceBetween, + } + Children [ + ( + @FeathersNumberInput + template_value(NumberInputValue::I32(value)) + {options} + Node { + flex_grow: 1.0, + max_width: px(120), + } + on( + |value_change: On>, mut commands: Commands| { + commands.entity(value_change.event_target()) + .insert(NumberInputValue::I32(value_change.value)); + }) + ), + ( + #Output + label("-") + ) + ] + ] + } +} From 3ccfe1d42f9e9d26021ccad64084baf31f71f001 Mon Sep 17 00:00:00 2001 From: Talin Date: Mon, 15 Jun 2026 13:45:55 -0700 Subject: [PATCH 2/7] CI --- crates/bevy_feathers/src/controls/number_input.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bevy_feathers/src/controls/number_input.rs b/crates/bevy_feathers/src/controls/number_input.rs index e1f30ac464ad9..cbb3d60d994ef 100644 --- a/crates/bevy_feathers/src/controls/number_input.rs +++ b/crates/bevy_feathers/src/controls/number_input.rs @@ -53,7 +53,7 @@ use crate::{ tokens, }; -/// Threshold used to distingush between a "click" and a "drag" gesture. +/// Threshold used to distinguish between a "click" and a "drag" gesture. const DRAG_THRESHOLD_DISTANCE: f32 = 0.5; const BASE_DRAG_SPEED: f64 = 0.01f64; From a6f869d6e7bda93438a018431e8352b73abd28dd Mon Sep 17 00:00:00 2001 From: Talin Date: Mon, 15 Jun 2026 14:09:34 -0700 Subject: [PATCH 3/7] Rebuild examples. --- examples/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/README.md b/examples/README.md index 332385ae57e38..ec13c2f94f923 100644 --- a/examples/README.md +++ b/examples/README.md @@ -602,6 +602,7 @@ Example | Description [Drag to Scroll](../examples/ui/scroll_and_overflow/drag_to_scroll.rs) | This example tests scale factor, dragging and scrolling [Editable Text Filter](../examples/ui/text/editable_text_filter.rs) | Demonstrates an 8-character hex input using EditableTextFilter [Feathers Counter](../examples/ui/widgets/feathers_counter.rs) | Simple counter using feathers +[Feathers Number Input](../examples/ui/widgets/feathers_number_input.rs) | Feathers Number Input Options [Feathers Widgets](../examples/ui/widgets/feathers_gallery.rs) | Gallery of Feathers Widgets [Fixed Node](../examples/ui/layout/fixed_node.rs) | Demonstrates how to use FixedNode to lay out a UI node as a root node [Flex Layout](../examples/ui/layout/flex_layout.rs) | Demonstrates how the AlignItems and JustifyContent properties can be composed to layout nodes and position text From 4bb456ae750b67d810443a17383c8d77bded7543 Mon Sep 17 00:00:00 2001 From: Talin Date: Tue, 16 Jun 2026 08:42:00 -0700 Subject: [PATCH 4/7] * Don't allow focus when disabled * Use an alpha-blended background color to permit input fields to look good against various backgrounds. --- .../src/controls/number_input.rs | 5 +++- crates/bevy_feathers/src/dark_theme.rs | 24 ++++++++++++++----- crates/bevy_feathers/src/palette.rs | 2 ++ 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/crates/bevy_feathers/src/controls/number_input.rs b/crates/bevy_feathers/src/controls/number_input.rs index cbb3d60d994ef..f02b2bf832f63 100644 --- a/crates/bevy_feathers/src/controls/number_input.rs +++ b/crates/bevy_feathers/src/controls/number_input.rs @@ -794,14 +794,17 @@ fn scrubber_on_release( &TextScroll, )>, q_scrubber: Query<(&ComputedNode, &UiGlobalTransform, &mut ScrubberDragState)>, + q_root: Query>, q_parent: Query<&ChildOf>, mut input_focus: ResMut, ui_scale: Res, ) { if let Ok(&ChildOf(text_id)) = q_parent.get(release.event_target()) + && let Ok(&ChildOf(root_id)) = q_parent.get(text_id) && let Ok((mut editable_text, node, target, transform, text_scroll)) = q_text.get_mut(text_id) && let Ok((_, _, drag_state)) = q_scrubber.get(release.entity) + && let Ok(disabled) = q_root.get(root_id) { // If editable text has focus, then pass the event through. if input_focus.get() == Some(text_id) { @@ -811,7 +814,7 @@ fn scrubber_on_release( release.propagate(false); // Copy of logic from EditableText / text_input, but done on pointer up instead of down. - if drag_state.max_distance <= DRAG_THRESHOLD_DISTANCE { + if !disabled && drag_state.max_distance <= DRAG_THRESHOLD_DISTANCE { if release.button != PointerButton::Primary { return; } diff --git a/crates/bevy_feathers/src/dark_theme.rs b/crates/bevy_feathers/src/dark_theme.rs index 9b9dec6604a40..4fc9ac51f94db 100644 --- a/crates/bevy_feathers/src/dark_theme.rs +++ b/crates/bevy_feathers/src/dark_theme.rs @@ -43,13 +43,19 @@ pub fn create_dark_theme() -> ThemeProps { palette::WHITE.with_alpha(0.5), ), // Slider - (tokens::SLIDER_BG, palette::GRAY_1), - (tokens::SLIDER_BG_HOVER, palette::GRAY_1.lighter(0.05)), - (tokens::SLIDER_BG_PRESSED, palette::GRAY_1.lighter(0.1)), + (tokens::SLIDER_BG, palette::LIGHT_GRAY_MIX.with_alpha(0.028)), + ( + tokens::SLIDER_BG_HOVER, + palette::LIGHT_GRAY_MIX.with_alpha(0.045), + ), + ( + tokens::SLIDER_BG_PRESSED, + palette::LIGHT_GRAY_MIX.with_alpha(0.045), + ), (tokens::SLIDER_BG_DISABLED, palette::GRAY_1), (tokens::SLIDER_BAR, palette::ACCENT), (tokens::SLIDER_BAR_HOVER, palette::ACCENT.lighter(0.05)), - (tokens::SLIDER_BAR_PRESSED, palette::ACCENT.lighter(0.06)), + (tokens::SLIDER_BAR_PRESSED, palette::ACCENT.lighter(0.05)), (tokens::SLIDER_BAR_DISABLED, palette::GRAY_2), (tokens::SLIDER_TEXT, palette::WHITE), (tokens::SLIDER_TEXT_DISABLED, palette::WHITE.with_alpha(0.5)), @@ -250,8 +256,14 @@ pub fn create_dark_theme() -> ThemeProps { palette::WHITE.with_alpha(0.5), ), // Text Input - (tokens::TEXT_INPUT_BG, palette::GRAY_1), - (tokens::TEXT_INPUT_LABEL_BG, palette::GRAY_3), + ( + tokens::TEXT_INPUT_BG, + palette::LIGHT_GRAY_MIX.with_alpha(0.028), + ), + ( + tokens::TEXT_INPUT_LABEL_BG, + palette::LIGHT_GRAY_MIX.with_alpha(0.09), + ), (tokens::TEXT_INPUT_TEXT, palette::WHITE), ( tokens::TEXT_INPUT_TEXT_DISABLED, diff --git a/crates/bevy_feathers/src/palette.rs b/crates/bevy_feathers/src/palette.rs index f8a709c1b76be..9b0c4ba3a1a61 100644 --- a/crates/bevy_feathers/src/palette.rs +++ b/crates/bevy_feathers/src/palette.rs @@ -19,6 +19,8 @@ pub const WARM_GRAY_1: Color = Color::oklcha(0.3757, 0.0017, 286.32, 1.0); pub const LIGHT_GRAY_1: Color = Color::oklcha(0.7607, 0.0014, 286.37, 1.0); ///
- dim label text pub const LIGHT_GRAY_2: Color = Color::oklcha(0.6106, 0.003, 286.31, 1.0); +///
- mix with backgroud to produce text fill +pub const LIGHT_GRAY_MIX: Color = Color::oklcha(0.8185, 0.0171, 274.77, 1.0); ///
- button label text pub const WHITE: Color = Color::oklcha(1.0, 0.000000059604645, 90.0, 1.0); ///
- call-to-action and selection color From 8921e4397c8da5aef2a164a9d395daa5ec237982 Mon Sep 17 00:00:00 2001 From: Talin Date: Tue, 16 Jun 2026 09:12:43 -0700 Subject: [PATCH 5/7] Typo --- crates/bevy_feathers/src/palette.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bevy_feathers/src/palette.rs b/crates/bevy_feathers/src/palette.rs index 9b0c4ba3a1a61..d581a36d94006 100644 --- a/crates/bevy_feathers/src/palette.rs +++ b/crates/bevy_feathers/src/palette.rs @@ -19,7 +19,7 @@ pub const WARM_GRAY_1: Color = Color::oklcha(0.3757, 0.0017, 286.32, 1.0); pub const LIGHT_GRAY_1: Color = Color::oklcha(0.7607, 0.0014, 286.37, 1.0); ///
- dim label text pub const LIGHT_GRAY_2: Color = Color::oklcha(0.6106, 0.003, 286.31, 1.0); -///
- mix with backgroud to produce text fill +///
- mix with background to produce text fill pub const LIGHT_GRAY_MIX: Color = Color::oklcha(0.8185, 0.0171, 274.77, 1.0); ///
- button label text pub const WHITE: Color = Color::oklcha(1.0, 0.000000059604645, 90.0, 1.0); From 0eee4ae3f2dc13a32d9d1898aebf674e325b1560 Mon Sep 17 00:00:00 2001 From: Talin Date: Wed, 17 Jun 2026 08:08:38 -0700 Subject: [PATCH 6/7] Apply suggestions from code review Co-authored-by: ickshonpe --- crates/bevy_feathers/src/controls/number_input.rs | 12 +++++++++--- examples/ui/widgets/feathers_gallery.rs | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/bevy_feathers/src/controls/number_input.rs b/crates/bevy_feathers/src/controls/number_input.rs index f02b2bf832f63..29457afa0fbdd 100644 --- a/crates/bevy_feathers/src/controls/number_input.rs +++ b/crates/bevy_feathers/src/controls/number_input.rs @@ -117,7 +117,7 @@ impl FeathersNumberInput { Node { column_gap: px(0), border: UiRect { - left: {if props.label_text.is_some() { px(3.0) } else { px(0.0) }}, + left: px(if props.label_text.is_some() { 3.0 } else { 0.0 }), }, padding: UiRect { left: px(0.0), @@ -557,8 +557,7 @@ fn number_input_on_insert_value( { let (mut editable_text, mut gradient) = q_text_input.get_mut(text_id).unwrap(); let new_digits = input_value.to_string(); - let old_digits = editable_text.value().to_string(); - if old_digits != new_digits { + if editable_text.value() != &new_digits { editable_text.queue_edit(TextEdit::SelectAll); editable_text.queue_edit(TextEdit::Insert(new_digits.into())); } @@ -674,6 +673,7 @@ fn number_input_hovered( mut q_text_input: Query<(&Hovered, &mut BackgroundGradient)>, theme: Res, mut commands: Commands, + input_focus: Res, ) { let text_id = insert.event_target(); if let Ok((&Hovered(hovered), mut gradient)) = q_text_input.get_mut(text_id) @@ -689,6 +689,12 @@ fn number_input_hovered( &mut gradient, &mut commands, ); + + if input_focus.get() == Some(text_id) { + commands + .entity(text_id) + .insert(EntityCursor::System(bevy_window::SystemCursorIcon::Text)); + } } } diff --git a/examples/ui/widgets/feathers_gallery.rs b/examples/ui/widgets/feathers_gallery.rs index 30c1c6da30d05..3492c339a6f65 100644 --- a/examples/ui/widgets/feathers_gallery.rs +++ b/examples/ui/widgets/feathers_gallery.rs @@ -826,7 +826,7 @@ fn update_colors( // with typing. 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::SelectAll); editable_text.queue_edit(TextEdit::Insert(states.rgb_color.to_hex().into())); } From 72c61341a02567d5af8532b6c4faaff68b2947da Mon Sep 17 00:00:00 2001 From: Talin Date: Wed, 17 Jun 2026 08:18:33 -0700 Subject: [PATCH 7/7] Review feedback --- .../src/controls/number_input.rs | 38 ++++++------------- 1 file changed, 12 insertions(+), 26 deletions(-) diff --git a/crates/bevy_feathers/src/controls/number_input.rs b/crates/bevy_feathers/src/controls/number_input.rs index 29457afa0fbdd..eebcb86d986e2 100644 --- a/crates/bevy_feathers/src/controls/number_input.rs +++ b/crates/bevy_feathers/src/controls/number_input.rs @@ -42,7 +42,6 @@ use bevy_ui::{ LinearGradient, Node, PositionType, UiGlobalTransform, UiRect, UiScale, }; use bevy_ui_widgets::ValueChange; -use bevy_window::CursorOptions; use crate::{ constants::{fonts, size}, @@ -545,24 +544,31 @@ struct ScrubberDragState { fn number_input_on_insert_value( update: On, q_children: Query<&Children>, - q_number_input: Query<(&NumberInputValue, Option<&SoftLimit>), With>, + q_number_input: Query< + (&NumberInputValue, Option<&SoftLimit>, Option<&HardLimit>), + With, + >, mut q_text_input: Query<(&mut EditableText, &mut BackgroundGradient)>, ) { let text_input_id = q_children .iter_descendants(update.event_target()) .find(|e| q_text_input.contains(*e)); - if let Ok((input_value, limit)) = q_number_input.get(update.event_target()) + if let Ok((&input_value, soft_limit, hard_limit)) = q_number_input.get(update.event_target()) && let Some(text_id) = text_input_id { + let clamped_value = match hard_limit { + Some(limit) => limit.0.clamp(input_value), + None => input_value, + }; let (mut editable_text, mut gradient) = q_text_input.get_mut(text_id).unwrap(); - let new_digits = input_value.to_string(); + let new_digits = clamped_value.to_string(); if editable_text.value() != &new_digits { editable_text.queue_edit(TextEdit::SelectAll); editable_text.queue_edit(TextEdit::Insert(new_digits.into())); } - update_slider_pos(input_value, limit, &mut gradient); + update_slider_pos(&clamped_value, soft_limit, &mut gradient); } } @@ -689,7 +695,7 @@ fn number_input_hovered( &mut gradient, &mut commands, ); - + if input_focus.get() == Some(text_id) { commands .entity(text_id) @@ -857,7 +863,6 @@ fn scrubber_on_drag_start( mut q_text_input: Query<&mut BackgroundGradient>, mut q_scrubber: Query<(&ComputedNode, &mut ScrubberDragState)>, q_parent: Query<&ChildOf>, - mut q_cursor_options: Query<&mut CursorOptions>, focus: Res, theme: Res, mut commands: Commands, @@ -909,15 +914,6 @@ fn scrubber_on_drag_start( &mut gradient, &mut commands, ); - - // Only hide the cursor if there's no slide bar. - if soft_limit.is_none() { - // TODO: Can simplify this by only changing the window that this ui element is on, - // but I've forgotten how to get the window from a computed node. - for mut options in q_cursor_options.iter_mut() { - options.visible = false; - } - } } } @@ -975,15 +971,10 @@ fn scrubber_on_drag_end( mut q_text_input: Query<(&Hovered, &mut BackgroundGradient)>, mut q_scrubber: Query<&mut ScrubberDragState>, q_parent: Query<&ChildOf>, - mut q_cursor_options: Query<&mut CursorOptions>, focus: Res, mut commands: Commands, theme: Res, ) { - for mut options in q_cursor_options.iter_mut() { - options.visible = true; - } - if let Ok(&ChildOf(text_id)) = q_parent.get(drag_end.event_target()) && focus.get() != Some(text_id) && let Ok(&ChildOf(root_id)) = q_parent.get(text_id) @@ -1023,14 +1014,9 @@ fn scrubber_on_drag_cancel( q_parent: Query<&ChildOf>, mut q_text_input: Query<(&Hovered, &mut BackgroundGradient)>, mut q_scrubber: Query<&mut ScrubberDragState>, - mut q_cursor_options: Query<&mut CursorOptions>, theme: Res, mut commands: Commands, ) { - for mut options in q_cursor_options.iter_mut() { - options.visible = true; - } - if let Ok(&ChildOf(text_id)) = q_parent.get(drag_cancel.event_target()) && let Ok(mut drag_state) = q_scrubber.get_mut(drag_cancel.entity) && let Ok((&Hovered(hovered), mut gradient)) = q_text_input.get_mut(text_id)