From ef2f0ca9f7fa09b6c72e813507d267e42d3fabd7 Mon Sep 17 00:00:00 2001 From: Talin Date: Sat, 27 Jun 2026 15:27:23 -0700 Subject: [PATCH 01/42] New menu-related widgets: * FeathersLazyMenu * FeathersMenuToolButton --- _release-content/release-notes/lazy_menus.md | 13 ++ crates/bevy_feathers/src/controls/menu.rs | 169 +++++++++++++++++-- examples/ui/widgets/feathers_gallery.rs | 71 +++++++- 3 files changed, 238 insertions(+), 15 deletions(-) create mode 100644 _release-content/release-notes/lazy_menus.md diff --git a/_release-content/release-notes/lazy_menus.md b/_release-content/release-notes/lazy_menus.md new file mode 100644 index 0000000000000..60435a63f6f87 --- /dev/null +++ b/_release-content/release-notes/lazy_menus.md @@ -0,0 +1,13 @@ +--- +title: "Feathers menu improvements" +authors: ["@viridia"] +pull_requests: [] +--- + +In addition to `FeathersMenu`, there is now `FeathersLazyMenu`. While the former expects a +pre-spawned popup entity which is hidden, the latter dynamically spawns the popup when the menu +is opened, and and despawns it when closed. This is the recommended approach for menu popups +whose list of menu items is long, expensive to keep around, or dynamically-generated. + +In addition to `FeathersMenuButton`, there is now also `FeathersMenuToolButton` which works the +same as a regular menu button but which has the tool button form factor. diff --git a/crates/bevy_feathers/src/controls/menu.rs b/crates/bevy_feathers/src/controls/menu.rs index 5ce931ea52bed..3d1e255fa9cee 100644 --- a/crates/bevy_feathers/src/controls/menu.rs +++ b/crates/bevy_feathers/src/controls/menu.rs @@ -1,3 +1,4 @@ +use alloc::sync::Arc; use bevy_app::{Plugin, PreUpdate}; use bevy_camera::visibility::Visibility; use bevy_color::{Alpha, Srgba}; @@ -12,7 +13,7 @@ use bevy_ecs::{ schedule::IntoScheduleConfigs, system::{Commands, Query, Res, ResMut}, }; -use bevy_log::warn; +use bevy_log::{info, warn}; use bevy_picking::{hover::Hovered, PickingSystems}; use bevy_reflect::std_traits::ReflectDefault; use bevy_reflect::Reflect; @@ -29,7 +30,7 @@ use bevy_ui_widgets::{ use crate::{ constants::{fonts, icons, size}, - controls::{ButtonVariant, FeathersButton}, + controls::{ButtonVariant, FeathersButton, FeathersToolButton}, cursor::EntityCursor, display::icon, font_styles::InheritableFont, @@ -133,6 +134,120 @@ fn on_menu_event( } } +/// Top-level menu container. This wraps the menu button and provides an anchor for the popover. +/// This is slightly different from the normal [`FeathersMenu`] in that it dynamically spawns +/// the menu popup when the menu is shown and despawns it when it is closed, instead of simply +/// hiding the popup. This is the recommended approach for popups which are large, expensive to +/// keep around, or whose items are dynamically generated. +/// +/// The popup function should return a scene whose root entity is a [`FeathersMenuPopup`]. +#[derive(SceneComponent, Clone)] +pub struct FeathersLazyMenu { + /// Callback which constructs the popup + pub popup: Arc Box + Sync + Send>, +} + +impl Default for FeathersLazyMenu { + fn default() -> Self { + Self { + popup: Arc::new(|| { + warn!("Menu content not specified"); + Box::new(bsn!()) + }), + } + } +} + +impl FeathersLazyMenu { + fn scene() -> impl Scene { + bsn! { + Node { + height: size::ROW_HEIGHT, + justify_content: JustifyContent::Stretch, + align_items: AlignItems::Stretch, + } + FeathersMenu + on(on_lazy_menu_event) + } + } +} + +fn on_lazy_menu_event( + mut ev: On, + q_menu_lazy: Query<&FeathersLazyMenu>, + q_menu_children: Query<&Children>, + q_popovers: Query<(), With>, + q_buttons: Query<(), With>, + mut commands: Commands, + mut focus: ResMut, +) { + match ev.event().action { + MenuAction::Open(nav) => { + let Ok(FeathersLazyMenu { popup }) = q_menu_lazy.get(ev.source) else { + return; + }; + ev.propagate(false); + commands + .entity(ev.source) + .queue_spawn_related_scenes::(bsn!( + popup() + template_value(MenuFocusState::Opening(nav)) + Visibility::Visible + )); + } + MenuAction::Toggle => { + let Ok(FeathersLazyMenu { popup }) = q_menu_lazy.get(ev.source) else { + return; + }; + let Ok(children) = q_menu_children.get(ev.source) else { + return; + }; + let mut menu_open = false; + for child in children.iter() { + if q_popovers.contains(*child) { + ev.propagate(false); + menu_open = true; + commands.entity(*child).despawn(); + } + } + + if !menu_open { + commands + .entity(ev.source) + .queue_spawn_related_scenes::(bsn!( + popup() + template_value(MenuFocusState::Opening(NavAction::First)) + Visibility::Visible + )); + } + } + MenuAction::CloseAll => { + let Ok(children) = q_menu_children.get(ev.source) else { + return; + }; + for child in children.iter() { + if q_popovers.contains(*child) { + ev.propagate(false); + commands.entity(*child).despawn(); + } + } + } + MenuAction::FocusRoot => { + info!("FocusRoot"); + let Ok(children) = q_menu_children.get(ev.source) else { + return; + }; + for child in children.iter() { + if q_buttons.contains(*child) { + ev.propagate(false); + focus.set(*child, FocusCause::Navigated); + break; + } + } + } + } +} + /// A menu button widget. This produces a button that has a dropdown arrow. /// /// This is spawnable by inheriting it as a "scene component" with optional [`FeathersMenuButtonProps`]. @@ -175,16 +290,46 @@ impl FeathersMenuButton { // Additional children for menu chevron Children [ { - if props.arrow { - Box::new(bsn_list!( - Node { - flex_grow: 1.0, - }, - icon(icons::CHEVRON_DOWN), - )) as Box - } else { - Box::new(bsn_list!()) as Box - } + props.arrow.then(|| bsn_list!( + Node { + flex_grow: 1.0, + }, + icon(icons::CHEVRON_DOWN), + )) + } + ] + } + } +} + +/// A menu button widget with the tool button form factor. This produces a button that has a +/// dropdown arrow. +/// +/// This is spawnable by inheriting it as a "scene component" with optional [`FeathersMenuButtonProps`]. +#[derive(SceneComponent, Default, Clone)] +#[scene(FeathersMenuButtonProps)] +#[derive(Reflect)] +#[reflect(Component, Default, Clone)] +pub struct FeathersMenuToolButton; + +impl FeathersMenuToolButton { + fn scene(props: FeathersMenuButtonProps) -> impl Scene { + bsn! { + @FeathersToolButton { + @caption: {props.caption}, + @variant: ButtonVariant::Normal, + @corners: {props.corners}, + } + ActivateOnPress + MenuButton + FeathersMenuButton + // Additional children for menu chevron + Children [ + { + props.arrow.then(|| bsn_list!( + Node { min_width: px(2) }, + icon(icons::CHEVRON_DOWN), + )) } ] } diff --git a/examples/ui/widgets/feathers_gallery.rs b/examples/ui/widgets/feathers_gallery.rs index 43676a91a293e..283d58d2db881 100644 --- a/examples/ui/widgets/feathers_gallery.rs +++ b/examples/ui/widgets/feathers_gallery.rs @@ -21,12 +21,14 @@ use bevy::{ text::{EditableText, TextEdit, TextEditChange}, ui::{Checked, InteractionDisabled, Selected}, ui_widgets::{ - checkbox_self_update, listbox_update_selection, radio_self_update, slider_self_update, - Activate, ActivateOnPress, RadioGroup, RequestClose, SliderPrecision, SliderStep, - SliderValue, ValueChange, + checkbox_self_update, listbox_update_selection, + popover::{Popover, PopoverAlign, PopoverPlacement, PopoverSide}, + radio_self_update, slider_self_update, Activate, ActivateOnPress, RadioGroup, RequestClose, + SliderPrecision, SliderStep, SliderValue, ValueChange, }, window::SystemCursorIcon, }; +use std::sync::Arc; /// A struct to hold the state of various widgets shown in the demo. #[derive(Resource)] @@ -101,6 +103,55 @@ fn demo_root() -> impl Scene { } fn demo_column_1() -> impl Scene { + // Lazily-constructed menu popup + let popup: Arc Box + Sync + Send> = Arc::new(|| { + Box::new(bsn!( + @FeathersMenuPopup + // Override popover placement to right-align the popup + Popover { + positions: vec![ + PopoverPlacement { + side: PopoverSide::Bottom, + align: PopoverAlign::End, + gap: 2.0, + }, + PopoverPlacement { + side: PopoverSide::Top, + align: PopoverAlign::End, + gap: 2.0, + }, + ], + window_margin: 10.0, + } + Children [ + ( + @FeathersMenuItem { + @caption: bsn! { Text("MenuItem 4") ThemedText } + } + on(|_: On| { + info!("Menu item 4 clicked!"); + }) + ), + ( + @FeathersMenuItem { + @caption: bsn! { Text("MenuItem 5") ThemedText } + } + on(|_: On| { + info!("Menu item 5 clicked!"); + }) + ), + ( + @FeathersMenuItem { + @caption: bsn! { Text("MenuItem 6") ThemedText } + } + on(|_: On| { + info!("Menu item 6 clicked!"); + }) + ) + ] + )) + }); + bsn! { Node { display: Display::Flex, @@ -205,6 +256,20 @@ fn demo_column_1() -> impl Scene { ] ) ] + ), + ( + @FeathersLazyMenu { popup } + Children [ + ( + @FeathersMenuToolButton { + @caption: bsn! { Text("\u{0398}") ThemedText } + } + AccessibleLabel("Menu Example") + Node { + flex_grow: 1.0, + } + ) + ] ) ] ), From ab47f955e4e284728ebcc8f894392588a60f3e77 Mon Sep 17 00:00:00 2001 From: Talin Date: Sat, 27 Jun 2026 15:31:06 -0700 Subject: [PATCH 02/42] PR # --- _release-content/release-notes/lazy_menus.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_release-content/release-notes/lazy_menus.md b/_release-content/release-notes/lazy_menus.md index 60435a63f6f87..41327835c09fe 100644 --- a/_release-content/release-notes/lazy_menus.md +++ b/_release-content/release-notes/lazy_menus.md @@ -1,7 +1,7 @@ --- title: "Feathers menu improvements" authors: ["@viridia"] -pull_requests: [] +pull_requests: [24784] --- In addition to `FeathersMenu`, there is now `FeathersLazyMenu`. While the former expects a From 9e03953939204adf1d25431eb629e260cab17e5a Mon Sep 17 00:00:00 2001 From: Talin Date: Wed, 1 Jul 2026 15:11:20 -0700 Subject: [PATCH 03/42] Separate `EditableText` into two components. --- .../migration-guides/text_input.md | 9 ++ _release-content/release-notes/text_input.md | 15 ++ .../bevy_feathers/src/controls/text_input.rs | 2 + crates/bevy_ui_widgets/src/text_input.rs | 146 ++++++++++++------ examples/testbed/ui.rs | 4 + examples/ui/text/editable_text_filter.rs | 2 + examples/ui/text/ime_support.rs | 1 + examples/ui/text/multiline_text_input.rs | 5 + examples/ui/text/multiple_text_inputs.rs | 2 + examples/ui/text/text_input.rs | 2 + 10 files changed, 143 insertions(+), 45 deletions(-) create mode 100644 _release-content/migration-guides/text_input.md create mode 100644 _release-content/release-notes/text_input.md diff --git a/_release-content/migration-guides/text_input.md b/_release-content/migration-guides/text_input.md new file mode 100644 index 0000000000000..7a4dfc30f8966 --- /dev/null +++ b/_release-content/migration-guides/text_input.md @@ -0,0 +1,9 @@ +--- +title: "EditableText readonly and display-only modes" +pull_requests: [] +--- + +Previously, the `EditableText` component functioned as both a holder for the state of a editable +text, and as a standalone widget, with observers and keyboard mappings. These two functions have +been separated: to make a complete, working text input widget, you will now need to insert +both an `EditableText` and a `TextInput` component. diff --git a/_release-content/release-notes/text_input.md b/_release-content/release-notes/text_input.md new file mode 100644 index 0000000000000..fbd1a53f35374 --- /dev/null +++ b/_release-content/release-notes/text_input.md @@ -0,0 +1,15 @@ +--- +title: "TextInput" +authors: ["@viridia"] +pull_requests: [] +--- + +The `EditableText` component has been split into two components, which are now `EditableText` +and `TextInput`. The `EditableText` component, which lives in the `bevy::text` crate, holds the +state of a text input field, but no longer has any built-in observers - that is, it does not +behave like a widget (headless or otherwise), but merely a holder of state. + +All of the widget-like behaviors (responding to keystrokes) have been moved to a new `TextInput` +component which lives in the `bevy::ui_widgets` crate. Not only is the arrangement more consistent +with the other widgets, but in addition this new component also has properties which are only +interesting to widgets, like a "read-only" option. diff --git a/crates/bevy_feathers/src/controls/text_input.rs b/crates/bevy_feathers/src/controls/text_input.rs index 81f969f7c820f..fa398a73ad0c2 100644 --- a/crates/bevy_feathers/src/controls/text_input.rs +++ b/crates/bevy_feathers/src/controls/text_input.rs @@ -21,6 +21,7 @@ use bevy_text::{ use bevy_ui::{ px, AlignItems, BorderRadius, Display, InteractionDisabled, JustifyContent, Node, UiRect, }; +use bevy_ui_widgets::TextInput; use crate::{ constants::{fonts, size}, @@ -106,6 +107,7 @@ impl FeathersTextInput { } , } FeathersTextInput + TextInput EditableText { cursor_width: 0.3, visible_width: {props.visible_width}, diff --git a/crates/bevy_ui_widgets/src/text_input.rs b/crates/bevy_ui_widgets/src/text_input.rs index cf9b84a04bfb6..92fb5473b1c42 100644 --- a/crates/bevy_ui_widgets/src/text_input.rs +++ b/crates/bevy_ui_widgets/src/text_input.rs @@ -6,9 +6,10 @@ //! Note that this module is distinct from the core `bevy_text` crate to avoid pulling in //! [`bevy_input`] to that crate, which is intended to be usable in non-interactive contexts. -use bevy_a11y::AccessibilitySystems; +use accesskit::Role; +use bevy_a11y::{AccessibilityNode, AccessibilitySystems}; use bevy_app::{App, Plugin, PostUpdate, PreUpdate}; -use bevy_ecs::prelude::*; +use bevy_ecs::{prelude::*, reflect::ReflectComponent}; use bevy_input::keyboard::{Key, KeyboardInput}; use bevy_input::{ButtonInput, InputSystems}; use bevy_input_focus::{ @@ -46,6 +47,21 @@ const SHIFT_COMMAND: u8 = SHIFT | COMMAND; #[cfg(not(target_os = "macos"))] const SHIFT_ALT: u8 = SHIFT | ALT; +/// Editable text widget. +#[derive(Component, Clone, Default, Reflect, PartialEq)] +#[require(EditableText)] +#[require(AccessibilityNode(accesskit::Node::new(Role::TextInput)))] +#[reflect(Component)] +pub enum TextInput { + /// Text input functions normally + #[default] + Editable, + /// Cursor movement, selection, and copy to clipboard is still enabled, but no mutations are allowed + ReadOnly, + /// Display only, all interactions disabled - this is used by number input widget when dragging. + DisplayOnly, +} + /// System that processes keyboard input events into text edit actions for focused [`EditableText`] widgets. /// /// See [`EditableText`] for more details on the standard mapping from keyboard events to text edit actions @@ -55,10 +71,10 @@ const SHIFT_ALT: u8 = SHIFT | ALT; /// and then applied later by the [`apply_text_edits`](`bevy_text::apply_text_edits`) system. fn on_focused_keyboard_input( mut keyboard_input: On>, - mut query: Query<&mut EditableText>, + mut query: Query<(&TextInput, &mut EditableText)>, keys: Res>, ) { - let Ok(mut editable_text) = query.get_mut(keyboard_input.focused_entity) else { + let Ok((text_input, mut editable_text)) = query.get_mut(keyboard_input.focused_entity) else { return; // Focused entity is not an EditableText, nothing to do }; @@ -84,63 +100,80 @@ fn on_focused_keyboard_input( let mut should_propagate = true; - let mut queue_edit = |edit| { - if keyboard_input.input.state.is_pressed() { + let mut queue_edit = |edit, readonly| { + if *text_input == TextInput::Editable + || (readonly && *text_input == TextInput::ReadOnly) + && keyboard_input.input.state.is_pressed() + { editable_text.queue_edit(edit); } should_propagate = false; }; match (mod_flags, &keyboard_input.input.logical_key) { - (NONE, Key::Copy) => queue_edit(TextEdit::Copy), - (NONE, Key::Cut) => queue_edit(TextEdit::Cut), - (NONE, Key::Paste) => queue_edit(TextEdit::Paste), + (NONE, Key::Copy) => queue_edit(TextEdit::Copy, true), + (NONE, Key::Cut) => queue_edit(TextEdit::Cut, false), + (NONE, Key::Paste) => queue_edit(TextEdit::Paste, false), (COMMAND, Key::Character(c)) if c.eq_ignore_ascii_case("a") => { - queue_edit(TextEdit::SelectAll); + queue_edit(TextEdit::SelectAll, true); } (COMMAND, Key::Character(c)) if c.eq_ignore_ascii_case("c") => { - queue_edit(TextEdit::Copy); + queue_edit(TextEdit::Copy, true); + } + (COMMAND, Key::Character(c)) if c.eq_ignore_ascii_case("x") => { + queue_edit(TextEdit::Cut, false); } - (COMMAND, Key::Character(c)) if c.eq_ignore_ascii_case("x") => queue_edit(TextEdit::Cut), (COMMAND, Key::Character(c)) if c.eq_ignore_ascii_case("v") => { - queue_edit(TextEdit::Paste); + queue_edit(TextEdit::Paste, true); } #[cfg(not(target_os = "macos"))] - (SHIFT, Key::Delete) => queue_edit(TextEdit::Cut), - (WORD, Key::Backspace) => queue_edit(TextEdit::BackspaceWord), - (WORD, Key::Delete) => queue_edit(TextEdit::DeleteWord), + (SHIFT, Key::Delete) => queue_edit(TextEdit::Cut, false), + (WORD, Key::Backspace) => queue_edit(TextEdit::BackspaceWord, false), + (WORD, Key::Delete) => queue_edit(TextEdit::DeleteWord, false), #[cfg(target_os = "macos")] - (SUPER | SHIFT_SUPER, Key::ArrowLeft) => queue_edit(TextEdit::HardLineStart(shift_pressed)), + (SUPER | SHIFT_SUPER, Key::ArrowLeft) => { + queue_edit(TextEdit::HardLineStart(shift_pressed), true); + } #[cfg(target_os = "macos")] - (SUPER | SHIFT_SUPER, Key::ArrowRight) => queue_edit(TextEdit::HardLineEnd(shift_pressed)), + (SUPER | SHIFT_SUPER, Key::ArrowRight) => { + queue_edit(TextEdit::HardLineEnd(shift_pressed), true); + } #[cfg(not(target_os = "macos"))] - (ALT | SHIFT_ALT, Key::Home) => queue_edit(TextEdit::HardLineStart(shift_pressed)), + (ALT | SHIFT_ALT, Key::Home) => queue_edit(TextEdit::HardLineStart(shift_pressed), true), #[cfg(not(target_os = "macos"))] - (ALT | SHIFT_ALT, Key::End) => queue_edit(TextEdit::HardLineEnd(shift_pressed)), - (WORD | SHIFT_WORD, Key::ArrowLeft) => queue_edit(TextEdit::WordLeft(shift_pressed)), - (WORD | SHIFT_WORD, Key::ArrowRight) => queue_edit(TextEdit::WordRight(shift_pressed)), - (NONE | SHIFT, Key::ArrowLeft) => queue_edit(TextEdit::Left(shift_pressed)), - (NONE | SHIFT, Key::ArrowRight) => queue_edit(TextEdit::Right(shift_pressed)), - (COMMAND | SHIFT_COMMAND, Key::ArrowUp) => queue_edit(TextEdit::TextStart(shift_pressed)), - (COMMAND | SHIFT_COMMAND, Key::ArrowDown) => queue_edit(TextEdit::TextEnd(shift_pressed)), - (NONE | SHIFT, Key::ArrowUp) => queue_edit(TextEdit::Up(shift_pressed)), - (NONE | SHIFT, Key::ArrowDown) => queue_edit(TextEdit::Down(shift_pressed)), - (COMMAND | SHIFT_COMMAND, Key::Home) => queue_edit(TextEdit::TextStart(shift_pressed)), - (COMMAND | SHIFT_COMMAND, Key::End) => queue_edit(TextEdit::TextEnd(shift_pressed)), - (NONE | SHIFT, Key::Home) => queue_edit(TextEdit::LineStart(shift_pressed)), - (NONE | SHIFT, Key::End) => queue_edit(TextEdit::LineEnd(shift_pressed)), - (NONE, Key::Backspace) => queue_edit(TextEdit::Backspace), - (NONE, Key::Delete) => queue_edit(TextEdit::Delete), - (NONE, Key::Escape) => queue_edit(TextEdit::CollapseSelection), + (ALT | SHIFT_ALT, Key::End) => queue_edit(TextEdit::HardLineEnd(shift_pressed), true), + (WORD | SHIFT_WORD, Key::ArrowLeft) => queue_edit(TextEdit::WordLeft(shift_pressed), true), + (WORD | SHIFT_WORD, Key::ArrowRight) => { + queue_edit(TextEdit::WordRight(shift_pressed), true); + } + (NONE | SHIFT, Key::ArrowLeft) => queue_edit(TextEdit::Left(shift_pressed), true), + (NONE | SHIFT, Key::ArrowRight) => queue_edit(TextEdit::Right(shift_pressed), true), + (COMMAND | SHIFT_COMMAND, Key::ArrowUp) => { + queue_edit(TextEdit::TextStart(shift_pressed), true); + } + (COMMAND | SHIFT_COMMAND, Key::ArrowDown) => { + queue_edit(TextEdit::TextEnd(shift_pressed), true); + } + (NONE | SHIFT, Key::ArrowUp) => queue_edit(TextEdit::Up(shift_pressed), true), + (NONE | SHIFT, Key::ArrowDown) => queue_edit(TextEdit::Down(shift_pressed), true), + (COMMAND | SHIFT_COMMAND, Key::Home) => { + queue_edit(TextEdit::TextStart(shift_pressed), true); + } + (COMMAND | SHIFT_COMMAND, Key::End) => queue_edit(TextEdit::TextEnd(shift_pressed), true), + (NONE | SHIFT, Key::Home) => queue_edit(TextEdit::LineStart(shift_pressed), true), + (NONE | SHIFT, Key::End) => queue_edit(TextEdit::LineEnd(shift_pressed), true), + (NONE, Key::Backspace) => queue_edit(TextEdit::Backspace, false), + (NONE, Key::Delete) => queue_edit(TextEdit::Delete, false), + (NONE, Key::Escape) => queue_edit(TextEdit::CollapseSelection, true), (NONE | SHIFT, Key::Character(_)) | (NONE, Key::Space) => { if let Some(text) = &keyboard_input.input.text && !text.is_empty() { - queue_edit(TextEdit::Insert(text.clone())); + queue_edit(TextEdit::Insert(text.clone()), false); } } (NONE, Key::Enter) if allow_newlines => { - queue_edit(TextEdit::Insert("\n".into())); + queue_edit(TextEdit::Insert("\n".into()), false); } _ => { // Ignore and propagate to allow for tab navigation and submit actions. @@ -157,6 +190,7 @@ fn on_focused_keyboard_input( fn on_pointer_press( mut press: On>, mut text_input_query: Query<( + &TextInput, &mut EditableText, &ComputedNode, &ComputedUiRenderTargetInfo, @@ -171,7 +205,7 @@ fn on_pointer_press( return; } - let Ok((mut editable_text, node, target, transform, text_scroll)) = + let Ok((text_input, mut editable_text, node, target, transform, text_scroll)) = text_input_query.get_mut(press.entity) else { return; @@ -179,6 +213,10 @@ fn on_pointer_press( input_focus.set(press.entity, FocusCause::Pressed); + if *text_input == TextInput::DisplayOnly { + return; + } + press.propagate(false); if editable_text.is_composing() { @@ -217,6 +255,7 @@ fn on_pointer_press( fn on_pointer_drag( mut drag: On>, mut text_input_query: Query<( + &TextInput, &mut EditableText, &ComputedNode, &ComputedUiRenderTargetInfo, @@ -229,12 +268,16 @@ fn on_pointer_drag( return; } - let Ok((mut editable_text, node, target, transform, text_scroll)) = + let Ok((text_input, mut editable_text, node, target, transform, text_scroll)) = text_input_query.get_mut(drag.entity) else { return; }; + if *text_input == TextInput::DisplayOnly { + return; + } + drag.propagate(false); if editable_text.is_composing() { @@ -266,7 +309,7 @@ fn on_pointer_drag( fn on_ime_input( mut ime_reader: MessageReader, input_focus: Res, - mut editable_text_query: Query<&mut EditableText>, + mut editable_text_query: Query<(&TextInput, &mut EditableText)>, ) { let Some(focused_entity) = input_focus.get() else { // No focused entity, nothing to do. @@ -275,13 +318,17 @@ fn on_ime_input( return; }; - let Ok(mut editable_text) = editable_text_query.get_mut(focused_entity) else { + let Ok((text_input, mut editable_text)) = editable_text_query.get_mut(focused_entity) else { // Focused entity is not an EditableText, nothing to do. // Still need to drain the reader to prevent stale events on next focus. ime_reader.read().for_each(drop); return; }; + if *text_input != TextInput::Editable { + return; + } + for ime in ime_reader.read() { match ime { Ime::Preedit { value, cursor, .. } => { @@ -317,6 +364,7 @@ fn on_ime_input( fn update_ime_position( input_focus: Res, editable_text_query: Query<( + &TextInput, &EditableText, &ComputedNode, &UiGlobalTransform, @@ -330,12 +378,17 @@ fn update_ime_position( let Some(focused) = input_focus.get() else { return; }; - let Ok((editable_text, node, transform, target, text_scroll)) = + + let Ok((text_input, editable_text, node, transform, target, text_scroll)) = editable_text_query.get(focused) else { return; }; + if *text_input == TextInput::DisplayOnly { + return; + } + let Ok(mut window) = windows.single_mut() else { return; }; @@ -356,7 +409,7 @@ fn update_ime_position( /// IME is enabled when an `EditableText` gains focus and disabled when focus moves elsewhere. fn listen_for_ime_input_when_text_input_focused( input_focus: Res, - editable_text_query: Query<(), With>, + editable_text_query: Query<(), With>, mut windows: Query<&mut Window, With>, ) { if !input_focus.is_changed() { @@ -390,7 +443,10 @@ fn listen_for_ime_input_when_text_input_focused( /// A [`TextEdit::CollapseSelection`] is also fired to collapse any active highlighted text /// selection back to the caret cursor position, preventing text styles from getting stuck in a /// focused color state. -fn on_focus_lost(trigger: On, mut editable_text_query: Query<&mut EditableText>) { +fn on_focus_lost( + trigger: On, + mut editable_text_query: Query<&mut EditableText, With>, +) { if let Ok(mut editable_text) = editable_text_query.get_mut(trigger.entity) { editable_text.queue_edit(TextEdit::clear_ime_compose()); editable_text.queue_edit(TextEdit::CollapseSelection); @@ -415,7 +471,7 @@ struct QueuedSelectAll(Option); fn on_focus_select_all( focus_gained: On, - mut q_text_input: Query<(&mut EditableText, Has)>, + mut q_text_input: Query<(&mut EditableText, Has), With>, mut queued_select_all: ResMut, ) { let target = focus_gained.event_target(); diff --git a/examples/testbed/ui.rs b/examples/testbed/ui.rs index b13f0bfe4c9e9..dbbb311c01b12 100644 --- a/examples/testbed/ui.rs +++ b/examples/testbed/ui.rs @@ -2057,6 +2057,7 @@ mod editable_text { use bevy::text::EditableText; use bevy::text::TextEdit; use bevy::ui::widget::TextScroll; + use bevy::ui_widgets::TextInput; pub fn setup(mut commands: Commands) { commands.spawn((Camera2d, DespawnOnExit(super::Scene::EditableText))); @@ -2073,6 +2074,7 @@ mod editable_text { DespawnOnExit(super::Scene::EditableText), children![ ( + TextInput::default(), EditableText { pending_edits: vec![TextEdit::Insert("Single line EditableText".into())], ..default() @@ -2085,6 +2087,7 @@ mod editable_text { BorderColor::all(YELLOW), ), ( + TextInput::default(), EditableText { pending_edits: vec![ TextEdit::Insert( @@ -2105,6 +2108,7 @@ mod editable_text { BorderColor::all(YELLOW), ), ( + TextInput::default(), EditableText { pending_edits: vec![ TextEdit::Insert( diff --git a/examples/ui/text/editable_text_filter.rs b/examples/ui/text/editable_text_filter.rs index aee98af372df6..057a4ff9d3c9a 100644 --- a/examples/ui/text/editable_text_filter.rs +++ b/examples/ui/text/editable_text_filter.rs @@ -5,6 +5,7 @@ use bevy::color::palettes::tailwind::SLATE_300; use bevy::input_focus::AutoFocus; use bevy::prelude::*; use bevy::text::{EditableText, EditableTextFilter, TextCursorStyle}; +use bevy::ui_widgets::TextInput; fn main() { App::new() @@ -32,6 +33,7 @@ fn setup(mut commands: Commands) { padding: px(8.).all(), ..default() }, + TextInput::default(), EditableText { max_characters: Some(8), ..default() diff --git a/examples/ui/text/ime_support.rs b/examples/ui/text/ime_support.rs index 2e9f086fd2ad1..3989e0a095b95 100644 --- a/examples/ui/text/ime_support.rs +++ b/examples/ui/text/ime_support.rs @@ -58,6 +58,7 @@ fn setup(mut commands: Commands) { ..default() }, BorderColor::from(Color::from(SLATE_300)), + TextInput::default(), EditableText { allow_newlines: true, ..default() diff --git a/examples/ui/text/multiline_text_input.rs b/examples/ui/text/multiline_text_input.rs index 5d3377b82fbf4..22291b8572c99 100644 --- a/examples/ui/text/multiline_text_input.rs +++ b/examples/ui/text/multiline_text_input.rs @@ -7,6 +7,7 @@ use bevy::input_focus::tab_navigation::{TabGroup, TabIndex, TabNavigationPlugin} use bevy::input_focus::{AutoFocus, FocusCause, FocusedInput, InputFocus}; use bevy::prelude::*; use bevy::text::{EditableText, EditableTextFilter, TextCursorStyle}; +use bevy::ui_widgets::TextInput; use bevy::ui_widgets::{ popover::{Popover, PopoverAlign, PopoverPlacement, PopoverSide}, Activate, Button, MenuAction, MenuButton, MenuEvent, MenuFocusState, MenuItem, MenuPopup, @@ -66,6 +67,7 @@ fn setup(mut commands: Commands, asset_server: Res) { padding: px(8.).all(), ..default() }, + TextInput::default(), EditableText { visible_lines: Some(8.), allow_newlines: true, @@ -148,6 +150,7 @@ fn setup(mut commands: Commands, asset_server: Res) { }, BackgroundColor(DARK_SLATE_GRAY.into()), BorderColor::all(SLATE_300), + TextInput::default(), EditableText::new("8"), EditableTextFilter::new(|c| c.is_ascii_digit() || c == '.'), TextCursorStyle { @@ -232,6 +235,7 @@ fn setup(mut commands: Commands, asset_server: Res) { }, BackgroundColor(DARK_SLATE_GRAY.into()), BorderColor::all(SLATE_300), + TextInput::default(), EditableText::new("30"), EditableTextFilter::new(|c| c.is_ascii_digit()), TextCursorStyle { @@ -313,6 +317,7 @@ fn setup(mut commands: Commands, asset_server: Res) { }, BackgroundColor(DARK_SLATE_GRAY.into()), BorderColor::all(SLATE_300), + TextInput::default(), EditableText::new("0"), EditableTextFilter::new(|c| c.is_ascii_digit() || c == '.'), TextCursorStyle { diff --git a/examples/ui/text/multiple_text_inputs.rs b/examples/ui/text/multiple_text_inputs.rs index 7d191cfde4add..2b4378fcdf22c 100644 --- a/examples/ui/text/multiple_text_inputs.rs +++ b/examples/ui/text/multiple_text_inputs.rs @@ -15,6 +15,7 @@ use bevy::input_focus::{ }; use bevy::prelude::*; use bevy::text::{EditableText, TextCursorStyle}; +use bevy::ui_widgets::TextInput; fn main() { App::new() @@ -119,6 +120,7 @@ fn setup(mut commands: Commands, asset_server: Res) { padding: px(4.).all(), ..default() }, + TextInput::default(), EditableText::new(format!("Initial text {row}")), TextCursorStyle::default(), font.clone(), diff --git a/examples/ui/text/text_input.rs b/examples/ui/text/text_input.rs index 7431762a1fc06..fa448dbb308cb 100644 --- a/examples/ui/text/text_input.rs +++ b/examples/ui/text/text_input.rs @@ -31,6 +31,7 @@ use bevy::input_focus::{ }; use bevy::prelude::*; use bevy::text::{EditableText, TextCursorStyle}; +use bevy::ui_widgets::TextInput; fn main() { App::new() @@ -124,6 +125,7 @@ fn build_input_text(commands: &mut Commands, is_left: bool, font_size: f32) -> E }, BorderColor::from(Color::from(SLATE_300)), Name::new(if is_left { "Left" } else { "Right" }), + TextInput::default(), EditableText { visible_width: Some(10.), allow_newlines: false, From a3dca2e8e40b1f4ba023acc8a6bff6dd7f6d0a68 Mon Sep 17 00:00:00 2001 From: Talin Date: Thu, 9 Jul 2026 08:44:06 -0700 Subject: [PATCH 04/42] Remove duplicative plugin in examples. --- examples/ui/text/multiline_text_input.rs | 3 +-- examples/ui/text/multiple_text_inputs.rs | 5 ++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/examples/ui/text/multiline_text_input.rs b/examples/ui/text/multiline_text_input.rs index bf1ac222631ea..22291b8572c99 100644 --- a/examples/ui/text/multiline_text_input.rs +++ b/examples/ui/text/multiline_text_input.rs @@ -3,7 +3,6 @@ use bevy::color::palettes::css::DARK_SLATE_GRAY; use bevy::color::palettes::tailwind::SLATE_300; use bevy::input::keyboard::{Key, KeyboardInput}; -use bevy::input_focus::pointer_focus::PointerFocusPlugin; use bevy::input_focus::tab_navigation::{TabGroup, TabIndex, TabNavigationPlugin}; use bevy::input_focus::{AutoFocus, FocusCause, FocusedInput, InputFocus}; use bevy::prelude::*; @@ -17,7 +16,7 @@ use bevy::ui_widgets::{ fn main() { App::new() - .add_plugins((DefaultPlugins, TabNavigationPlugin, PointerFocusPlugin)) + .add_plugins((DefaultPlugins, TabNavigationPlugin)) .add_systems(Startup, setup) .run(); } diff --git a/examples/ui/text/multiple_text_inputs.rs b/examples/ui/text/multiple_text_inputs.rs index 86d2564f1b39e..123d4dfb7efac 100644 --- a/examples/ui/text/multiple_text_inputs.rs +++ b/examples/ui/text/multiple_text_inputs.rs @@ -8,12 +8,11 @@ use bevy::color::palettes::tailwind::SLATE_300; use bevy::input::keyboard::Key; use bevy::input_focus::tab_navigation::NavAction; +use bevy::input_focus::{tab_navigation::TabNavigation, AutoFocus, FocusCause}; use bevy::input_focus::{ - pointer_focus::PointerFocusPlugin, tab_navigation::{TabGroup, TabIndex, TabNavigationPlugin}, InputFocus, }; -use bevy::input_focus::{tab_navigation::TabNavigation, AutoFocus, FocusCause}; use bevy::prelude::*; use bevy::text::{EditableText, TextCursorStyle, TextEditChange}; use bevy::ui_widgets::TextInput; @@ -21,7 +20,7 @@ use bevy::ui_widgets::TextInput; fn main() { App::new() // `EditableTextInputPlugin` is part of `DefaultPlugins` - .add_plugins((DefaultPlugins, TabNavigationPlugin, PointerFocusPlugin)) + .add_plugins((DefaultPlugins, TabNavigationPlugin)) .add_systems(Startup, setup) .add_systems(Update, (submit_text, update_row_border_colors)) .add_observer(synchronize_output_text) From 6dc477480205680224e6f1d097ddbc2a3b55a8a6 Mon Sep 17 00:00:00 2001 From: Talin Date: Sat, 11 Jul 2026 09:19:53 -0700 Subject: [PATCH 05/42] Review feedback (part 1). --- crates/bevy_text/src/text_edit.rs | 43 ++++++++++++++ crates/bevy_ui_widgets/src/text_input.rs | 74 ++++++++++++------------ 2 files changed, 81 insertions(+), 36 deletions(-) diff --git a/crates/bevy_text/src/text_edit.rs b/crates/bevy_text/src/text_edit.rs index 9e9f5defa0e7e..6c05d1f836856 100644 --- a/crates/bevy_text/src/text_edit.rs +++ b/crates/bevy_text/src/text_edit.rs @@ -210,6 +210,49 @@ impl TextEdit { } } + /// Returns whether this actually affects the text, or is just cursor movement + pub fn is_destructive(&self) -> bool { + match self { + // Destructive actions + TextEdit::Cut + | TextEdit::Paste + | TextEdit::Insert(_) + | TextEdit::Backspace + | TextEdit::BackspaceWord + | TextEdit::Delete + | TextEdit::DeleteWord + | TextEdit::ImeSetCompose { + value: _, + cursor: _, + } + | TextEdit::ImeCommit { value: _ } => true, + + // Nondestructive actions + TextEdit::Copy + | TextEdit::Left(_) + | TextEdit::Right(_) + | TextEdit::WordLeft(_) + | TextEdit::WordRight(_) + | TextEdit::Up(_) + | TextEdit::Down(_) + | TextEdit::TextStart(_) + | TextEdit::TextEnd(_) + | TextEdit::HardLineStart(_) + | TextEdit::HardLineEnd(_) + | TextEdit::LineStart(_) + | TextEdit::LineEnd(_) + | TextEdit::CollapseSelection + | TextEdit::SelectAll + | TextEdit::SelectAllIfCollapsed + | TextEdit::MoveToPoint(_) + | TextEdit::SelectWordAtPoint(_) + | TextEdit::SelectLineAtPoint(_) + | TextEdit::SelectedHardLineAtPoint(_) + | TextEdit::ExtendSelectionToPoint(_) + | TextEdit::ShiftClickExtension(_) => false, + } + } + /// Apply the [`TextEdit`] to the text editor driver. /// /// Note that some edits, such as [`TextEdit::Paste`], may need to be deferred across frames due to asynchronous clipboard I/O. diff --git a/crates/bevy_ui_widgets/src/text_input.rs b/crates/bevy_ui_widgets/src/text_input.rs index 53c33f7b03357..dab04c32818ee 100644 --- a/crates/bevy_ui_widgets/src/text_input.rs +++ b/crates/bevy_ui_widgets/src/text_input.rs @@ -100,10 +100,10 @@ fn on_focused_keyboard_input( let mut should_propagate = true; - let mut queue_edit = |edit, readonly| { - if *text_input == TextInput::Editable - || (readonly && *text_input == TextInput::ReadOnly) - && keyboard_input.input.state.is_pressed() + let mut queue_edit = |edit: TextEdit| { + if (*text_input == TextInput::Editable + || (!edit.is_destructive() && *text_input == TextInput::ReadOnly)) + && keyboard_input.input.state.is_pressed() { editable_text.queue_edit(edit); } @@ -111,69 +111,69 @@ fn on_focused_keyboard_input( }; match (mod_flags, &keyboard_input.input.logical_key) { - (NONE, Key::Copy) => queue_edit(TextEdit::Copy, true), - (NONE, Key::Cut) => queue_edit(TextEdit::Cut, false), - (NONE, Key::Paste) => queue_edit(TextEdit::Paste, false), + (NONE, Key::Copy) => queue_edit(TextEdit::Copy), + (NONE, Key::Cut) => queue_edit(TextEdit::Cut), + (NONE, Key::Paste) => queue_edit(TextEdit::Paste), (COMMAND, Key::Character(c)) if c.eq_ignore_ascii_case("a") => { - queue_edit(TextEdit::SelectAll, true); + queue_edit(TextEdit::SelectAll); } (COMMAND, Key::Character(c)) if c.eq_ignore_ascii_case("c") => { - queue_edit(TextEdit::Copy, true); + queue_edit(TextEdit::Copy); } (COMMAND, Key::Character(c)) if c.eq_ignore_ascii_case("x") => { - queue_edit(TextEdit::Cut, false); + queue_edit(TextEdit::Cut); } (COMMAND, Key::Character(c)) if c.eq_ignore_ascii_case("v") => { - queue_edit(TextEdit::Paste, true); + queue_edit(TextEdit::Paste); } #[cfg(not(target_os = "macos"))] - (SHIFT, Key::Delete) => queue_edit(TextEdit::Cut, false), - (WORD, Key::Backspace) => queue_edit(TextEdit::BackspaceWord, false), - (WORD, Key::Delete) => queue_edit(TextEdit::DeleteWord, false), + (SHIFT, Key::Delete) => queue_edit(TextEdit::Cut), + (WORD, Key::Backspace) => queue_edit(TextEdit::BackspaceWord), + (WORD, Key::Delete) => queue_edit(TextEdit::DeleteWord), #[cfg(target_os = "macos")] (SUPER | SHIFT_SUPER, Key::ArrowLeft) => { - queue_edit(TextEdit::HardLineStart(shift_pressed), true); + queue_edit(TextEdit::HardLineStart(shift_pressed)); } #[cfg(target_os = "macos")] (SUPER | SHIFT_SUPER, Key::ArrowRight) => { - queue_edit(TextEdit::HardLineEnd(shift_pressed), true); + queue_edit(TextEdit::HardLineEnd(shift_pressed)); } #[cfg(not(target_os = "macos"))] - (ALT | SHIFT_ALT, Key::Home) => queue_edit(TextEdit::HardLineStart(shift_pressed), true), + (ALT | SHIFT_ALT, Key::Home) => queue_edit(TextEdit::HardLineStart(shift_pressed)), #[cfg(not(target_os = "macos"))] - (ALT | SHIFT_ALT, Key::End) => queue_edit(TextEdit::HardLineEnd(shift_pressed), true), - (WORD | SHIFT_WORD, Key::ArrowLeft) => queue_edit(TextEdit::WordLeft(shift_pressed), true), + (ALT | SHIFT_ALT, Key::End) => queue_edit(TextEdit::HardLineEnd(shift_pressed)), + (WORD | SHIFT_WORD, Key::ArrowLeft) => queue_edit(TextEdit::WordLeft(shift_pressed)), (WORD | SHIFT_WORD, Key::ArrowRight) => { - queue_edit(TextEdit::WordRight(shift_pressed), true); + queue_edit(TextEdit::WordRight(shift_pressed)); } - (NONE | SHIFT, Key::ArrowLeft) => queue_edit(TextEdit::Left(shift_pressed), true), - (NONE | SHIFT, Key::ArrowRight) => queue_edit(TextEdit::Right(shift_pressed), true), + (NONE | SHIFT, Key::ArrowLeft) => queue_edit(TextEdit::Left(shift_pressed)), + (NONE | SHIFT, Key::ArrowRight) => queue_edit(TextEdit::Right(shift_pressed)), (COMMAND | SHIFT_COMMAND, Key::ArrowUp) => { - queue_edit(TextEdit::TextStart(shift_pressed), true); + queue_edit(TextEdit::TextStart(shift_pressed)); } (COMMAND | SHIFT_COMMAND, Key::ArrowDown) => { - queue_edit(TextEdit::TextEnd(shift_pressed), true); + queue_edit(TextEdit::TextEnd(shift_pressed)); } - (NONE | SHIFT, Key::ArrowUp) => queue_edit(TextEdit::Up(shift_pressed), true), - (NONE | SHIFT, Key::ArrowDown) => queue_edit(TextEdit::Down(shift_pressed), true), + (NONE | SHIFT, Key::ArrowUp) => queue_edit(TextEdit::Up(shift_pressed)), + (NONE | SHIFT, Key::ArrowDown) => queue_edit(TextEdit::Down(shift_pressed)), (COMMAND | SHIFT_COMMAND, Key::Home) => { - queue_edit(TextEdit::TextStart(shift_pressed), true); + queue_edit(TextEdit::TextStart(shift_pressed)); } - (COMMAND | SHIFT_COMMAND, Key::End) => queue_edit(TextEdit::TextEnd(shift_pressed), true), - (NONE | SHIFT, Key::Home) => queue_edit(TextEdit::LineStart(shift_pressed), true), - (NONE | SHIFT, Key::End) => queue_edit(TextEdit::LineEnd(shift_pressed), true), - (NONE, Key::Backspace) => queue_edit(TextEdit::Backspace, false), - (NONE, Key::Delete) => queue_edit(TextEdit::Delete, false), - (NONE, Key::Escape) => queue_edit(TextEdit::CollapseSelection, true), + (COMMAND | SHIFT_COMMAND, Key::End) => queue_edit(TextEdit::TextEnd(shift_pressed)), + (NONE | SHIFT, Key::Home) => queue_edit(TextEdit::LineStart(shift_pressed)), + (NONE | SHIFT, Key::End) => queue_edit(TextEdit::LineEnd(shift_pressed)), + (NONE, Key::Backspace) => queue_edit(TextEdit::Backspace), + (NONE, Key::Delete) => queue_edit(TextEdit::Delete), + (NONE, Key::Escape) => queue_edit(TextEdit::CollapseSelection), (NONE | SHIFT, Key::Character(_)) | (NONE, Key::Space) => { if let Some(text) = &keyboard_input.input.text && !text.is_empty() { - queue_edit(TextEdit::Insert(text.clone()), false); + queue_edit(TextEdit::Insert(text.clone())); } } (NONE, Key::Enter) if allow_newlines => { - queue_edit(TextEdit::Insert("\n".into()), false); + queue_edit(TextEdit::Insert("\n".into())); } _ => { // Ignore and propagate to allow for tab navigation and submit actions. @@ -330,6 +330,8 @@ fn on_ime_input( }; if *text_input != TextInput::Editable { + // Still need to drain the reader to prevent stale events on next focus. + ime_reader.read().for_each(drop); return; } @@ -389,7 +391,7 @@ fn update_ime_position( return; }; - if *text_input == TextInput::DisplayOnly { + if *text_input != TextInput::Editable { return; } From 3d448c0ea31813031bf1258aa436942467d22e9d Mon Sep 17 00:00:00 2001 From: Talin Date: Sat, 11 Jul 2026 09:22:16 -0700 Subject: [PATCH 06/42] Restore original formatting. --- crates/bevy_ui_widgets/src/text_input.rs | 32 ++++++------------------ 1 file changed, 8 insertions(+), 24 deletions(-) diff --git a/crates/bevy_ui_widgets/src/text_input.rs b/crates/bevy_ui_widgets/src/text_input.rs index dab04c32818ee..b789dbfd14d5b 100644 --- a/crates/bevy_ui_widgets/src/text_input.rs +++ b/crates/bevy_ui_widgets/src/text_input.rs @@ -117,12 +117,8 @@ fn on_focused_keyboard_input( (COMMAND, Key::Character(c)) if c.eq_ignore_ascii_case("a") => { queue_edit(TextEdit::SelectAll); } - (COMMAND, Key::Character(c)) if c.eq_ignore_ascii_case("c") => { - queue_edit(TextEdit::Copy); - } - (COMMAND, Key::Character(c)) if c.eq_ignore_ascii_case("x") => { - queue_edit(TextEdit::Cut); - } + (COMMAND, Key::Character(c)) if c.eq_ignore_ascii_case("c") => queue_edit(TextEdit::Copy), + (COMMAND, Key::Character(c)) if c.eq_ignore_ascii_case("x") => queue_edit(TextEdit::Cut), (COMMAND, Key::Character(c)) if c.eq_ignore_ascii_case("v") => { queue_edit(TextEdit::Paste); } @@ -131,34 +127,22 @@ fn on_focused_keyboard_input( (WORD, Key::Backspace) => queue_edit(TextEdit::BackspaceWord), (WORD, Key::Delete) => queue_edit(TextEdit::DeleteWord), #[cfg(target_os = "macos")] - (SUPER | SHIFT_SUPER, Key::ArrowLeft) => { - queue_edit(TextEdit::HardLineStart(shift_pressed)); - } + (SUPER | SHIFT_SUPER, Key::ArrowLeft) => queue_edit(TextEdit::HardLineStart(shift_pressed)), #[cfg(target_os = "macos")] - (SUPER | SHIFT_SUPER, Key::ArrowRight) => { - queue_edit(TextEdit::HardLineEnd(shift_pressed)); - } + (SUPER | SHIFT_SUPER, Key::ArrowRight) => queue_edit(TextEdit::HardLineEnd(shift_pressed)), #[cfg(not(target_os = "macos"))] (ALT | SHIFT_ALT, Key::Home) => queue_edit(TextEdit::HardLineStart(shift_pressed)), #[cfg(not(target_os = "macos"))] (ALT | SHIFT_ALT, Key::End) => queue_edit(TextEdit::HardLineEnd(shift_pressed)), (WORD | SHIFT_WORD, Key::ArrowLeft) => queue_edit(TextEdit::WordLeft(shift_pressed)), - (WORD | SHIFT_WORD, Key::ArrowRight) => { - queue_edit(TextEdit::WordRight(shift_pressed)); - } + (WORD | SHIFT_WORD, Key::ArrowRight) => queue_edit(TextEdit::WordRight(shift_pressed)), (NONE | SHIFT, Key::ArrowLeft) => queue_edit(TextEdit::Left(shift_pressed)), (NONE | SHIFT, Key::ArrowRight) => queue_edit(TextEdit::Right(shift_pressed)), - (COMMAND | SHIFT_COMMAND, Key::ArrowUp) => { - queue_edit(TextEdit::TextStart(shift_pressed)); - } - (COMMAND | SHIFT_COMMAND, Key::ArrowDown) => { - queue_edit(TextEdit::TextEnd(shift_pressed)); - } + (COMMAND | SHIFT_COMMAND, Key::ArrowUp) => queue_edit(TextEdit::TextStart(shift_pressed)), + (COMMAND | SHIFT_COMMAND, Key::ArrowDown) => queue_edit(TextEdit::TextEnd(shift_pressed)), (NONE | SHIFT, Key::ArrowUp) => queue_edit(TextEdit::Up(shift_pressed)), (NONE | SHIFT, Key::ArrowDown) => queue_edit(TextEdit::Down(shift_pressed)), - (COMMAND | SHIFT_COMMAND, Key::Home) => { - queue_edit(TextEdit::TextStart(shift_pressed)); - } + (COMMAND | SHIFT_COMMAND, Key::Home) => queue_edit(TextEdit::TextStart(shift_pressed)), (COMMAND | SHIFT_COMMAND, Key::End) => queue_edit(TextEdit::TextEnd(shift_pressed)), (NONE | SHIFT, Key::Home) => queue_edit(TextEdit::LineStart(shift_pressed)), (NONE | SHIFT, Key::End) => queue_edit(TextEdit::LineEnd(shift_pressed)), From 577ad781e0869873a23f9a5c28890ad90d88ccea Mon Sep 17 00:00:00 2001 From: Talin Date: Sat, 11 Jul 2026 09:26:05 -0700 Subject: [PATCH 07/42] Apply suggestions from code review Co-authored-by: ickshonpe --- crates/bevy_ui_widgets/src/text_input.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/bevy_ui_widgets/src/text_input.rs b/crates/bevy_ui_widgets/src/text_input.rs index b789dbfd14d5b..1b9e40abed212 100644 --- a/crates/bevy_ui_widgets/src/text_input.rs +++ b/crates/bevy_ui_widgets/src/text_input.rs @@ -399,10 +399,11 @@ fn update_ime_position( /// IME is enabled when an `EditableText` gains focus and disabled when focus moves elsewhere. fn listen_for_ime_input_when_text_input_focused( input_focus: Res, - editable_text_query: Query<(), With>, + text_input_changed_query: Query<(), Changed>, + mut editable_text_query: Query<(Ref, &mut EditableText)>, mut windows: Query<&mut Window, With>, ) { - if !input_focus.is_changed() { + if !input_focus.is_changed() && text_input_changed_query.is_empty() { return; } let Ok(mut window) = windows.single_mut() else { @@ -410,9 +411,17 @@ fn listen_for_ime_input_when_text_input_focused( }; let editable_text_focused = input_focus .get() - .is_some_and(|e| editable_text_query.contains(e)); + let editable_text_focused = input_focus + .get() + .and_then(|e| editable_text_query.get_mut(e).ok()) + .is_some_and(|(text_input, mut editable_text)| { + if text_input.is_changed() && *text_input != TextInput::Editable { + editable_text.queue_edit(TextEdit::clear_ime_compose()); + } + *text_input == TextInput::Editable + }); - // The IME should be enabled whenever an EditableText is focused, + // The IME should be enabled whenever an EditableText is focused and editable, // even if the IME isn't currently composing (i.e. there's no preedit text). // The field here is about "should" we accept IME input, not "are we currently" accepting IME input window.ime_enabled = editable_text_focused; From f68c1241afa8112c5df0719360424ee5bfe0d670 Mon Sep 17 00:00:00 2001 From: Talin Date: Sat, 11 Jul 2026 09:26:54 -0700 Subject: [PATCH 08/42] Merge error. --- crates/bevy_ui_widgets/src/text_input.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/bevy_ui_widgets/src/text_input.rs b/crates/bevy_ui_widgets/src/text_input.rs index 1b9e40abed212..516f5f248da40 100644 --- a/crates/bevy_ui_widgets/src/text_input.rs +++ b/crates/bevy_ui_widgets/src/text_input.rs @@ -409,8 +409,6 @@ fn listen_for_ime_input_when_text_input_focused( let Ok(mut window) = windows.single_mut() else { return; }; - let editable_text_focused = input_focus - .get() let editable_text_focused = input_focus .get() .and_then(|e| editable_text_query.get_mut(e).ok()) @@ -419,7 +417,7 @@ fn listen_for_ime_input_when_text_input_focused( editable_text.queue_edit(TextEdit::clear_ime_compose()); } *text_input == TextInput::Editable - }); + }); // The IME should be enabled whenever an EditableText is focused and editable, // even if the IME isn't currently composing (i.e. there's no preedit text). From 0c9d29ec5722f56b3deb3ba796866daebadfd536 Mon Sep 17 00:00:00 2001 From: Talin Date: Sat, 11 Jul 2026 09:45:28 -0700 Subject: [PATCH 09/42] Missing import. --- examples/ui/text/ime_support.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/ui/text/ime_support.rs b/examples/ui/text/ime_support.rs index 87bc10aa659b0..2def1fda5a24d 100644 --- a/examples/ui/text/ime_support.rs +++ b/examples/ui/text/ime_support.rs @@ -15,6 +15,7 @@ use bevy::input_focus::{ }; use bevy::prelude::*; use bevy::text::{EditableText, TextCursorStyle}; +use bevy::ui_widgets::TextInput; fn main() { App::new() From cdd91807868ff4d336727cd451495324ea52a1c5 Mon Sep 17 00:00:00 2001 From: Talin Date: Sun, 12 Jul 2026 11:02:07 -0700 Subject: [PATCH 10/42] Dynamically register AccessibilityNode as a required component of EditableText. --- crates/bevy_ui_widgets/src/text_input.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/bevy_ui_widgets/src/text_input.rs b/crates/bevy_ui_widgets/src/text_input.rs index 516f5f248da40..202a27c995000 100644 --- a/crates/bevy_ui_widgets/src/text_input.rs +++ b/crates/bevy_ui_widgets/src/text_input.rs @@ -50,7 +50,6 @@ const SHIFT_ALT: u8 = SHIFT | ALT; /// Editable text widget. #[derive(Component, Clone, Default, Reflect, PartialEq)] #[require(EditableText)] -#[require(AccessibilityNode(accesskit::Node::new(Role::TextInput)))] #[reflect(Component)] pub enum TextInput { /// Text input functions normally @@ -399,7 +398,7 @@ fn update_ime_position( /// IME is enabled when an `EditableText` gains focus and disabled when focus moves elsewhere. fn listen_for_ime_input_when_text_input_focused( input_focus: Res, - text_input_changed_query: Query<(), Changed>, + text_input_changed_query: Query<(), Or<(Changed, Changed)>>, mut editable_text_query: Query<(Ref, &mut EditableText)>, mut windows: Query<&mut Window, With>, ) { @@ -581,6 +580,9 @@ impl Plugin for EditableTextInputPlugin { app.register_required_components::() .register_required_components::() .register_required_components::() - .register_required_components::(); + .register_required_components::() + .register_required_components_with::(|| { + AccessibilityNode(accesskit::Node::new(Role::TextInput)) + }); } } From 2d794088900aa208e9e08620afe0014ced1aeb9b Mon Sep 17 00:00:00 2001 From: Talin Date: Tue, 14 Jul 2026 14:59:37 -0700 Subject: [PATCH 11/42] `TextInput` is now a marker. Read-only state is now controlled by `TextReadWriteMode`. --- crates/bevy_internal/src/default_plugins.rs | 2 +- crates/bevy_text/src/editing.rs | 14 ++ crates/bevy_ui_widgets/src/lib.rs | 2 +- crates/bevy_ui_widgets/src/text_input.rs | 169 ++++++++++++-------- examples/testbed/ui.rs | 15 ++ examples/ui/text/editable_text_filter.rs | 2 +- examples/ui/text/ime_support.rs | 2 +- examples/ui/text/multiline_text_input.rs | 8 +- examples/ui/text/multiple_text_inputs.rs | 8 +- examples/ui/text/text_input.rs | 6 +- 10 files changed, 148 insertions(+), 80 deletions(-) diff --git a/crates/bevy_internal/src/default_plugins.rs b/crates/bevy_internal/src/default_plugins.rs index 26df25b475326..554aa2b0f7f94 100644 --- a/crates/bevy_internal/src/default_plugins.rs +++ b/crates/bevy_internal/src/default_plugins.rs @@ -127,7 +127,7 @@ impl Plugin for IgnoreAmbiguitiesPlugin { )] fn build(&self, app: &mut bevy_app::App) { #[cfg(all(feature = "bevy_ui_widgets", feature = "bevy_sprite"))] - if app.is_plugin_added::() + if app.is_plugin_added::() && app.is_plugin_added::() { // update_ime_position reads Window to reposition the IME cursor, while diff --git a/crates/bevy_text/src/editing.rs b/crates/bevy_text/src/editing.rs index e0425df584287..0b8f27219325d 100644 --- a/crates/bevy_text/src/editing.rs +++ b/crates/bevy_text/src/editing.rs @@ -311,6 +311,20 @@ pub struct EditableTextGeneration(parley::Generation); #[derive(Component, Clone, Default)] pub struct EditableTextFilter(Option bool + Send + Sync + 'static>>); +/// Indicates whether the text is editable, or is in "readonly" mode. A special "static" mode is +/// also available, which is used by the feathers number input widget. +#[derive(Component, Clone, Copy, Default, PartialEq)] +pub enum TextReadWriteMode { + /// Text input functions normally + #[default] + Editable, + /// Cursor movement, selection, and copy to clipboard is still enabled, but no mutations are allowed + ReadOnly, + /// Display only, all interactions disabled - this is used by number input widget when dragging. + /// This disallows cursor movement and selection as well. + Static, +} + impl EditableTextFilter { /// Create a new `EditableTextFilter` from the given filter function. pub fn new(filter: impl Fn(char) -> bool + Send + Sync + 'static) -> Self { diff --git a/crates/bevy_ui_widgets/src/lib.rs b/crates/bevy_ui_widgets/src/lib.rs index 92d17d051ae6f..046ee2c6c3ec2 100644 --- a/crates/bevy_ui_widgets/src/lib.rs +++ b/crates/bevy_ui_widgets/src/lib.rs @@ -91,7 +91,7 @@ impl PluginGroup for UiWidgetsPlugins { PluginGroupBuilder::start::() .add(ButtonPlugin) .add(CheckboxPlugin) - .add(EditableTextInputPlugin) + .add(TextInputPlugin) .add(ListBoxPlugin) .add(MenuPlugin) .add(DialogPlugin) diff --git a/crates/bevy_ui_widgets/src/text_input.rs b/crates/bevy_ui_widgets/src/text_input.rs index 2509afe0c5b8a..ac01c587a5de4 100644 --- a/crates/bevy_ui_widgets/src/text_input.rs +++ b/crates/bevy_ui_widgets/src/text_input.rs @@ -1,7 +1,7 @@ -//! Input handling for [`EditableText`] widgets. +//! Input handling for [`TextInput`] widgets. //! //! This module provides systems to process keyboard input events and apply text edits -//! to focused [`EditableText`] widgets. +//! to focused [`TextInput`] widgets. //! //! Note that this module is distinct from the core `bevy_text` crate to avoid pulling in //! [`bevy_input`] to that crate, which is intended to be usable in non-interactive contexts. @@ -21,7 +21,7 @@ use bevy_picking::pointer::PointerButton; use bevy_reflect::Reflect; use bevy_text::{ scrollable_text_layout_width, EditableText, EditableTextSystems, PreeditCursor, TextEdit, - TextLayout, TextLayoutInfo, + TextLayout, TextLayoutInfo, TextReadWriteMode, }; use bevy_time::{Real, Time}; use bevy_ui::widget::{sync_editable_text_viewports, update_editable_text_layout}; @@ -52,18 +52,11 @@ const SHIFT_COMMAND: u8 = SHIFT | COMMAND; const SHIFT_ALT: u8 = SHIFT | ALT; /// Editable text widget. -#[derive(Component, Clone, Default, Reflect, PartialEq)] +#[derive(Component, Clone, Default, Reflect)] #[require(EditableText)] #[reflect(Component)] -pub enum TextInput { - /// Text input functions normally - #[default] - Editable, - /// Cursor movement, selection, and copy to clipboard is still enabled, but no mutations are allowed - ReadOnly, - /// Display only, all interactions disabled - this is used by number input widget when dragging. - DisplayOnly, -} +pub struct TextInput; + /// Autoscroll speed is proportional to the input size const AUTOSCROLL_BASE_SPEED: f32 = 0.75; const AUTOSCROLL_MAX_SPEED: f32 = 2.0; @@ -80,10 +73,10 @@ const AUTOSCROLL_RAMP_DISTANCE: f32 = 0.5; /// and then applied later by the [`apply_text_edits`](`bevy_text::apply_text_edits`) system. fn on_focused_keyboard_input( mut keyboard_input: On>, - mut query: Query<(&TextInput, &mut EditableText)>, + mut query: Query<(&mut EditableText, Option<&TextReadWriteMode>), With>, keys: Res>, ) { - let Ok((text_input, mut editable_text)) = query.get_mut(keyboard_input.focused_entity) else { + let Ok((mut editable_text, opt_rwmode)) = query.get_mut(keyboard_input.focused_entity) else { return; // Focused entity is not an EditableText, nothing to do }; @@ -109,9 +102,10 @@ fn on_focused_keyboard_input( let mut should_propagate = true; + let rwmode = opt_rwmode.unwrap_or(&TextReadWriteMode::Editable); let mut queue_edit = |edit: TextEdit| { - if (*text_input == TextInput::Editable - || (!edit.is_destructive() && *text_input == TextInput::ReadOnly)) + if (*rwmode == TextReadWriteMode::Editable + || (!edit.is_destructive() && *rwmode == TextReadWriteMode::ReadOnly)) && keyboard_input.input.state.is_pressed() { editable_text.queue_edit(edit); @@ -188,13 +182,16 @@ fn on_focused_keyboard_input( /// and then applied later by the [`apply_text_edits`](`bevy_text::apply_text_edits`) system. fn on_pointer_press( mut press: On>, - mut text_input_query: Query<( - &TextInput, - &mut EditableText, - &ComputedNode, - &ComputedUiRenderTargetInfo, - &UiGlobalTransform, - )>, + mut text_input_query: Query< + ( + &mut EditableText, + Option<&TextReadWriteMode>, + &ComputedNode, + &ComputedUiRenderTargetInfo, + &UiGlobalTransform, + ), + With, + >, keys: Res>, mut input_focus: ResMut, ui_scale: Res, @@ -203,7 +200,7 @@ fn on_pointer_press( return; } - let Ok((text_input, mut editable_text, node, target, transform)) = + let Ok((mut editable_text, opt_rwmode, node, target, transform)) = text_input_query.get_mut(press.entity) else { // The press landed on something that isn't an `EditableText`. Clicking away to blur a @@ -215,7 +212,8 @@ fn on_pointer_press( input_focus.set(press.entity, FocusCause::Pressed); - if *text_input == TextInput::DisplayOnly { + let rwmode = opt_rwmode.unwrap_or(&TextReadWriteMode::Editable); + if *rwmode == TextReadWriteMode::Static { return; } @@ -256,26 +254,30 @@ fn on_pointer_press( /// and then applied later by the [`apply_text_edits`](`bevy_text::apply_text_edits`) system. fn on_pointer_drag( mut drag: On>, - mut text_input_query: Query<( - &TextInput, - &mut EditableText, - &ComputedNode, - &ComputedUiRenderTargetInfo, - &UiGlobalTransform, - )>, + mut text_input_query: Query< + ( + &mut EditableText, + Option<&TextReadWriteMode>, + &ComputedNode, + &ComputedUiRenderTargetInfo, + &UiGlobalTransform, + ), + With, + >, ui_scale: Res, ) { if drag.button != PointerButton::Primary { return; } - let Ok((text_input, mut editable_text, node, target, transform)) = + let Ok((mut editable_text, opt_rwmode, node, target, transform)) = text_input_query.get_mut(drag.entity) else { return; }; - if *text_input == TextInput::DisplayOnly { + let rwmode = opt_rwmode.unwrap_or(&TextReadWriteMode::Editable); + if *rwmode == TextReadWriteMode::Static { return; } @@ -422,7 +424,10 @@ fn autoscroll_axis(overflow: f32, view_size: f32, time_delta: f32) -> f32 { fn on_ime_input( mut ime_reader: MessageReader, input_focus: Res, - mut editable_text_query: Query<(&TextInput, &mut EditableText)>, + mut editable_text_query: Query< + (&mut EditableText, Option<&TextReadWriteMode>), + With, + >, ) { let Some(focused_entity) = input_focus.get() else { // No focused entity, nothing to do. @@ -431,14 +436,15 @@ fn on_ime_input( return; }; - let Ok((text_input, mut editable_text)) = editable_text_query.get_mut(focused_entity) else { + let Ok((mut editable_text, opt_rwmode)) = editable_text_query.get_mut(focused_entity) else { // Focused entity is not an EditableText, nothing to do. // Still need to drain the reader to prevent stale events on next focus. ime_reader.read().for_each(drop); return; }; - if *text_input != TextInput::Editable { + let rwmode = opt_rwmode.unwrap_or(&TextReadWriteMode::Editable); + if *rwmode != TextReadWriteMode::Editable { // Still need to drain the reader to prevent stale events on next focus. ime_reader.read().for_each(drop); return; @@ -478,13 +484,16 @@ fn on_ime_input( /// composing text rather than overlapping it. fn update_ime_position( input_focus: Res, - editable_text_query: Query<( - &TextInput, - &EditableText, - &ComputedNode, - &UiGlobalTransform, - &ComputedUiRenderTargetInfo, - )>, + editable_text_query: Query< + ( + &EditableText, + Option<&TextReadWriteMode>, + &ComputedNode, + &UiGlobalTransform, + &ComputedUiRenderTargetInfo, + ), + With, + >, // TODO: support multiple windows and track which one has focus mut windows: Query<&mut Window, With>, ui_scale: Res, @@ -493,12 +502,13 @@ fn update_ime_position( return; }; - let Ok((text_input, editable_text, node, transform, target)) = editable_text_query.get(focused) + let Ok((editable_text, opt_rwmode, node, transform, target)) = editable_text_query.get(focused) else { return; }; - if *text_input != TextInput::Editable { + let rwmode = opt_rwmode.unwrap_or(&TextReadWriteMode::Editable); + if *rwmode != TextReadWriteMode::Editable { return; } @@ -517,30 +527,59 @@ fn update_ime_position( } /// System that enables or disables IME on the primary window based on whether the focused entity -/// is an [`EditableText`]. +/// is an editable text field. /// -/// IME is enabled when an `EditableText` gains focus and disabled when focus moves elsewhere. +/// IME is enabled when an editable `EditableText` gains focus and disabled when focus moves +/// elsewhere. The IME state is also re-evaluated whenever the focused entity's read/write mode +/// changes. Because [`TextReadWriteMode`] is an optional component, such a change can happen when the +/// component is overwritten, inserted, or removed; removal reverts the effective mode to the +/// [`Editable`](TextReadWriteMode::Editable) default and is detected via [`RemovedComponents`]. fn listen_for_ime_input_when_text_input_focused( input_focus: Res, - text_input_changed_query: Query<(), Or<(Changed, Changed)>>, - mut editable_text_query: Query<(Ref, &mut EditableText)>, + mut removed_rwmode: RemovedComponents, + mut editable_text_query: Query< + (&mut EditableText, Option>), + With, + >, mut windows: Query<&mut Window, With>, ) { - if !input_focus.is_changed() && text_input_changed_query.is_empty() { - return; - } + // React to removal of `TextReadWriteMode`, and drain the whole reader (using `.count()`) so that + // we don't trigger on subsequent runs. + let focused_entity = input_focus.get(); + let rwmode_removed_from_focused = removed_rwmode + .read() + .filter(|&entity| Some(entity) == focused_entity) + .count() + > 0; + let Ok(mut window) = windows.single_mut() else { return; }; - let editable_text_focused = input_focus - .get() - .and_then(|e| editable_text_query.get_mut(e).ok()) - .is_some_and(|(text_input, mut editable_text)| { - if text_input.is_changed() && *text_input != TextInput::Editable { - editable_text.queue_edit(TextEdit::clear_ime_compose()); - } - *text_input == TextInput::Editable + + // Fetch the focused editable text field (if any). + let focused = focused_entity.and_then(|e| editable_text_query.get_mut(e).ok()); + + // Skip work unless something relevant changed this frame: focus moved, the focused + // field's read/write mode or value changed, or its read/write mode was removed. + let relevant_change = input_focus.is_changed() + || rwmode_removed_from_focused + || focused.as_ref().is_some_and(|(editable_text, opt_rwmode)| { + editable_text.is_changed() || opt_rwmode.as_ref().is_some_and(Ref::is_changed) }); + if !relevant_change { + return; + } + + let editable_text_focused = focused.is_some_and(|(mut editable_text, opt_rwmode)| { + let rwmode_changed = opt_rwmode.as_ref().is_some_and(Ref::is_changed); + let rwmode = opt_rwmode + .as_deref() + .unwrap_or(&TextReadWriteMode::Editable); + if rwmode_changed && *rwmode != TextReadWriteMode::Editable { + editable_text.queue_edit(TextEdit::clear_ime_compose()); + } + *rwmode == TextReadWriteMode::Editable + }); // The IME should be enabled whenever an EditableText is focused and editable, // even if the IME isn't currently composing (i.e. there's no preedit text). @@ -633,7 +672,7 @@ fn apply_queued_select_all( } } -/// System sets for IME-related systems used by [`EditableTextInputPlugin`]. +/// System sets for IME-related systems used by [`TextInputPlugin`]. #[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] pub enum ImeSystems { /// Processes [`Ime`] events into [`TextEdit`] actions for the focused [`EditableText`]. @@ -659,9 +698,9 @@ pub enum ImeSystems { /// /// Note that [`TextEdit`]s are applied during [`PostUpdate`] /// in the [`EditableTextSystems`] system set. -pub struct EditableTextInputPlugin; +pub struct TextInputPlugin; -impl Plugin for EditableTextInputPlugin { +impl Plugin for TextInputPlugin { fn build(&self, app: &mut App) { app.init_resource::() .add_observer(on_focused_keyboard_input) diff --git a/examples/testbed/ui.rs b/examples/testbed/ui.rs index 29b42197c54de..6ada2b3f12cb0 100644 --- a/examples/testbed/ui.rs +++ b/examples/testbed/ui.rs @@ -2648,6 +2648,7 @@ mod editable_text { use bevy::text::EditableText; use bevy::text::TextCursorStyle; use bevy::text::TextEdit; + use bevy::ui_widgets::TextInput; const DUMMY_TEXT: &str = "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten"; const LOREM_TEXT: &str = concat!( @@ -2696,6 +2697,7 @@ mod editable_text { children![ Text::new("Single line"), ( + TextInput, EditableText { pending_edits: vec![TextEdit::Insert( "Single line EditableText".into(), @@ -2733,6 +2735,7 @@ mod editable_text { ), Text::new("Insert end"), ( + TextInput, EditableText { pending_edits: vec![TextEdit::Insert(LOREM_TEXT.into())], ..default() @@ -2752,6 +2755,7 @@ mod editable_text { ), Text::new("Select line start"), ( + TextInput, EditableText { pending_edits: vec![ TextEdit::Insert(LOREM_TEXT.into()), @@ -2783,6 +2787,7 @@ mod editable_text { children![ Text::new("Wrapped start"), ( + TextInput, EditableText { pending_edits: vec![ TextEdit::Insert(LOREM_TEXT.into()), @@ -2814,6 +2819,7 @@ mod editable_text { children![ Text::new("Wrapped selection"), ( + TextInput, EditableText { pending_edits: vec![ TextEdit::Insert(LOREM_TEXT.into()), @@ -2847,6 +2853,7 @@ mod editable_text { children![ Text::new("Clamp top"), ( + TextInput, EditableText { pending_edits: vec![ TextEdit::Insert(DUMMY_TEXT.into()), @@ -2878,6 +2885,7 @@ mod editable_text { children![ Text::new("Home, Scroll 1"), ( + TextInput, EditableText { pending_edits: vec![ TextEdit::Insert(DUMMY_TEXT.into()), @@ -2910,6 +2918,7 @@ mod editable_text { children![ Text::new("Home, Scroll 2"), ( + TextInput, EditableText { pending_edits: vec![ TextEdit::Insert(DUMMY_TEXT.into()), @@ -2942,6 +2951,7 @@ mod editable_text { children![ Text::new("Clamp bottom"), ( + TextInput, EditableText { pending_edits: vec![ TextEdit::Insert(DUMMY_TEXT.into()), @@ -2974,6 +2984,7 @@ mod editable_text { children![ Text::new("Bottom -1"), ( + TextInput, EditableText { pending_edits: vec![ TextEdit::Insert(DUMMY_TEXT.into()), @@ -3007,6 +3018,7 @@ mod editable_text { children![ Text::new("Top +3"), ( + TextInput, EditableText { pending_edits: vec![ TextEdit::Insert(DUMMY_TEXT.into()), @@ -3039,6 +3051,7 @@ mod editable_text { children![ Text::new("Select down 3"), ( + TextInput, EditableText { pending_edits: vec![ TextEdit::Insert(DUMMY_TEXT.into()), @@ -3074,6 +3087,7 @@ mod editable_text { children![ Text::new("End, Scroll 1"), ( + TextInput, EditableText { pending_edits: vec![ TextEdit::Insert(DUMMY_TEXT.into()), @@ -3105,6 +3119,7 @@ mod editable_text { children![ Text::new("End, Scroll -0.5"), ( + TextInput, EditableText { pending_edits: vec![ TextEdit::Insert(DUMMY_TEXT.into()), diff --git a/examples/ui/text/editable_text_filter.rs b/examples/ui/text/editable_text_filter.rs index 057a4ff9d3c9a..b70b70b47c3cc 100644 --- a/examples/ui/text/editable_text_filter.rs +++ b/examples/ui/text/editable_text_filter.rs @@ -33,7 +33,7 @@ fn setup(mut commands: Commands) { padding: px(8.).all(), ..default() }, - TextInput::default(), + TextInput, EditableText { max_characters: Some(8), ..default() diff --git a/examples/ui/text/ime_support.rs b/examples/ui/text/ime_support.rs index 7c88010fc6dbb..8782ac13aa918 100644 --- a/examples/ui/text/ime_support.rs +++ b/examples/ui/text/ime_support.rs @@ -59,7 +59,7 @@ fn setup(mut commands: Commands) { ..default() }, BorderColor::from(Color::from(SLATE_300)), - TextInput::default(), + TextInput, EditableText { allow_newlines: true, ..default() diff --git a/examples/ui/text/multiline_text_input.rs b/examples/ui/text/multiline_text_input.rs index 22291b8572c99..99d893883a85a 100644 --- a/examples/ui/text/multiline_text_input.rs +++ b/examples/ui/text/multiline_text_input.rs @@ -67,7 +67,7 @@ fn setup(mut commands: Commands, asset_server: Res) { padding: px(8.).all(), ..default() }, - TextInput::default(), + TextInput, EditableText { visible_lines: Some(8.), allow_newlines: true, @@ -150,7 +150,7 @@ fn setup(mut commands: Commands, asset_server: Res) { }, BackgroundColor(DARK_SLATE_GRAY.into()), BorderColor::all(SLATE_300), - TextInput::default(), + TextInput, EditableText::new("8"), EditableTextFilter::new(|c| c.is_ascii_digit() || c == '.'), TextCursorStyle { @@ -235,7 +235,7 @@ fn setup(mut commands: Commands, asset_server: Res) { }, BackgroundColor(DARK_SLATE_GRAY.into()), BorderColor::all(SLATE_300), - TextInput::default(), + TextInput, EditableText::new("30"), EditableTextFilter::new(|c| c.is_ascii_digit()), TextCursorStyle { @@ -317,7 +317,7 @@ fn setup(mut commands: Commands, asset_server: Res) { }, BackgroundColor(DARK_SLATE_GRAY.into()), BorderColor::all(SLATE_300), - TextInput::default(), + TextInput, EditableText::new("0"), EditableTextFilter::new(|c| c.is_ascii_digit() || c == '.'), TextCursorStyle { diff --git a/examples/ui/text/multiple_text_inputs.rs b/examples/ui/text/multiple_text_inputs.rs index 123d4dfb7efac..86b5ed2b44dea 100644 --- a/examples/ui/text/multiple_text_inputs.rs +++ b/examples/ui/text/multiple_text_inputs.rs @@ -1,9 +1,9 @@ //! Demonstrates multiple text inputs //! //! This example arranges text inputs in a four-column grid layout. The first column shows the text justification, the second column is an -//! [`EditableText`] text input node, the third column is a `Text` node that is kept synchronized with the [`EditableText`]'s contents by the +//! [`TextInput`] text input node, the third column is a `Text` node that is kept synchronized with the [`TextInput`]'s contents by the //! [`synchronize_output_text`] system, and the fourth column is updated by the [`submit_text`] system when the user submits the -//! [`EditableText`]'s text by pressing `Enter`. +//! [`TextInput`]'s text by pressing `Enter`. use bevy::color::palettes::tailwind::SLATE_300; use bevy::input::keyboard::Key; @@ -19,7 +19,7 @@ use bevy::ui_widgets::TextInput; fn main() { App::new() - // `EditableTextInputPlugin` is part of `DefaultPlugins` + // `TextInputPlugin` is part of `DefaultPlugins` .add_plugins((DefaultPlugins, TabNavigationPlugin)) .add_systems(Startup, setup) .add_systems(Update, (submit_text, update_row_border_colors)) @@ -114,7 +114,7 @@ fn setup(mut commands: Commands, asset_server: Res) { padding: px(4.).all(), ..default() }, - TextInput::default(), + TextInput, EditableText::new(format!("Initial text {row}")), TextCursorStyle::default(), font.clone(), diff --git a/examples/ui/text/text_input.rs b/examples/ui/text/text_input.rs index fa448dbb308cb..9fbf9b2b4e18b 100644 --- a/examples/ui/text/text_input.rs +++ b/examples/ui/text/text_input.rs @@ -1,6 +1,6 @@ -//! Demonstrates a simple, unstyled [`EditableText`] widget. +//! Demonstrates a simple, unstyled [`TextInput`] widget. //! -//! [`EditableText`] is a basic primitive for text input in Bevy UI. +//! [`TextInput`] is a basic primitive for text input in Bevy UI. //! In most cases, this should be combined with other entities to create a compound widget //! that includes e.g. a background, border, and text label. //! @@ -125,7 +125,7 @@ fn build_input_text(commands: &mut Commands, is_left: bool, font_size: f32) -> E }, BorderColor::from(Color::from(SLATE_300)), Name::new(if is_left { "Left" } else { "Right" }), - TextInput::default(), + TextInput, EditableText { visible_width: Some(10.), allow_newlines: false, From a6a83d3350225fed7117792f253ebfa43715f48b Mon Sep 17 00:00:00 2001 From: Talin Date: Sun, 19 Jul 2026 12:33:13 -0700 Subject: [PATCH 12/42] Make `TextReadWriteMode` a required component of `EditableText`. --- .../src/controls/number_input.rs | 7 ++- .../bevy_feathers/src/controls/text_input.rs | 7 +++ crates/bevy_text/src/editing.rs | 3 +- crates/bevy_ui_widgets/src/text_input.rs | 63 +++++-------------- 4 files changed, 31 insertions(+), 49 deletions(-) diff --git a/crates/bevy_feathers/src/controls/number_input.rs b/crates/bevy_feathers/src/controls/number_input.rs index 71b6041f77fc5..1718526a905c4 100644 --- a/crates/bevy_feathers/src/controls/number_input.rs +++ b/crates/bevy_feathers/src/controls/number_input.rs @@ -35,7 +35,7 @@ use bevy_reflect::Reflect; use bevy_scene::prelude::*; use bevy_text::{ EditableText, EditableTextFilter, FontSourceTemplate, Justify, LineHeight, TextEdit, TextFont, - TextLayout, + TextLayout, TextReadWriteMode, }; use bevy_ui::{ percent, px, widget::Text, AlignItems, AlignSelf, BackgroundGradient, ColorStop, ComputedNode, @@ -601,6 +601,7 @@ fn number_input_on_insert_disabled( &mut gradient, &mut commands, ); + commands.entity(text_id).insert(TextReadWriteMode::ReadOnly); } } @@ -632,6 +633,7 @@ fn number_input_on_remove_disabled( &mut gradient, &mut commands, ); + commands.entity(text_id).insert(TextReadWriteMode::Editable); } } @@ -675,6 +677,9 @@ fn number_input_init( &mut gradient, &mut commands, ); + if is_disabled { + commands.entity(text_id).insert(TextReadWriteMode::ReadOnly); + } } } diff --git a/crates/bevy_feathers/src/controls/text_input.rs b/crates/bevy_feathers/src/controls/text_input.rs index fa398a73ad0c2..357faf8e22a79 100644 --- a/crates/bevy_feathers/src/controls/text_input.rs +++ b/crates/bevy_feathers/src/controls/text_input.rs @@ -17,6 +17,7 @@ use bevy_reflect::Reflect; use bevy_scene::prelude::*; use bevy_text::{ EditableText, FontSource, FontWeight, LineBreak, TextCursorStyle, TextFont, TextLayout, + TextReadWriteMode, }; use bevy_ui::{ px, AlignItems, BorderRadius, Display, InteractionDisabled, JustifyContent, Node, UiRect, @@ -156,6 +157,9 @@ fn update_text_input_styles( ) { for (input_ent, disabled, font_color) in q_inputs.iter() { set_text_input_styles(input_ent, disabled, font_color, &mut commands); + commands + .entity(input_ent) + .insert(TextReadWriteMode::ReadOnly); } } @@ -170,6 +174,9 @@ fn update_text_input_styles_remove( removed_disabled.read().for_each(|ent| { if let Ok((input_ent, disabled, font_color)) = q_inputs.get(ent) { set_text_input_styles(input_ent, disabled, font_color, &mut commands); + commands + .entity(input_ent) + .insert(TextReadWriteMode::Editable); } }); } diff --git a/crates/bevy_text/src/editing.rs b/crates/bevy_text/src/editing.rs index 0b8f27219325d..bee7a28e1b4eb 100644 --- a/crates/bevy_text/src/editing.rs +++ b/crates/bevy_text/src/editing.rs @@ -103,7 +103,8 @@ use parley::{FontContext, LayoutContext, PlainEditor, SplitString}; TextColor, LineHeight, FontHinting, - EditableTextGeneration + EditableTextGeneration, + TextReadWriteMode )] pub struct EditableText { /// A [`parley::PlainEditor`], tracking both the text content and cursor position. diff --git a/crates/bevy_ui_widgets/src/text_input.rs b/crates/bevy_ui_widgets/src/text_input.rs index ac01c587a5de4..06fa366b86bd4 100644 --- a/crates/bevy_ui_widgets/src/text_input.rs +++ b/crates/bevy_ui_widgets/src/text_input.rs @@ -15,6 +15,7 @@ use bevy_input::{ButtonInput, InputSystems}; use bevy_input_focus::{ FocusCause, FocusGained, FocusLost, FocusedInput, InputFocus, InputFocusSystems, }; +use bevy_log::warn; use bevy_math::Vec2; use bevy_picking::events::{Drag, Pointer, PointerState, Press, Release}; use bevy_picking::pointer::PointerButton; @@ -73,10 +74,10 @@ const AUTOSCROLL_RAMP_DISTANCE: f32 = 0.5; /// and then applied later by the [`apply_text_edits`](`bevy_text::apply_text_edits`) system. fn on_focused_keyboard_input( mut keyboard_input: On>, - mut query: Query<(&mut EditableText, Option<&TextReadWriteMode>), With>, + mut query: Query<(&mut EditableText, &TextReadWriteMode), With>, keys: Res>, ) { - let Ok((mut editable_text, opt_rwmode)) = query.get_mut(keyboard_input.focused_entity) else { + let Ok((mut editable_text, rwmode)) = query.get_mut(keyboard_input.focused_entity) else { return; // Focused entity is not an EditableText, nothing to do }; @@ -102,7 +103,6 @@ fn on_focused_keyboard_input( let mut should_propagate = true; - let rwmode = opt_rwmode.unwrap_or(&TextReadWriteMode::Editable); let mut queue_edit = |edit: TextEdit| { if (*rwmode == TextReadWriteMode::Editable || (!edit.is_destructive() && *rwmode == TextReadWriteMode::ReadOnly)) @@ -185,7 +185,7 @@ fn on_pointer_press( mut text_input_query: Query< ( &mut EditableText, - Option<&TextReadWriteMode>, + &TextReadWriteMode, &ComputedNode, &ComputedUiRenderTargetInfo, &UiGlobalTransform, @@ -193,26 +193,23 @@ fn on_pointer_press( With, >, keys: Res>, - mut input_focus: ResMut, ui_scale: Res, ) { if press.button != PointerButton::Primary { return; } - let Ok((mut editable_text, opt_rwmode, node, target, transform)) = + let Ok((mut editable_text, rwmode, node, target, transform)) = text_input_query.get_mut(press.entity) else { // The press landed on something that isn't an `EditableText`. Clicking away to blur a // focused text input is handled by `PointerFocusPlugin` (in `bevy_input_focus`), which // triggers a bubbling `AcquireFocus` that clears focus at the window, so there is nothing // to do here. + warn!("Not an editable text"); return; }; - input_focus.set(press.entity, FocusCause::Pressed); - - let rwmode = opt_rwmode.unwrap_or(&TextReadWriteMode::Editable); if *rwmode == TextReadWriteMode::Static { return; } @@ -223,6 +220,7 @@ fn on_pointer_press( // The IME is active; all input needs to be routed there, including pointer presses. return; } + let Some(local_pos) = transform.try_inverse().and_then(|inverse| { let local_pos = inverse .transform_point2(press.pointer_location.position * target.scale_factor() / ui_scale.0); @@ -257,7 +255,7 @@ fn on_pointer_drag( mut text_input_query: Query< ( &mut EditableText, - Option<&TextReadWriteMode>, + &TextReadWriteMode, &ComputedNode, &ComputedUiRenderTargetInfo, &UiGlobalTransform, @@ -270,13 +268,12 @@ fn on_pointer_drag( return; } - let Ok((mut editable_text, opt_rwmode, node, target, transform)) = + let Ok((mut editable_text, rwmode, node, target, transform)) = text_input_query.get_mut(drag.entity) else { return; }; - let rwmode = opt_rwmode.unwrap_or(&TextReadWriteMode::Editable); if *rwmode == TextReadWriteMode::Static { return; } @@ -424,10 +421,7 @@ fn autoscroll_axis(overflow: f32, view_size: f32, time_delta: f32) -> f32 { fn on_ime_input( mut ime_reader: MessageReader, input_focus: Res, - mut editable_text_query: Query< - (&mut EditableText, Option<&TextReadWriteMode>), - With, - >, + mut editable_text_query: Query<(&mut EditableText, &TextReadWriteMode), With>, ) { let Some(focused_entity) = input_focus.get() else { // No focused entity, nothing to do. @@ -436,14 +430,13 @@ fn on_ime_input( return; }; - let Ok((mut editable_text, opt_rwmode)) = editable_text_query.get_mut(focused_entity) else { + let Ok((mut editable_text, rwmode)) = editable_text_query.get_mut(focused_entity) else { // Focused entity is not an EditableText, nothing to do. // Still need to drain the reader to prevent stale events on next focus. ime_reader.read().for_each(drop); return; }; - let rwmode = opt_rwmode.unwrap_or(&TextReadWriteMode::Editable); if *rwmode != TextReadWriteMode::Editable { // Still need to drain the reader to prevent stale events on next focus. ime_reader.read().for_each(drop); @@ -487,7 +480,7 @@ fn update_ime_position( editable_text_query: Query< ( &EditableText, - Option<&TextReadWriteMode>, + &TextReadWriteMode, &ComputedNode, &UiGlobalTransform, &ComputedUiRenderTargetInfo, @@ -502,12 +495,11 @@ fn update_ime_position( return; }; - let Ok((editable_text, opt_rwmode, node, transform, target)) = editable_text_query.get(focused) + let Ok((editable_text, rwmode, node, transform, target)) = editable_text_query.get(focused) else { return; }; - let rwmode = opt_rwmode.unwrap_or(&TextReadWriteMode::Editable); if *rwmode != TextReadWriteMode::Editable { return; } @@ -536,21 +528,12 @@ fn update_ime_position( /// [`Editable`](TextReadWriteMode::Editable) default and is detected via [`RemovedComponents`]. fn listen_for_ime_input_when_text_input_focused( input_focus: Res, - mut removed_rwmode: RemovedComponents, - mut editable_text_query: Query< - (&mut EditableText, Option>), - With, - >, + mut editable_text_query: Query<(&mut EditableText, Ref), With>, mut windows: Query<&mut Window, With>, ) { // React to removal of `TextReadWriteMode`, and drain the whole reader (using `.count()`) so that // we don't trigger on subsequent runs. let focused_entity = input_focus.get(); - let rwmode_removed_from_focused = removed_rwmode - .read() - .filter(|&entity| Some(entity) == focused_entity) - .count() - > 0; let Ok(mut window) = windows.single_mut() else { return; @@ -559,22 +542,8 @@ fn listen_for_ime_input_when_text_input_focused( // Fetch the focused editable text field (if any). let focused = focused_entity.and_then(|e| editable_text_query.get_mut(e).ok()); - // Skip work unless something relevant changed this frame: focus moved, the focused - // field's read/write mode or value changed, or its read/write mode was removed. - let relevant_change = input_focus.is_changed() - || rwmode_removed_from_focused - || focused.as_ref().is_some_and(|(editable_text, opt_rwmode)| { - editable_text.is_changed() || opt_rwmode.as_ref().is_some_and(Ref::is_changed) - }); - if !relevant_change { - return; - } - - let editable_text_focused = focused.is_some_and(|(mut editable_text, opt_rwmode)| { - let rwmode_changed = opt_rwmode.as_ref().is_some_and(Ref::is_changed); - let rwmode = opt_rwmode - .as_deref() - .unwrap_or(&TextReadWriteMode::Editable); + let editable_text_focused = focused.is_some_and(|(mut editable_text, rwmode)| { + let rwmode_changed = rwmode.is_changed(); if rwmode_changed && *rwmode != TextReadWriteMode::Editable { editable_text.queue_edit(TextEdit::clear_ime_compose()); } From 1dfbac3844823bd917816ab058fa3ae4e3166ff3 Mon Sep 17 00:00:00 2001 From: Talin Date: Mon, 20 Jul 2026 09:53:41 -0700 Subject: [PATCH 13/42] Bug fixes, improved example --- .../src/controls/number_input.rs | 5 +- crates/bevy_text/src/editing.rs | 2 +- crates/bevy_ui/src/interaction_states.rs | 8 ++ crates/bevy_ui_render/src/text.rs | 12 ++- crates/bevy_ui_widgets/src/text_input.rs | 2 - examples/ui/text/multiple_text_inputs.rs | 102 +++++++++++++++++- 6 files changed, 120 insertions(+), 11 deletions(-) diff --git a/crates/bevy_feathers/src/controls/number_input.rs b/crates/bevy_feathers/src/controls/number_input.rs index 1718526a905c4..925c0bf83e466 100644 --- a/crates/bevy_feathers/src/controls/number_input.rs +++ b/crates/bevy_feathers/src/controls/number_input.rs @@ -818,16 +818,13 @@ fn scrubber_on_release( &UiGlobalTransform, )>, 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)) = 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) { @@ -837,7 +834,7 @@ fn scrubber_on_release( release.propagate(false); // Copy of logic from EditableText / text_input, but done on pointer up instead of down. - if !disabled && drag_state.max_distance <= DRAG_THRESHOLD_DISTANCE { + if drag_state.max_distance <= DRAG_THRESHOLD_DISTANCE { if release.button != PointerButton::Primary { return; } diff --git a/crates/bevy_text/src/editing.rs b/crates/bevy_text/src/editing.rs index bee7a28e1b4eb..6bf54ee6ec19a 100644 --- a/crates/bevy_text/src/editing.rs +++ b/crates/bevy_text/src/editing.rs @@ -314,7 +314,7 @@ pub struct EditableTextFilter(Option bool + Send + Sync + 's /// Indicates whether the text is editable, or is in "readonly" mode. A special "static" mode is /// also available, which is used by the feathers number input widget. -#[derive(Component, Clone, Copy, Default, PartialEq)] +#[derive(Component, Clone, Copy, Default, PartialEq, Debug)] pub enum TextReadWriteMode { /// Text input functions normally #[default] diff --git a/crates/bevy_ui/src/interaction_states.rs b/crates/bevy_ui/src/interaction_states.rs index 0af2e772f4679..e78c560c58dd8 100644 --- a/crates/bevy_ui/src/interaction_states.rs +++ b/crates/bevy_ui/src/interaction_states.rs @@ -18,6 +18,14 @@ use bevy_reflect::Reflect; /// the `InteractionDisabled` component should be added to the root entity of the widget - the /// same entity that contains the `AccessibilityNode` component. This will ensure that /// the a11y tree is updated correctly. +/// +/// For developers familiar with the web and HTML, the word "disabled" as used here should be +/// interpreted as working more like the `aria-disabled` (which doesn't prevent input focus) +/// than the `disabled` attribute (which does). However, unlike `aria-disabled`, which is purely +/// advisory, this does make the widget non-operable. +/// +/// In particular, this marker currently has no effect on the "click-to-focus" observer which +/// lives in [`bevy_input_focus`], as that is a lower-level crate that this one depends on. #[derive(Component, Debug, Clone, Copy, Default, Reflect)] #[reflect(Component, Default, Clone)] pub struct InteractionDisabled; diff --git a/crates/bevy_ui_render/src/text.rs b/crates/bevy_ui_render/src/text.rs index cbc14406e29d9..023a2bf5d4237 100644 --- a/crates/bevy_ui_render/src/text.rs +++ b/crates/bevy_ui_render/src/text.rs @@ -6,7 +6,7 @@ use bevy_input_focus::InputFocus; use bevy_math::{Affine2, Rect, Vec2}; use bevy_render::{sync_world::TemporaryRenderEntity, Extract}; use bevy_sprite::BorderRect; -use bevy_text::{EditableText, TextColor, TextCursorStyle, TextLayoutInfo}; +use bevy_text::{EditableText, TextColor, TextCursorStyle, TextLayoutInfo, TextReadWriteMode}; use bevy_ui::{ CalculatedClip, ComputedNode, ComputedStackIndex, ComputedUiTargetCamera, ResolvedBorderRadius, UiGlobalTransform, @@ -30,6 +30,7 @@ pub fn extract_text_cursor( &ComputedUiTargetCamera, &TextLayoutInfo, &TextCursorStyle, + &TextReadWriteMode, Option<&EditableText>, )>, >, @@ -48,6 +49,7 @@ pub fn extract_text_cursor( target_camera, text_layout_info, cursor_style, + rwmode, editable_text, ) in text_node_query.iter() { @@ -85,13 +87,16 @@ pub fn extract_text_cursor( focused = true; } - let sc = if focused { + let sc = if focused && *rwmode == TextReadWriteMode::Editable { cursor_style.selection_color } else { cursor_style.unfocused_selection_color }; - if !text_layout_info.selection_rects.is_empty() && !sc.is_fully_transparent() { + if !text_layout_info.selection_rects.is_empty() + && !sc.is_fully_transparent() + && *rwmode != TextReadWriteMode::Static + { let selection_color = sc.to_linear(); let selection_radius = cursor_style.selection_radius.clamp(0.0, 0.5); @@ -166,6 +171,7 @@ pub fn extract_text_cursor( if let Some((true, cursor_rect)) = text_layout_info.cursor && !cursor_rect.is_empty() && !cursor_style.color.is_fully_transparent() + && *rwmode != TextReadWriteMode::Static { extracted_uinodes.uinodes.push(ExtractedUiNode { render_entity: commands.spawn(TemporaryRenderEntity::default()).id(), diff --git a/crates/bevy_ui_widgets/src/text_input.rs b/crates/bevy_ui_widgets/src/text_input.rs index ae81435c017af..83c1387a35440 100644 --- a/crates/bevy_ui_widgets/src/text_input.rs +++ b/crates/bevy_ui_widgets/src/text_input.rs @@ -15,7 +15,6 @@ use bevy_input::{ButtonInput, InputSystems}; use bevy_input_focus::{ FocusCause, FocusGained, FocusLost, FocusedInput, InputFocus, InputFocusSystems, }; -use bevy_log::warn; use bevy_math::Vec2; use bevy_picking::events::{Drag, Pointer, PointerState, Press, Release}; use bevy_picking::pointer::PointerButton; @@ -206,7 +205,6 @@ fn on_pointer_press( // focused text input is handled by `PointerFocusPlugin` (in `bevy_input_focus`), which // triggers a bubbling `AcquireFocus` that clears focus at the window, so there is nothing // to do here. - warn!("Not an editable text"); return; }; diff --git a/examples/ui/text/multiple_text_inputs.rs b/examples/ui/text/multiple_text_inputs.rs index 86b5ed2b44dea..6ec152ffcfd5d 100644 --- a/examples/ui/text/multiple_text_inputs.rs +++ b/examples/ui/text/multiple_text_inputs.rs @@ -14,7 +14,7 @@ use bevy::input_focus::{ InputFocus, }; use bevy::prelude::*; -use bevy::text::{EditableText, TextCursorStyle, TextEditChange}; +use bevy::text::{EditableText, TextCursorStyle, TextEditChange, TextReadWriteMode}; use bevy::ui_widgets::TextInput; fn main() { @@ -176,6 +176,106 @@ fn setup(mut commands: Commands, asset_server: Res) { )); } + let label_font = font.clone().with_font_size(14.); + for label in ["ReadWrite", "EditableText", "value", "submission"] { + parent.spawn(( + Text::new(label), + label_font.clone(), + Node { + justify_self: JustifySelf::Center, + margin: px(-4).bottom(), + ..default() + }, + )); + } + + for (row, rwmode) in [ + TextReadWriteMode::Editable, + TextReadWriteMode::ReadOnly, + TextReadWriteMode::Static, + ] + .into_iter() + .enumerate() + { + parent.spawn(( + Node { + border: px(4).all(), + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + ..default() + }, + BorderColor::all(Color::WHITE), + children![(Text::new(format!("{rwmode:?}")), font.clone(),)], + )); + + let mut input = parent.spawn(( + Node { + border: px(4.).all(), + padding: px(4.).all(), + ..default() + }, + TextInput, + EditableText::new(format!("Initial text {row}")), + TextCursorStyle::default(), + font.clone(), + BackgroundColor(bevy::color::palettes::css::DARK_GREY.into()), + TextInputRow(row + 100), + rwmode, + TabIndex(row as i32 + 100), + BorderColor::all(SLATE_300), + )); + if row == 0 { + input.insert(AutoFocus); + } + + parent.spawn(( + Node { + border: px(4.).all(), + padding: px(4.).all(), + overflow: Overflow::clip_x(), + overflow_clip_margin: OverflowClipMargin { + visual_box: VisualBox::ContentBox, + ..default() + }, + ..default() + }, + BackgroundColor(bevy::color::palettes::css::DARK_SLATE_BLUE.into()), + BorderColor::all(Color::WHITE), + children![( + Text::default(), + TextLayout::no_wrap(), + font.clone(), + BackgroundColor(bevy::color::palettes::css::DARK_SLATE_GRAY.into()), + BorderColor::all(Color::WHITE), + TextInputRow(row + 100), + TextOutput, + )], + )); + + parent.spawn(( + Node { + border: px(4.).all(), + padding: px(4.).all(), + overflow: Overflow::clip_x(), + overflow_clip_margin: OverflowClipMargin { + visual_box: VisualBox::ContentBox, + ..default() + }, + + ..default() + }, + BackgroundColor(bevy::color::palettes::css::DARK_SLATE_BLUE.into()), + BorderColor::all(Color::WHITE), + children![( + Text::default(), + TextLayout::no_wrap(), + font.clone(), + TextInputRow(row + 100), + SubmitOutput, + )], + )); + } + parent.spawn(( Text::new("Press Enter to submit"), Node { From 547fb2db6f27d8652ce955ba89057990e7ebb073 Mon Sep 17 00:00:00 2001 From: Talin Date: Mon, 20 Jul 2026 10:34:32 -0700 Subject: [PATCH 14/42] Add ordering for IME system sets. --- crates/bevy_ui_widgets/src/text_input.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/bevy_ui_widgets/src/text_input.rs b/crates/bevy_ui_widgets/src/text_input.rs index 83c1387a35440..aa32e8a11e312 100644 --- a/crates/bevy_ui_widgets/src/text_input.rs +++ b/crates/bevy_ui_widgets/src/text_input.rs @@ -675,6 +675,15 @@ impl Plugin for TextInputPlugin { .add_observer(on_pointer_press) .add_observer(on_focus_lost) .add_observer(on_focus_select_all) + .configure_sets( + PreUpdate, + ( + ImeSystems::ToggleWindowIMEInput, + ImeSystems::HandleEvents, + ImeSystems::UpdatePosition, + ) + .chain(), + ) .add_systems( PreUpdate, ( From d0aa807a63a00135540ebc8c5176d09a27816f08 Mon Sep 17 00:00:00 2001 From: Talin Date: Tue, 21 Jul 2026 11:40:31 -0700 Subject: [PATCH 15/42] Fix number input to use new editing modes, get rid of focus weirdness. --- .../src/controls/number_input.rs | 192 +++++++++++------- 1 file changed, 113 insertions(+), 79 deletions(-) diff --git a/crates/bevy_feathers/src/controls/number_input.rs b/crates/bevy_feathers/src/controls/number_input.rs index 925c0bf83e466..d76fae220eed4 100644 --- a/crates/bevy_feathers/src/controls/number_input.rs +++ b/crates/bevy_feathers/src/controls/number_input.rs @@ -13,15 +13,13 @@ use bevy_ecs::{ query::{Has, With}, reflect::ReflectComponent, schedule::IntoScheduleConfigs, - system::{Commands, Query, Res, ResMut}, + system::{Commands, Query, Res}, }; use bevy_input::{ keyboard::{Key, KeyCode, KeyboardInput}, ButtonInput, }; -use bevy_input_focus::{ - AcquireFocus, FocusCause, FocusGained, FocusLost, FocusedInput, InputFocus, -}; +use bevy_input_focus::{FocusGained, FocusLost, FocusedInput, InputFocus}; use bevy_log::{warn, warn_once}; use bevy_math::ops; use bevy_picking::{ @@ -160,6 +158,7 @@ impl FeathersNumberInput { @FeathersTextInput { @max_characters: 20usize, } + DragState Node { flex_grow: 1.0, align_items: AlignItems::Center, @@ -202,7 +201,6 @@ impl FeathersNumberInput { ( // 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), @@ -210,7 +208,6 @@ impl FeathersNumberInput { bottom: px(0), right: px(0), } - on(scrubber_on_acquire_focus) on(scrubber_on_press) on(scrubber_on_release) on(scrubber_on_drag_start) @@ -521,12 +518,37 @@ impl Default for NumberInputStep { } } +/// Manage state transitions between scrubbing and dragging modes +#[derive(Default, Clone, Reflect, PartialEq, Debug)] +enum EditMode { + /// Neither scrubbing nor dragging (unfocused) + #[default] + Idle, + /// Drag value via scrubbing + Scrubbing, + /// Edit value by typing + Editing, +} + /// Component used to manage the state of a number during dragging ("scrubbing"). +/// +/// State transitions: +/// +/// if not editing, then a click puts us into "dragging" mode. Once the drag is finished +/// we check max movement, if it's less than the threshold, we transition to "editing" mode. +/// +/// Loss of focus, or hitting the return key, cancels "editing" mode. +/// +/// Changing [`EditMode`] affects: +/// * Cursor shape +/// * [`TextReadWriteMode`] +/// * How drag events are handled +/// * Background and text color (not yet implemented). #[derive(Component, Default, Clone, Reflect)] #[reflect(Component)] -struct ScrubberDragState { +struct DragState { /// Whether the input is currently being dragged. - dragging: bool, + mode: EditMode, /// Conversion factor from pixels dragged to value drag_speed: f64, @@ -721,18 +743,21 @@ fn number_input_on_enter_key( key_input: On>, q_parent: Query<&ChildOf>, q_number_input: Query<(&NumberInputValue, Option<&HardLimit>), With>, - q_text_input: Query<&EditableText>, + mut q_text_input: Query<(&EditableText, &mut DragState)>, mut commands: Commands, ) { if key_input.input.key_code != KeyCode::Enter { return; } - if let Ok(&ChildOf(root)) = q_parent.get(key_input.event_target()) + let text_id = key_input.event_target(); + if let Ok(&ChildOf(root)) = q_parent.get(text_id) && let Ok((input_value, hard_limit)) = q_number_input.get(root) - && let Ok(editable_text) = q_text_input.get(key_input.event_target()) + && let Ok((editable_text, mut drag_state)) = q_text_input.get_mut(text_id) { let text_value = editable_text.value().to_string(); + drag_state.mode = EditMode::Idle; // Boot us out of editing mode + commands.entity(text_id).insert(TextReadWriteMode::Static); // Hide selection emit_value_change( text_value, input_value.format(), @@ -755,14 +780,21 @@ fn number_input_on_focus_gained(focus_gained: On, mut commands: Com fn number_input_on_focus_lost( focus_lost: On, q_parent: Query<&ChildOf>, - q_number_input: Query<(&NumberInputValue, Option<&HardLimit>), With>, + q_number_input: Query< + ( + &NumberInputValue, + Has, + Option<&HardLimit>, + ), + With, + >, mut q_text_input: Query<&mut EditableText>, mut commands: Commands, ) { let editable_text_id = focus_lost.event_target(); if let Ok(&ChildOf(root)) = q_parent.get(editable_text_id) - && let Ok((input_value, hard_limit)) = q_number_input.get(root) + && let Ok((input_value, disabled, 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(); @@ -775,35 +807,43 @@ fn number_input_on_focus_lost( true, ); - // Restore cursor back to normal. + // Restore cursor and rwmode back to normal. commands .entity(editable_text_id) .insert(EntityCursor::System( bevy_window::SystemCursorIcon::ColResize, - )); + )) + .insert(if disabled { + TextReadWriteMode::ReadOnly + } else { + TextReadWriteMode::Editable + }); } } -/// 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>, + mut q_text_input: Query<&mut DragState>, + q_number_input: Query, With>, q_parent: Query<&ChildOf>, - mut focus: ResMut, + mut commands: Commands, ) { if let Ok(&ChildOf(text_id)) = q_parent.get(press.event_target()) - && let Ok(mut drag_state) = q_scrubber.get_mut(press.entity) + && let Ok(&ChildOf(root_id)) = q_parent.get(text_id) + && let Ok(disabled) = q_number_input.get(root_id) + && let Ok(mut drag_state) = q_text_input.get_mut(text_id) { 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(); + if disabled { + drag_state.mode = EditMode::Idle; + commands.entity(text_id).insert(TextReadWriteMode::ReadOnly); + press.propagate(false); + } else if drag_state.mode == EditMode::Editing { + // Let events propagate to text edit if editing mode. + commands.entity(text_id).insert(TextReadWriteMode::Editable); + } else { + drag_state.mode = EditMode::Scrubbing; + commands.entity(text_id).insert(TextReadWriteMode::Static); press.propagate(false); } } @@ -813,21 +853,21 @@ fn scrubber_on_release( mut release: On>, mut q_text: Query<( &mut EditableText, + &mut DragState, &ComputedNode, &ComputedUiRenderTargetInfo, &UiGlobalTransform, )>, - q_scrubber: Query<(&ComputedNode, &UiGlobalTransform, &mut ScrubberDragState)>, q_parent: Query<&ChildOf>, - mut input_focus: ResMut, ui_scale: Res, + mut commands: Commands, ) { if let Ok(&ChildOf(text_id)) = q_parent.get(release.event_target()) - && let Ok((mut editable_text, node, target, transform)) = q_text.get_mut(text_id) - && let Ok((_, _, drag_state)) = q_scrubber.get(release.entity) + && let Ok((mut editable_text, mut drag_state, node, target, transform)) = + q_text.get_mut(text_id) { - // If editable text has focus, then pass the event through. - if input_focus.get() == Some(text_id) { + // If we're in editing mode, let event propagate so that text input can handle it. + if drag_state.mode == EditMode::Editing { return; } @@ -853,8 +893,9 @@ fn scrubber_on_release( return; }; + drag_state.mode = EditMode::Editing; + commands.entity(text_id).insert(TextReadWriteMode::Editable); editable_text.queue_edit(TextEdit::MoveToPoint(local_pos)); - input_focus.set(text_id, FocusCause::Pressed); } } } @@ -868,8 +909,8 @@ fn scrubber_on_drag_start( Option<&NumberInputStep>, Has, )>, - mut q_text_input: Query<&mut BackgroundGradient>, - mut q_scrubber: Query<(&ComputedNode, &mut ScrubberDragState)>, + mut q_text_input: Query<(&mut BackgroundGradient, &mut DragState)>, + mut q_scrubber: Query<&ComputedNode>, q_parent: Query<&ChildOf>, input_focus: Res, theme: Res, @@ -878,14 +919,13 @@ fn scrubber_on_drag_start( 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) + && let Ok((mut gradient, mut drag)) = q_text_input.get_mut(text_id) && !disabled - && input_focus.get() != Some(text_id) - && let Ok((node, mut drag)) = q_scrubber.get_mut(drag_start.event_target()) + && let Ok(node) = q_scrubber.get_mut(drag_start.event_target()) + && drag.mode == EditMode::Scrubbing { 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; @@ -934,22 +974,23 @@ fn scrubber_on_drag( Option<&NumberInputPrecision>, Has, )>, - mut q_scrubber: Query<(&UiGlobalTransform, &mut ScrubberDragState)>, + mut q_text_input: Query<&mut DragState>, + mut q_scrubber: Query<&UiGlobalTransform>, 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) + && let Ok(mut drag_state) = q_text_input.get_mut(text_id) + && let Ok(transform) = q_scrubber.get_mut(drag.entity) + && drag_state.mode == EditMode::Scrubbing { 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 { + if !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) { @@ -977,60 +1018,53 @@ fn scrubber_on_drag_end( Option<&NumberInputPrecision>, Has, )>, - mut q_text_input: Query<(&Hovered, &mut BackgroundGradient)>, - mut q_scrubber: Query<&mut ScrubberDragState>, + mut q_text_input: Query<(&Hovered, &mut BackgroundGradient, &mut DragState)>, q_parent: Query<&ChildOf>, input_focus: Res, mut commands: Commands, theme: Res, ) { if let Ok(&ChildOf(text_id)) = q_parent.get(drag_end.event_target()) - && input_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) + && let Ok((&Hovered(hovered), mut gradient, mut drag_state)) = q_text_input.get_mut(text_id) + && drag_state.mode == EditMode::Scrubbing { 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, - false, - &mut gradient, + if !disabled { + emit_drag_value_change( &mut commands, + root_id, + soft_limit, + hard_limit, + precision, + &mut drag_state, + true, ); - drag_state.dragging = false; } + set_slidebar_styles( + text_id, + &theme, + disabled, + false, + hovered, + input_focus.get() == Some(text_id), + &mut gradient, + &mut commands, + ); } } 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_text_input: Query<(&Hovered, &mut BackgroundGradient, &mut DragState)>, theme: Res, input_focus: Res, mut commands: Commands, ) { 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) + && let Ok((&Hovered(hovered), mut gradient, mut drag_state)) = q_text_input.get_mut(text_id) { set_slidebar_styles( text_id, @@ -1043,7 +1077,7 @@ fn scrubber_on_drag_cancel( &mut commands, ); drag_cancel.propagate(false); - drag_state.dragging = false; + drag_state.mode = EditMode::Idle; } } @@ -1103,7 +1137,7 @@ fn set_slidebar_styles( let cursor_shape = match disabled { true => bevy_window::SystemCursorIcon::NotAllowed, - false => bevy_window::SystemCursorIcon::EwResize, + false => bevy_window::SystemCursorIcon::ColResize, }; if let [Gradient::Linear(linear_gradient)] = &mut gradient.0[..] { @@ -1126,7 +1160,7 @@ fn emit_drag_value_change( soft_limit: Option<&SoftLimit>, hard_limit: Option<&HardLimit>, precision: Option<&NumberInputPrecision>, - drag_state: &mut ScrubberDragState, + drag_state: &mut DragState, is_final: bool, ) { // Relative scrub: always measured from the value at drag start. From 02ce8c01fa8853f247beb4fc2d057325b6be1079 Mon Sep 17 00:00:00 2001 From: Kevin Chen Date: Sun, 19 Jul 2026 23:13:19 -0400 Subject: [PATCH 16/42] Migrate `computed_states` to use `ui_widgets::Button` and `Checkbox` (Phase 5 Item 2) (#25071) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Objective - Part of #24112 - The issue says to migrate to use Feathers, but the example is more akin to `game_menu` in terms of having a “homemade ui” type of feel; the buttons used are for a menu screen. As such, I opted to use ui_widgets instead for this one. Plus, it was an opportunity to show how to use `Checkbox` in an example with more verisimilitude. ## Solution - The usual import of `ui_widgets::Button` to replace the deprecated `Button`. - A new thing of changing the tutorial button into a `Checkbox` to show how that works in practice. ## Testing `cargo run --example computed_states --features=“bevy_dev_tools”` There should be no changes functionally or visually between this and main. --- examples/state/computed_states.rs | 216 +++++++++++++++++------------- 1 file changed, 123 insertions(+), 93 deletions(-) diff --git a/examples/state/computed_states.rs b/examples/state/computed_states.rs index e810f4a6f7185..f85b115f6305c 100644 --- a/examples/state/computed_states.rs +++ b/examples/state/computed_states.rs @@ -16,7 +16,14 @@ //! And lastly, we'll add `Tutorial`, a computed state deriving from `TutorialState`, `InGame` and `IsPaused`, with 2 distinct //! states to display the 2 tutorial texts. -use bevy::{dev_tools::states::*, input::keyboard::Key, prelude::*}; +use bevy::{ + dev_tools::states::*, + input::keyboard::Key, + picking::hover::Hovered, + prelude::*, + ui::{Checked, Pressed}, + ui_widgets::{checkbox_self_update, Activate, ActivateOnPress, Button, Checkbox, ValueChange}, +}; use ui::*; @@ -184,7 +191,10 @@ fn main() { // using our states as normal. .add_systems(Startup, setup) .add_systems(OnEnter(AppState::Menu), setup_menu) - .add_systems(Update, menu.run_if(in_state(AppState::Menu))) + .add_systems(Update, menu_styling.run_if(in_state(AppState::Menu))) + .add_observer(menu_activate.run_if(in_state(AppState::Menu))) + .add_observer(menu_tutorial_checked.run_if(in_state(AppState::Menu))) + .add_observer(checkbox_self_update) .add_systems(OnExit(AppState::Menu), cleanup_menu) // We only want to run the [`setup_game`] function when we enter the [`AppState::InGame`] state, regardless // of whether the game is paused or not. @@ -218,54 +228,60 @@ fn main() { .run(); } -fn menu( - mut next_state: ResMut>, - tutorial_state: Res>, +/// Updates the app state when the play button is activated. +fn menu_activate(_event: On, mut next_state: ResMut>) { + // The play button is the only button on the menu that would send the `Activate` event. + next_state.set(AppState::InGame { + paused: false, + turbo: false, + }); +} + +/// Updates the app state when the tutorial button is checked/unchecked. +/// The `checkbox_self_update` observer added to the app handles +/// updating the `Checked` component on the `Checkbox` itself. +fn menu_tutorial_checked( + event: On>, mut next_tutorial: ResMut>, - mut interaction_query: Query< - (&Interaction, &mut BackgroundColor, &MenuButton), - (Changed, With