From 4b3685b80ff82e5755b941ca633563fae637aa0e Mon Sep 17 00:00:00 2001 From: Jesper Persson Date: Sat, 20 Jun 2026 13:01:30 +0200 Subject: [PATCH 1/2] Number input can emit empty value changes --- .../src/controls/number_input.rs | 158 +++++++++++------- examples/ui/widgets/feathers_gallery.rs | 9 +- 2 files changed, 108 insertions(+), 59 deletions(-) diff --git a/crates/bevy_feathers/src/controls/number_input.rs b/crates/bevy_feathers/src/controls/number_input.rs index 0dfd6dfbf0b5c..0a28cfee850ac 100644 --- a/crates/bevy_feathers/src/controls/number_input.rs +++ b/crates/bevy_feathers/src/controls/number_input.rs @@ -1,3 +1,5 @@ +use std::str::FromStr; + use bevy_app::PropagateOver; use bevy_ecs::{ component::Component, @@ -66,6 +68,8 @@ pub struct FeathersNumberInputProps { pub label_text: Option<&'static str>, /// Indicate what size numbers we are editing. pub number_format: NumberFormat, + /// Whether to emit a value change when the [`EditableText`] is empty + pub emit_value_change_on_empty: bool, } impl Default for FeathersNumberInputProps { @@ -74,6 +78,7 @@ impl Default for FeathersNumberInputProps { sigil_color: tokens::TEXT_INPUT_BG, label_text: None, number_format: NumberFormat::F32, + emit_value_change_on_empty: false, } } } @@ -85,6 +90,7 @@ impl FeathersNumberInput { ThemeBorderColor({props.sigil_color}) FeathersNumberInput template_value(props.number_format) + template_value(EmitValueChangeOnEmpty(props.emit_value_change_on_empty)) on(number_input_on_update) Children [ { @@ -142,6 +148,11 @@ pub enum NumberFormat { I64, } +/// Decides whether to emit a [`ValueChange`] event on empty text +#[derive(Component, Default, Clone, Copy, Reflect)] +#[reflect(Component, Default, Clone)] +pub struct EmitValueChangeOnEmpty(pub bool); + /// Represents numbers in different formats. #[derive(Debug, PartialEq, Clone, Copy, Reflect)] pub enum NumberInputValue { @@ -180,7 +191,7 @@ pub struct UpdateNumberInput { fn number_input_on_text_change( change: On, q_parent: Query<&ChildOf>, - q_number_input: Query<&NumberFormat, With>, + q_number_input: Query<(&NumberFormat, &EmitValueChangeOnEmpty), With>, q_text_input: Query<&EditableText>, mut commands: Commands, ) { @@ -188,7 +199,7 @@ fn number_input_on_text_change( return; }; - let Ok(number_format) = q_number_input.get(parent.get()) else { + let Ok((number_format, emit_value_change_on_empty)) = q_number_input.get(parent.get()) else { return; }; @@ -197,7 +208,14 @@ fn number_input_on_text_change( }; let text_value = editable_text.value().to_string(); - emit_value_change(text_value, *number_format, parent.0, &mut commands, false); + emit_value_change( + text_value, + *number_format, + emit_value_change_on_empty.0, + parent.0, + &mut commands, + false, + ); } fn number_input_on_update( @@ -233,7 +251,7 @@ fn number_input_on_update( fn number_input_on_enter_key( key_input: On>, q_parent: Query<&ChildOf>, - q_number_input: Query<&NumberFormat, With>, + q_number_input: Query<(&NumberFormat, &EmitValueChangeOnEmpty), With>, q_text_input: Query<&EditableText>, mut commands: Commands, ) { @@ -245,7 +263,7 @@ fn number_input_on_enter_key( return; }; - let Ok(number_format) = q_number_input.get(parent.get()) else { + let Ok((number_format, emit_value_change_on_empty)) = q_number_input.get(parent.get()) else { return; }; @@ -254,13 +272,20 @@ fn number_input_on_enter_key( }; let text_value = editable_text.value().to_string(); - emit_value_change(text_value, *number_format, parent.0, &mut commands, true); + emit_value_change( + text_value, + *number_format, + emit_value_change_on_empty.0, + parent.0, + &mut commands, + true, + ); } fn number_input_on_focus_loss( focus_lost: On, q_parent: Query<&ChildOf>, - q_number_input: Query<&NumberFormat, With>, + q_number_input: Query<(&NumberFormat, &EmitValueChangeOnEmpty), With>, mut q_text_input: Query<&mut EditableText>, mut commands: Commands, ) { @@ -270,7 +295,7 @@ fn number_input_on_focus_loss( return; }; - let Ok(number_format) = q_number_input.get(parent.get()) else { + let Ok((number_format, emit_value_change_on_empty)) = q_number_input.get(parent.get()) else { return; }; @@ -279,80 +304,99 @@ fn number_input_on_focus_loss( }; let text_value = editable_text.value().to_string(); - emit_value_change(text_value, *number_format, parent.0, &mut commands, true); + emit_value_change( + text_value, + *number_format, + emit_value_change_on_empty.0, + parent.0, + &mut commands, + true, + ); } fn emit_value_change( text_value: String, format: NumberFormat, + allow_empty: bool, source: Entity, commands: &mut Commands, is_final: bool, ) { let text_value = text_value.trim(); - if text_value.is_empty() { + if !allow_empty && text_value.is_empty() { return; } match format { - NumberFormat::F32 => { - match text_value.parse::() { - Ok(new_value) => { - commands.trigger(ValueChange { - source, - value: new_value, - is_final, - }); - } - Err(_) => { - // TODO: Emit a validation error once these are defined - warn!("Invalid floating-point number in text edit"); - } - } - } - NumberFormat::F64 => { - match text_value.parse::() { - Ok(new_value) => { + NumberFormat::F32 => emit_value_change_with_type::( + text_value, + source, + is_final, + allow_empty, + commands, + "floating-point", + ), + NumberFormat::F64 => emit_value_change_with_type::( + text_value, + source, + is_final, + allow_empty, + commands, + "floating-point", + ), + NumberFormat::I32 => emit_value_change_with_type::( + text_value, + source, + is_final, + allow_empty, + commands, + "integer", + ), + NumberFormat::I64 => emit_value_change_with_type::( + text_value, + source, + is_final, + allow_empty, + commands, + "integer", + ), + } +} + +fn emit_value_change_with_type( + text_value: &str, + source: Entity, + is_final: bool, + allow_empty: bool, + commands: &mut Commands, + number_type_str: &str, +) { + if allow_empty && text_value.is_empty() { + commands.trigger(ValueChange::> { + source, + value: None, + is_final, + }); + } else { + match text_value.parse::() { + Ok(new_value) => { + if allow_empty { commands.trigger(ValueChange { source, - value: new_value, + value: Some(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) => { + } else { 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"); - } + Err(_) => { + // TODO: Emit a validation error once these are defined + warn!("Invalid {number_type_str} number in text edit"); } } } diff --git a/examples/ui/widgets/feathers_gallery.rs b/examples/ui/widgets/feathers_gallery.rs index 954bcd3dbc9e0..3b64684f7314e 100644 --- a/examples/ui/widgets/feathers_gallery.rs +++ b/examples/ui/widgets/feathers_gallery.rs @@ -703,16 +703,21 @@ fn demo_column_2() -> impl Scene { @FeathersNumberInput { @sigil_color: tokens::TEXT_INPUT_Z_AXIS, @label_text: "Z", + @emit_value_change_on_empty: true, } DemoVec3Field::Z Node { flex_grow: 1.0, } on( - |value_change: On>, + |value_change: On>>, mut states: ResMut| { if value_change.is_final { - states.vec3_prop.z = value_change.value; + if let Some(value) = value_change.value { + states.vec3_prop.z = value; + } else { + info!("Value was empty! Skipping.") + } } }) ), From c794aa10209901ebadf8e7b757aacd04b42ddf67 Mon Sep 17 00:00:00 2001 From: Jesper Persson Date: Sat, 20 Jun 2026 13:18:18 +0200 Subject: [PATCH 2/2] Add Empty to NumberInputValue Also changed the example to have the scalar be optional. --- .../src/controls/number_input.rs | 3 ++ examples/ui/widgets/feathers_gallery.rs | 36 ++++++++++--------- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/crates/bevy_feathers/src/controls/number_input.rs b/crates/bevy_feathers/src/controls/number_input.rs index 0a28cfee850ac..63b15fa531a03 100644 --- a/crates/bevy_feathers/src/controls/number_input.rs +++ b/crates/bevy_feathers/src/controls/number_input.rs @@ -164,6 +164,8 @@ pub enum NumberInputValue { I32(i32), /// An `i64` value I64(i64), + /// An empty value + Empty, } impl core::fmt::Display for NumberInputValue { @@ -173,6 +175,7 @@ impl core::fmt::Display for NumberInputValue { NumberInputValue::F64(v) => write!(f, "{}", v), NumberInputValue::I32(v) => write!(f, "{}", v), NumberInputValue::I64(v) => write!(f, "{}", v), + NumberInputValue::Empty => write!(f, ""), } } } diff --git a/examples/ui/widgets/feathers_gallery.rs b/examples/ui/widgets/feathers_gallery.rs index 3b64684f7314e..b6f38736ac15c 100644 --- a/examples/ui/widgets/feathers_gallery.rs +++ b/examples/ui/widgets/feathers_gallery.rs @@ -33,7 +33,7 @@ use bevy::{ struct DemoWidgetStates { rgb_color: Srgba, hsl_color: Hsla, - scalar_prop: f32, + scalar_prop: Option, vec3_prop: Vec3, } @@ -68,7 +68,7 @@ fn main() { .insert_resource(DemoWidgetStates { rgb_color: palettes::tailwind::EMERALD_800.with_alpha(0.7), hsl_color: palettes::tailwind::AMBER_800.into(), - scalar_prop: 7.0, + scalar_prop: Some(7.0), vec3_prop: Vec3::new(10.1, 7.124, 100.0), }) .add_systems(Startup, scene.spawn()) @@ -625,14 +625,16 @@ fn demo_column_2() -> impl Scene { label("A standard group"), label_small("Scalar property"), ( - @FeathersNumberInput + @FeathersNumberInput { + @emit_value_change_on_empty: true, + } DemoScalarField Node { flex_grow: 1.0, max_width: px(100), } on( - |value_change: On>, + |value_change: On>>, mut states: ResMut| { if value_change.is_final { states.scalar_prop = value_change.value; @@ -648,7 +650,7 @@ fn demo_column_2() -> impl Scene { max_width: px(100), } on( - |value_change: On>, + |value_change: On>>, mut states: ResMut| { if value_change.is_final { states.scalar_prop = value_change.value; @@ -703,21 +705,16 @@ fn demo_column_2() -> impl Scene { @FeathersNumberInput { @sigil_color: tokens::TEXT_INPUT_Z_AXIS, @label_text: "Z", - @emit_value_change_on_empty: true, } DemoVec3Field::Z Node { flex_grow: 1.0, } on( - |value_change: On>>, + |value_change: On>, mut states: ResMut| { if value_change.is_final { - if let Some(value) = value_change.value { - states.vec3_prop.z = value; - } else { - info!("Value was empty! Skipping.") - } + states.vec3_prop.z = value_change.value; } }) ), @@ -842,10 +839,17 @@ fn update_colors( } for scalar_input_ent in q_scalar_input.iter() { - commands.trigger(UpdateNumberInput { - entity: scalar_input_ent, - value: NumberInputValue::F32(states.scalar_prop), - }); + if let Some(v) = states.scalar_prop { + commands.trigger(UpdateNumberInput { + entity: scalar_input_ent, + value: NumberInputValue::F32(v), + }); + } else { + commands.trigger(UpdateNumberInput { + entity: scalar_input_ent, + value: NumberInputValue::Empty, + }); + } } for (vec3_input_ent, axis) in q_vec3_input.iter() {