Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<String>` 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.
4 changes: 2 additions & 2 deletions crates/bevy_feathers/src/controls/number_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -157,7 +157,7 @@ impl FeathersNumberInput {

(
// The editable text entity
@FeathersTextInput {
@FeathersTextInputInner {
@max_characters: 20usize,
}
Node {
Expand Down
214 changes: 197 additions & 17 deletions crates/bevy_feathers/src/controls/text_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand All @@ -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)]
Expand Down Expand Up @@ -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<String>`] 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<f32>,
/// Max characters
pub max_characters: Option<usize>,
/// Adornments to place before the editable text.
pub leading_adornments: Box<dyn SceneList>,
/// Adornments to place after the editable text.
pub trailing_adornments: Box<dyn SceneList>,
}

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<FeathersTextInput>, Changed<TextInputValue>)>,
q_children: Query<&Children>,
mut q_inner: Query<&mut EditableText, With<FeathersTextInputInner>>,
) {
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<TextEditChange>,
q_parents: Query<&ChildOf>,
q_inner: Query<&EditableText, With<FeathersTextInputInner>>,
q_inputs: Query<&TextInputValue, With<FeathersTextInput>>,
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<FocusLost>,
q_parents: Query<&ChildOf>,
q_inner: Query<&EditableText, With<FeathersTextInputInner>>,
q_inputs: Query<(), With<FeathersTextInput>>,
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<InteractionDisabled>),
(
With<FeathersTextInput>,
Or<(Added<FeathersTextInput>, Added<InteractionDisabled>)>,
),
>,
q_children: Query<&Children>,
q_inner: Query<(), With<FeathersTextInputInner>>,
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::<InteractionDisabled>();
}
}
}
}

fn update_text_input_disabled_remove(
q_inputs: Query<(), With<FeathersTextInput>>,
q_children: Query<&Children>,
q_inner: Query<(), With<FeathersTextInputInner>>,
mut removed_disabled: RemovedComponents<InteractionDisabled>,
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::<InteractionDisabled>();
}
}
}

/// 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<f32>,
/// Max characters
pub max_characters: Option<usize>,
}

impl FeathersTextInputInner {
fn scene(props: FeathersTextInputInnerProps) -> impl Scene {
bsn! {
Node {
flex_grow: {
Expand All @@ -105,7 +277,7 @@ impl FeathersTextInput {
}
} ,
}
FeathersTextInput
FeathersTextInputInner
EditableText {
cursor_width: 0.3,
visible_width: {props.visible_width},
Expand All @@ -127,12 +299,14 @@ impl FeathersTextInput {
PropagateOver<TextFont>
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<FeathersTextInput>>,
mut q_text_input: Query<&mut TextCursorStyle, With<FeathersTextInputInner>>,
theme: Res<UiTheme>,
) {
if theme.is_changed() {
Expand All @@ -148,7 +322,7 @@ fn update_text_cursor_color(
fn update_text_input_styles(
q_inputs: Query<
(Entity, Has<InteractionDisabled>, &InheritableThemeTextColor),
(With<FeathersTextInput>, Added<InteractionDisabled>),
(With<FeathersTextInputInner>, Added<InteractionDisabled>),
>,
mut commands: Commands,
) {
Expand All @@ -160,7 +334,7 @@ fn update_text_input_styles(
fn update_text_input_styles_remove(
q_inputs: Query<
(Entity, Has<InteractionDisabled>, &InheritableThemeTextColor),
With<FeathersTextInput>,
With<FeathersTextInputInner>,
>,
mut removed_disabled: RemovedComponents<InteractionDisabled>,
mut commands: Commands,
Expand Down Expand Up @@ -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),
);
Expand Down
Loading