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..d34c1b7b8b88c --- /dev/null +++ b/_release-content/migration-guides/feathers_text_input_container.md @@ -0,0 +1,9 @@ +--- +title: "FeathersTextInput now includes its container" +pull_requests: [25016] +--- + +`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 71b6041f77fc5..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::{FeathersTextInput, 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 - @FeathersTextInput { + @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 81f969f7c820f..358ca760dbe0a 100644 --- a/crates/bevy_feathers/src/controls/text_input.rs +++ b/crates/bevy_feathers/src/controls/text_input.rs @@ -2,25 +2,31 @@ use bevy_app::{Plugin, PreUpdate, PropagateOver}; use bevy_asset::AssetServer; use bevy_ecs::{ change_detection::DetectChanges, + component::Component, entity::Entity, + event::EntityEvent, + hierarchy::{ChildOf, Children}, lifecycle::RemovedComponents, - query::{Added, Has, 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}, @@ -31,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)] @@ -68,33 +74,199 @@ 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 /// ``` +/// +/// 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. -#[derive(Default, Clone)] pub struct FeathersTextInputProps { /// Visible width pub visible_width: Option, /// Max characters pub max_characters: Option, + /// 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 { + fn default() -> Self { + Self { + visible_width: None, + max_characters: None, + leading_adornments: Box::new(bsn_list!()), + trailing_adornments: Box::new(bsn_list!()), + } + } } impl FeathersTextInput { fn scene(props: FeathersTextInputProps) -> impl Scene { + bsn! { + @FeathersTextInputContainer + FeathersTextInput + Children [{props.leading_adornments}, ( + @FeathersTextInputInner { + @visible_width: {props.visible_width}, + @max_characters: {props.max_characters}, + } + ), {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), + ( + With, + Or<(Added, Added)>, + ), + >, + q_children: Query<&Children>, + 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_inner.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_inner: 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_inner.contains(*entity)) + { + commands.entity(input).remove::(); + } + } +} + +/// The editable text entity used internally by Feathers text-based controls. +#[derive(SceneComponent, Default, Clone, Reflect)] +#[scene(FeathersTextInputInnerProps)] +#[reflect(Component, Default, Clone)] +pub(crate) struct FeathersTextInputInner; + +/// Props used to construct a [`FeathersTextInputInner`] scene. +#[derive(Default)] +pub(crate) struct FeathersTextInputInnerProps { + /// Visible width + pub visible_width: Option, + /// Max characters + pub max_characters: Option, +} + +impl FeathersTextInputInner { + fn scene(props: FeathersTextInputInnerProps) -> impl Scene { bsn! { Node { flex_grow: { @@ -105,7 +277,7 @@ impl FeathersTextInput { } } , } - FeathersTextInput + FeathersTextInputInner EditableText { cursor_width: 0.3, visible_width: {props.visible_width}, @@ -127,12 +299,14 @@ impl FeathersTextInput { 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() { @@ -148,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, ) { @@ -160,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, @@ -210,8 +384,14 @@ 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_value, + 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..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, @@ -637,24 +636,18 @@ fn demo_column_1() -> impl Scene { flex_spacer(), // Text input ( - @FeathersTextInputContainer + @FeathersTextInput { + @visible_width: 10f32, + @max_characters: 9usize, + @leading_adornments: bsn! { caption("#") }, + } + InheritableFont { font: fonts::MONO } + HexColorInput + on(handle_hex_color_change) 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, @@ -951,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() { @@ -1018,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() { @@ -1046,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;