From 0ca44bb47ecd04b75cfb5454ba7ea9730ff3bff5 Mon Sep 17 00:00:00 2001 From: 0xEgao Date: Thu, 16 Jul 2026 17:36:08 +0530 Subject: [PATCH 1/3] Make FeathersTextInput include its container --- .../src/controls/number_input.rs | 4 +- .../bevy_feathers/src/controls/text_input.rs | 123 ++++++++++++++++-- examples/ui/widgets/feathers_gallery.rs | 24 ++-- 3 files changed, 122 insertions(+), 29 deletions(-) diff --git a/crates/bevy_feathers/src/controls/number_input.rs b/crates/bevy_feathers/src/controls/number_input.rs index 71b6041f77fc5..70ad909f1f0a8 100644 --- a/crates/bevy_feathers/src/controls/number_input.rs +++ b/crates/bevy_feathers/src/controls/number_input.rs @@ -46,7 +46,7 @@ use bevy_ui_widgets::ValueChange; use crate::{ constants::{fonts, size}, - controls::{FeathersTextInput, FeathersTextInputContainer}, + controls::{FeathersTextInputBare, FeathersTextInputContainer}, cursor::EntityCursor, rounded_corners::RoundedCorners, theme::{ThemeBackgroundColor, ThemeBorderColor, ThemeTextColor, ThemeToken, UiTheme}, @@ -157,7 +157,7 @@ impl FeathersNumberInput { ( // The editable text entity - @FeathersTextInput { + @FeathersTextInputBare { @max_characters: 20usize, } Node { diff --git a/crates/bevy_feathers/src/controls/text_input.rs b/crates/bevy_feathers/src/controls/text_input.rs index 81f969f7c820f..862e83b0aa54b 100644 --- a/crates/bevy_feathers/src/controls/text_input.rs +++ b/crates/bevy_feathers/src/controls/text_input.rs @@ -3,8 +3,9 @@ use bevy_asset::AssetServer; use bevy_ecs::{ change_detection::DetectChanges, entity::Entity, + hierarchy::Children, lifecycle::RemovedComponents, - query::{Added, Has, With}, + query::{Added, Has, Or, With}, reflect::ReflectComponent, schedule::IntoScheduleConfigs, system::{Commands, Query, Res}, @@ -68,15 +69,12 @@ impl FeathersTextInputContainer { } } -/// Scene function to spawn a text input. For proper styling, this should be enclosed by a [`FeathersTextInputContainer`]. +/// Scene function to spawn a decorated text input. /// /// This is spawnable by inheriting it as a "scene component" with optional [`FeathersTextInputProps`]. /// /// ```ignore -/// @FeathersTextInputContainer -/// Children [ -/// :FeathersTextInput -/// ] +/// @FeathersTextInput /// ``` #[derive(SceneComponent, Default, Clone)] #[scene(FeathersTextInputProps)] @@ -85,16 +83,110 @@ impl FeathersTextInputContainer { pub struct FeathersTextInput; /// Props used to construct the [`FeathersTextInput`] scene. -#[derive(Default, Clone)] pub struct FeathersTextInputProps { /// Visible width pub visible_width: Option, /// Max characters pub max_characters: Option, + /// Components and observers to add to the editable text entity. + pub input: Box, + /// Additional controls to place before the editable text entity. + pub leading_controls: Box, + /// Additional controls to place after the editable text entity. + pub extra_controls: Box, +} + +impl Default for FeathersTextInputProps { + fn default() -> Self { + Self { + visible_width: None, + max_characters: None, + input: Box::new(bsn!()), + leading_controls: Box::new(bsn_list!()), + extra_controls: Box::new(bsn_list!()), + } + } } impl FeathersTextInput { fn scene(props: FeathersTextInputProps) -> impl Scene { + bsn! { + @FeathersTextInputContainer + FeathersTextInput + Children [{props.leading_controls}, ( + @FeathersTextInputBare { + @visible_width: {props.visible_width}, + @max_characters: {props.max_characters}, + } + {props.input} + ), {props.extra_controls}] + } + } +} + +fn update_text_input_disabled( + q_inputs: Query< + (Entity, Has), + ( + With, + Or<(Added, Added)>, + ), + >, + q_children: Query<&Children>, + q_bare_input: Query<(), With>, + mut commands: Commands, +) { + for (entity, disabled) in &q_inputs { + if let Some(input) = q_children + .iter_descendants(entity) + .find(|entity| q_bare_input.contains(*entity)) + { + if disabled { + commands.entity(input).insert(InteractionDisabled); + } else { + commands.entity(input).remove::(); + } + } + } +} + +fn update_text_input_disabled_remove( + q_inputs: Query<(), With>, + q_children: Query<&Children>, + q_bare_input: Query<(), With>, + mut removed_disabled: RemovedComponents, + mut commands: Commands, +) { + for entity in removed_disabled + .read() + .filter(|entity| q_inputs.contains(*entity)) + { + if let Some(input) = q_children + .iter_descendants(entity) + .find(|entity| q_bare_input.contains(*entity)) + { + commands.entity(input).remove::(); + } + } +} + +/// An undecorated text input for embedding in custom containers. +#[derive(SceneComponent, Default, Clone, Reflect)] +#[scene(FeathersTextInputBareProps)] +#[reflect(Component, Default, Clone)] +pub struct FeathersTextInputBare; + +/// Props used to construct a [`FeathersTextInputBare`] scene. +#[derive(Default)] +pub struct FeathersTextInputBareProps { + /// Visible width + pub visible_width: Option, + /// Max characters + pub max_characters: Option, +} + +impl FeathersTextInputBare { + fn scene(props: FeathersTextInputBareProps) -> impl Scene { bsn! { Node { flex_grow: { @@ -105,7 +197,7 @@ impl FeathersTextInput { } } , } - FeathersTextInput + FeathersTextInputBare EditableText { cursor_width: 0.3, visible_width: {props.visible_width}, @@ -132,7 +224,7 @@ impl FeathersTextInput { } fn update_text_cursor_color( - mut q_text_input: Query<&mut TextCursorStyle, With>, + mut q_text_input: Query<&mut TextCursorStyle, With>, theme: Res, ) { if theme.is_changed() { @@ -148,7 +240,7 @@ fn update_text_cursor_color( fn update_text_input_styles( q_inputs: Query< (Entity, Has, &InheritableThemeTextColor), - (With, Added), + (With, Added), >, mut commands: Commands, ) { @@ -160,7 +252,7 @@ fn update_text_input_styles( fn update_text_input_styles_remove( q_inputs: Query< (Entity, Has, &InheritableThemeTextColor), - With, + With, >, mut removed_disabled: RemovedComponents, mut commands: Commands, @@ -210,8 +302,13 @@ impl Plugin for TextInputPlugin { PreUpdate, ( update_text_cursor_color, - update_text_input_styles, - update_text_input_styles_remove, + ( + update_text_input_disabled_remove, + update_text_input_disabled, + update_text_input_styles, + update_text_input_styles_remove, + ) + .chain(), ) .in_set(PickingSystems::Last), ); diff --git a/examples/ui/widgets/feathers_gallery.rs b/examples/ui/widgets/feathers_gallery.rs index e374cbe96a6a8..8b67b28e0539c 100644 --- a/examples/ui/widgets/feathers_gallery.rs +++ b/examples/ui/widgets/feathers_gallery.rs @@ -637,24 +637,20 @@ fn demo_column_1() -> impl Scene { flex_spacer(), // Text input ( - @FeathersTextInputContainer + @FeathersTextInput { + @visible_width: 10f32, + @max_characters: 9usize, + @input: bsn! { + InheritableFont { font: fonts::MONO } + HexColorInput + on(handle_hex_color_change) + }, + @leading_controls: bsn! { caption("#") }, + } Node { flex_grow: 0. padding: { px(4).left() }, } - Children [ - ( - @FeathersTextInput { - @visible_width: 10f32, - @max_characters: 9usize, - } - InheritableFont { - font: fonts::MONO - } - HexColorInput - on(handle_hex_color_change) - ) - ] ) (@FeathersColorSwatch { @opaque_color_percentage: 30.0, From 45d7483389dd1dccc26cac51372ff8f486baf9ca Mon Sep 17 00:00:00 2001 From: 0xEgao Date: Thu, 16 Jul 2026 17:52:08 +0530 Subject: [PATCH 2/3] Document FeathersTextInput container migration --- .../migration-guides/feathers_text_input_container.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 _release-content/migration-guides/feathers_text_input_container.md diff --git a/_release-content/migration-guides/feathers_text_input_container.md b/_release-content/migration-guides/feathers_text_input_container.md new file mode 100644 index 0000000000000..9a1117bbc92ea --- /dev/null +++ b/_release-content/migration-guides/feathers_text_input_container.md @@ -0,0 +1,8 @@ +--- +title: "FeathersTextInput now includes its container" +pull_requests: [25016] +--- + +`FeathersTextInput` now marks and includes its decorated container by default; it is no longer on the entity containing `EditableText`. Pass editable-entity additions via its `input` prop, leading controls via `leading_controls`, and trailing controls via `extra_controls`. +Use `FeathersTextInputBare` when embedding an input in a custom container. +Queries that combine `With` and `EditableText` should instead use `With`. From c0156e7763d78add02b79547f3875735db182ec9 Mon Sep 17 00:00:00 2001 From: 0xEgao Date: Thu, 16 Jul 2026 22:57:36 +0530 Subject: [PATCH 3/3] Expose Feathers text input value on the widget root --- .../feathers_text_input_container.md | 7 +- .../src/controls/number_input.rs | 4 +- .../bevy_feathers/src/controls/text_input.rs | 151 ++++++++++++++---- examples/ui/widgets/feathers_gallery.rs | 36 ++--- 4 files changed, 136 insertions(+), 62 deletions(-) diff --git a/_release-content/migration-guides/feathers_text_input_container.md b/_release-content/migration-guides/feathers_text_input_container.md index 9a1117bbc92ea..d34c1b7b8b88c 100644 --- a/_release-content/migration-guides/feathers_text_input_container.md +++ b/_release-content/migration-guides/feathers_text_input_container.md @@ -3,6 +3,7 @@ title: "FeathersTextInput now includes its container" pull_requests: [25016] --- -`FeathersTextInput` now marks and includes its decorated container by default; it is no longer on the entity containing `EditableText`. Pass editable-entity additions via its `input` prop, leading controls via `leading_controls`, and trailing controls via `extra_controls`. -Use `FeathersTextInputBare` when embedding an input in a custom container. -Queries that combine `With` and `EditableText` should instead use `With`. +`FeathersTextInput` now marks and includes its decorated container by default. Read its text through the `TextInputValue` component on the root entity and update it by inserting a new value. User edits emit `ValueChange` events from the root entity. + +The editable text entity is now an implementation detail. Use the `leading_adornments` and +`trailing_adornments` props to place icons or other controls inside the decorated container. diff --git a/crates/bevy_feathers/src/controls/number_input.rs b/crates/bevy_feathers/src/controls/number_input.rs index 70ad909f1f0a8..4cc7ceddd8373 100644 --- a/crates/bevy_feathers/src/controls/number_input.rs +++ b/crates/bevy_feathers/src/controls/number_input.rs @@ -46,7 +46,7 @@ use bevy_ui_widgets::ValueChange; use crate::{ constants::{fonts, size}, - controls::{FeathersTextInputBare, FeathersTextInputContainer}, + controls::{FeathersTextInputContainer, FeathersTextInputInner}, cursor::EntityCursor, rounded_corners::RoundedCorners, theme::{ThemeBackgroundColor, ThemeBorderColor, ThemeTextColor, ThemeToken, UiTheme}, @@ -157,7 +157,7 @@ impl FeathersNumberInput { ( // The editable text entity - @FeathersTextInputBare { + @FeathersTextInputInner { @max_characters: 20usize, } Node { diff --git a/crates/bevy_feathers/src/controls/text_input.rs b/crates/bevy_feathers/src/controls/text_input.rs index 862e83b0aa54b..358ca760dbe0a 100644 --- a/crates/bevy_feathers/src/controls/text_input.rs +++ b/crates/bevy_feathers/src/controls/text_input.rs @@ -2,26 +2,31 @@ use bevy_app::{Plugin, PreUpdate, PropagateOver}; use bevy_asset::AssetServer; use bevy_ecs::{ change_detection::DetectChanges, + component::Component, entity::Entity, - hierarchy::Children, + event::EntityEvent, + hierarchy::{ChildOf, Children}, lifecycle::RemovedComponents, - query::{Added, Has, Or, With}, + observer::On, + query::{Added, Changed, Has, Or, With}, reflect::ReflectComponent, schedule::IntoScheduleConfigs, system::{Commands, Query, Res}, template::template, }; -use bevy_input_focus::tab_navigation::TabIndex; +use bevy_input_focus::{tab_navigation::TabIndex, FocusLost}; use bevy_picking::PickingSystems; use bevy_reflect::std_traits::ReflectDefault; use bevy_reflect::Reflect; use bevy_scene::prelude::*; use bevy_text::{ - EditableText, FontSource, FontWeight, LineBreak, TextCursorStyle, TextFont, TextLayout, + EditableText, FontSource, FontWeight, LineBreak, TextCursorStyle, TextEdit, TextEditChange, + TextFont, TextLayout, }; use bevy_ui::{ px, AlignItems, BorderRadius, Display, InteractionDisabled, JustifyContent, Node, UiRect, }; +use bevy_ui_widgets::ValueChange; use crate::{ constants::{fonts, size}, @@ -32,8 +37,8 @@ use crate::{ tokens, }; -/// Decorative frame around a text input widget. This is a separate entity to allow icons -/// (such as "search" or "clear") to be inserted adjacent to the input. +/// Decorative frame around a text input widget. This is a separate entity to allow adornments +/// (such as "search" or "clear" icons) to be inserted adjacent to the input. /// /// This is spawnable by inheriting it as a "scene component". #[derive(SceneComponent, Default, Clone, Reflect)] @@ -76,24 +81,32 @@ impl FeathersTextInputContainer { /// ```ignore /// @FeathersTextInput /// ``` +/// +/// Read or replace the text through [`TextInputValue`] on the widget root. User edits emit +/// [`ValueChange`] from the root, with a final event when the input loses focus. #[derive(SceneComponent, Default, Clone)] #[scene(FeathersTextInputProps)] #[derive(Reflect)] #[reflect(Component, Default, Clone)] +#[require(TextInputValue)] pub struct FeathersTextInput; +/// The text value exposed by a [`FeathersTextInput`] on its root entity. +#[derive(Component, Debug, Default, Clone, PartialEq, Eq, Reflect)] +#[component(immutable)] +#[reflect(Component, Default, Clone, PartialEq)] +pub struct TextInputValue(pub String); + /// Props used to construct the [`FeathersTextInput`] scene. pub struct FeathersTextInputProps { /// Visible width pub visible_width: Option, /// Max characters pub max_characters: Option, - /// Components and observers to add to the editable text entity. - pub input: Box, - /// Additional controls to place before the editable text entity. - pub leading_controls: Box, - /// Additional controls to place after the editable text entity. - pub extra_controls: Box, + /// Adornments to place before the editable text. + pub leading_adornments: Box, + /// Adornments to place after the editable text. + pub trailing_adornments: Box, } impl Default for FeathersTextInputProps { @@ -101,9 +114,8 @@ impl Default for FeathersTextInputProps { Self { visible_width: None, max_characters: None, - input: Box::new(bsn!()), - leading_controls: Box::new(bsn_list!()), - extra_controls: Box::new(bsn_list!()), + leading_adornments: Box::new(bsn_list!()), + trailing_adornments: Box::new(bsn_list!()), } } } @@ -113,17 +125,85 @@ impl FeathersTextInput { bsn! { @FeathersTextInputContainer FeathersTextInput - Children [{props.leading_controls}, ( - @FeathersTextInputBare { + Children [{props.leading_adornments}, ( + @FeathersTextInputInner { @visible_width: {props.visible_width}, @max_characters: {props.max_characters}, } - {props.input} - ), {props.extra_controls}] + ), {props.trailing_adornments}] + } + } +} + +fn update_text_input_value( + q_inputs: Query<(Entity, &TextInputValue), (With, Changed)>, + q_children: Query<&Children>, + mut q_inner: Query<&mut EditableText, With>, +) { + for (entity, value) in &q_inputs { + let Some(inner) = q_children + .iter_descendants(entity) + .find(|entity| q_inner.contains(*entity)) + else { + continue; + }; + let mut editable_text = q_inner.get_mut(inner).unwrap(); + if editable_text.value() != &value.0 { + editable_text.queue_edit(TextEdit::SelectAll); + editable_text.queue_edit(TextEdit::Insert(value.0.clone().into())); } } } +fn text_input_on_change( + change: On, + q_parents: Query<&ChildOf>, + q_inner: Query<&EditableText, With>, + q_inputs: Query<&TextInputValue, With>, + mut commands: Commands, +) { + let Ok(editable_text) = q_inner.get(change.event_target()) else { + return; + }; + let Some(root) = q_parents + .iter_ancestors(change.event_target()) + .find(|entity| q_inputs.contains(*entity)) + else { + return; + }; + let value = editable_text.value().to_string(); + if q_inputs.get(root).is_ok_and(|current| current.0 != value) { + commands.entity(root).insert(TextInputValue(value.clone())); + commands.trigger(ValueChange { + source: root, + value, + is_final: false, + }); + } +} + +fn text_input_on_focus_lost( + focus_lost: On, + q_parents: Query<&ChildOf>, + q_inner: Query<&EditableText, With>, + q_inputs: Query<(), With>, + mut commands: Commands, +) { + let Ok(editable_text) = q_inner.get(focus_lost.event_target()) else { + return; + }; + if let Some(root) = q_parents + .iter_ancestors(focus_lost.event_target()) + .find(|entity| q_inputs.contains(*entity)) + { + commands.trigger(ValueChange { + source: root, + value: editable_text.value().to_string(), + is_final: true, + }); + } +} + fn update_text_input_disabled( q_inputs: Query< (Entity, Has), @@ -133,13 +213,13 @@ fn update_text_input_disabled( ), >, q_children: Query<&Children>, - q_bare_input: Query<(), With>, + q_inner: Query<(), With>, mut commands: Commands, ) { for (entity, disabled) in &q_inputs { if let Some(input) = q_children .iter_descendants(entity) - .find(|entity| q_bare_input.contains(*entity)) + .find(|entity| q_inner.contains(*entity)) { if disabled { commands.entity(input).insert(InteractionDisabled); @@ -153,7 +233,7 @@ fn update_text_input_disabled( fn update_text_input_disabled_remove( q_inputs: Query<(), With>, q_children: Query<&Children>, - q_bare_input: Query<(), With>, + q_inner: Query<(), With>, mut removed_disabled: RemovedComponents, mut commands: Commands, ) { @@ -163,30 +243,30 @@ fn update_text_input_disabled_remove( { if let Some(input) = q_children .iter_descendants(entity) - .find(|entity| q_bare_input.contains(*entity)) + .find(|entity| q_inner.contains(*entity)) { commands.entity(input).remove::(); } } } -/// An undecorated text input for embedding in custom containers. +/// The editable text entity used internally by Feathers text-based controls. #[derive(SceneComponent, Default, Clone, Reflect)] -#[scene(FeathersTextInputBareProps)] +#[scene(FeathersTextInputInnerProps)] #[reflect(Component, Default, Clone)] -pub struct FeathersTextInputBare; +pub(crate) struct FeathersTextInputInner; -/// Props used to construct a [`FeathersTextInputBare`] scene. +/// Props used to construct a [`FeathersTextInputInner`] scene. #[derive(Default)] -pub struct FeathersTextInputBareProps { +pub(crate) struct FeathersTextInputInnerProps { /// Visible width pub visible_width: Option, /// Max characters pub max_characters: Option, } -impl FeathersTextInputBare { - fn scene(props: FeathersTextInputBareProps) -> impl Scene { +impl FeathersTextInputInner { + fn scene(props: FeathersTextInputInnerProps) -> impl Scene { bsn! { Node { flex_grow: { @@ -197,7 +277,7 @@ impl FeathersTextInputBare { } } , } - FeathersTextInputBare + FeathersTextInputInner EditableText { cursor_width: 0.3, visible_width: {props.visible_width}, @@ -219,12 +299,14 @@ impl FeathersTextInputBare { PropagateOver EntityCursor::System(bevy_window::SystemCursorIcon::Text) TextCursorStyle::default() + on(text_input_on_change) + on(text_input_on_focus_lost) } } } fn update_text_cursor_color( - mut q_text_input: Query<&mut TextCursorStyle, With>, + mut q_text_input: Query<&mut TextCursorStyle, With>, theme: Res, ) { if theme.is_changed() { @@ -240,7 +322,7 @@ fn update_text_cursor_color( fn update_text_input_styles( q_inputs: Query< (Entity, Has, &InheritableThemeTextColor), - (With, Added), + (With, Added), >, mut commands: Commands, ) { @@ -252,7 +334,7 @@ fn update_text_input_styles( fn update_text_input_styles_remove( q_inputs: Query< (Entity, Has, &InheritableThemeTextColor), - With, + With, >, mut removed_disabled: RemovedComponents, mut commands: Commands, @@ -305,6 +387,7 @@ impl Plugin for TextInputPlugin { ( update_text_input_disabled_remove, update_text_input_disabled, + update_text_input_value, update_text_input_styles, update_text_input_styles_remove, ) diff --git a/examples/ui/widgets/feathers_gallery.rs b/examples/ui/widgets/feathers_gallery.rs index 8b67b28e0539c..9ae89d294abf4 100644 --- a/examples/ui/widgets/feathers_gallery.rs +++ b/examples/ui/widgets/feathers_gallery.rs @@ -16,9 +16,8 @@ use bevy::{ theme::{ThemeBackgroundColor, ThemedText, UiTheme}, tokens, FeathersPlugins, }, - input_focus::{tab_navigation::TabGroup, AutoFocus, InputFocus}, + input_focus::{tab_navigation::TabGroup, AutoFocus}, prelude::*, - text::{EditableText, TextEdit, TextEditChange}, ui::{Checked, InteractionDisabled, Selected}, ui_widgets::{ checkbox_self_update, listbox_update_selection, @@ -640,13 +639,11 @@ fn demo_column_1() -> impl Scene { @FeathersTextInput { @visible_width: 10f32, @max_characters: 9usize, - @input: bsn! { - InheritableFont { font: fonts::MONO } - HexColorInput - on(handle_hex_color_change) - }, - @leading_controls: bsn! { caption("#") }, + @leading_adornments: bsn! { caption("#") }, } + InheritableFont { font: fonts::MONO } + HexColorInput + on(handle_hex_color_change) Node { flex_grow: 0. padding: { px(4).left() }, @@ -947,11 +944,10 @@ fn update_colors( mut sliders: Query<(Entity, &ColorSlider, &mut SliderBaseColor)>, mut swatches: Query<(&mut ColorSwatchValue, &SwatchType), With>, mut color_planes: Query<&mut ColorPlaneValue, With>, - q_text_input: Single<(Entity, &mut EditableText), With>, + q_text_input: Single<(Entity, &TextInputValue), With>, q_scalar_input: Query>, q_vec3_input: Query<(Entity, &DemoVec3Field)>, mut commands: Commands, - focus: Res, ) { if states.is_changed() { for (slider_ent, slider, mut base) in sliders.iter_mut() { @@ -1014,12 +1010,11 @@ fn update_colors( plane_value.0.z = states.rgb_color.green; } - // Only update the hex input field when it's not focused, otherwise it interferes - // 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::Insert(states.rgb_color.to_hex().into())); + let (input_ent, input_value) = q_text_input.into_inner(); + if Srgba::hex(&input_value.0).ok() != Some(states.rgb_color) { + commands + .entity(input_ent) + .insert(TextInputValue(states.rgb_color.to_hex())); } for scalar_input_ent in q_scalar_input.iter() { @@ -1042,13 +1037,8 @@ fn update_colors( } } -fn handle_hex_color_change( - _change: On, - q_text_input: Single<&EditableText, With>, - mut colors: ResMut, -) { - let editable_text = *q_text_input; - if let Ok(color) = Srgba::hex(editable_text.value().to_string()) +fn handle_hex_color_change(change: On>, mut colors: ResMut) { + if let Ok(color) = Srgba::hex(&change.value) && color != colors.rgb_color { colors.rgb_color = color;