diff --git a/Cargo.toml b/Cargo.toml index 748314db2ec88..bd63f5eb560d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1012,6 +1012,17 @@ description = "Renders text to multiple windows with different scale factors usi category = "2D Rendering" wasm = true +[[example]] +name = "editable_text" +path = "examples/ui/text/editable_text.rs" +doc-scrape-examples = true + +[package.metadata.example.editable_text] +name = "Editable Text" +description = "Demonstrates a simple, unstyled text input widget" +category = "UI (User Interface)" +wasm = true + [[example]] name = "texture_atlas" path = "examples/2d/texture_atlas.rs" diff --git a/crates/bevy_sprite/src/lib.rs b/crates/bevy_sprite/src/lib.rs index db281746bdd17..2c0f0ac0ced57 100644 --- a/crates/bevy_sprite/src/lib.rs +++ b/crates/bevy_sprite/src/lib.rs @@ -93,6 +93,7 @@ impl Plugin for SpritePlugin { .chain() .after(detect_text_needs_rerender) .after(bevy_text::load_font_assets_into_font_collection) + .after(bevy_text::apply_text_edits) .after(bevy_app::AnimationSystems) .before(bevy_asset::AssetEventSystems), ) diff --git a/crates/bevy_text/src/cursor.rs b/crates/bevy_text/src/cursor.rs new file mode 100644 index 0000000000000..ef68689538e7e --- /dev/null +++ b/crates/bevy_text/src/cursor.rs @@ -0,0 +1,28 @@ +use bevy_color::{ + palettes::css::{GREEN, RED}, + Alpha as _, Color, +}; +use bevy_ecs::component::Component; + +/// Controls text cursor appearance. +/// +/// When this component on the same entity as an [`EditableText`](`crate::EditableText`) , +/// and the [`UiRenderPlugin`](https://docs.rs/bevy/latest/bevy/ui_render/struct.UiRenderPlugin.html) +/// is active, a simple rectangle will be drawn for the cursor. +/// This is an optional component, to allow for stylistic cursors. +#[derive(Component, Clone, Copy, Debug, PartialEq)] +pub struct TextCursorStyle { + /// Color of the cursor + pub color: Color, + /// Background color of selected text + pub selection_color: Color, +} + +impl Default for TextCursorStyle { + fn default() -> Self { + Self { + color: RED.into(), + selection_color: Color::from(GREEN).with_alpha(0.5), + } + } +} diff --git a/crates/bevy_text/src/lib.rs b/crates/bevy_text/src/lib.rs index a7d73659bda1a..942b42a2b3115 100644 --- a/crates/bevy_text/src/lib.rs +++ b/crates/bevy_text/src/lib.rs @@ -32,6 +32,7 @@ extern crate alloc; mod bounds; +mod cursor; mod error; mod font; mod font_atlas; @@ -42,8 +43,11 @@ mod parley_context; mod pipeline; mod text; mod text_access; +mod text_edit; +mod text_editable; pub use bounds::*; +pub use cursor::*; pub use error::*; pub use font::*; pub use font_atlas::*; @@ -54,6 +58,8 @@ pub use parley_context::*; pub use pipeline::*; pub use text::*; pub use text_access::*; +pub use text_edit::*; +pub use text_editable::*; /// The text prelude. /// @@ -86,6 +92,10 @@ pub struct TextPlugin; #[derive(Debug, Hash, PartialEq, Eq, Clone, SystemSet)] pub struct Text2dUpdateSystems; +/// System set where [`EditableText::pending_edits`] are applied. +#[derive(Debug, Hash, PartialEq, Eq, Clone, SystemSet)] +pub struct EditableTextSystems; + impl Plugin for TextPlugin { fn build(&self, app: &mut App) { app.init_asset::() @@ -105,7 +115,8 @@ impl Plugin for TextPlugin { ) .chain(), ) - .add_systems(Last, trim_source_cache); + .add_systems(Last, trim_source_cache) + .add_systems(PostUpdate, apply_text_edits.in_set(EditableTextSystems)); #[cfg(feature = "default_font")] { diff --git a/crates/bevy_text/src/pipeline.rs b/crates/bevy_text/src/pipeline.rs index ba196af85cbeb..82c5ff9fcf694 100644 --- a/crates/bevy_text/src/pipeline.rs +++ b/crates/bevy_text/src/pipeline.rs @@ -207,7 +207,7 @@ impl TextPipeline { ); builder.push(StyleProperty::FontSize(section.font_size), range.clone()); builder.push( - StyleProperty::LineHeight(section.line_height.eval(section.font_size)), + StyleProperty::LineHeight(section.line_height.eval()), range.clone(), ); builder.push( @@ -410,18 +410,20 @@ impl TextPipeline { } layout_info.size = Vec2::new(layout.full_width(), layout.height()).ceil(); + Ok(()) } } -fn resolve_font_source<'a>( +/// Resolve a [`FontSource`], producing a [`FontFamily`], by looking it up in the [`Assets`] collection. +pub fn resolve_font_source<'a>( font: &'a FontSource, - fonts: &'a Assets, + fonts: &Assets, ) -> Result, TextError> { Ok(match font { FontSource::Handle(handle) => { let font = fonts.get(handle.id()).ok_or(TextError::NoSuchFont)?; - FontFamily::Named(Cow::Borrowed(font.family_name.as_str())) + FontFamily::Named(Cow::Owned(font.family_name.as_str().to_owned())) } FontSource::Family(family) => FontFamily::Named(Cow::Borrowed(family.as_str())), FontSource::Serif => FontFamily::Generic(parley::GenericFamily::Serif), @@ -459,6 +461,10 @@ pub struct TextLayoutInfo { pub run_geometry: Vec, /// The glyphs resulting size pub size: Vec2, + /// Cursor size and position for editing + pub cursor: Option, + /// Selection rects + pub selection_rects: Vec, } impl TextLayoutInfo { @@ -468,6 +474,8 @@ impl TextLayoutInfo { self.glyphs.clear(); self.run_geometry.clear(); self.size = Vec2::ZERO; + self.cursor = None; + self.selection_rects.clear(); } } diff --git a/crates/bevy_text/src/text.rs b/crates/bevy_text/src/text.rs index 73a5dce9948d9..c3ad4b8793094 100644 --- a/crates/bevy_text/src/text.rs +++ b/crates/bevy_text/src/text.rs @@ -928,7 +928,8 @@ pub enum LineHeight { } impl LineHeight { - pub(crate) fn eval(self, _font_size: f32) -> parley::LineHeight { + /// eval a line height + pub fn eval(self) -> parley::LineHeight { match self { LineHeight::Px(px) => parley::LineHeight::Absolute(px), LineHeight::RelativeToFont(scale) => parley::LineHeight::FontSizeRelative(scale), diff --git a/crates/bevy_text/src/text_edit.rs b/crates/bevy_text/src/text_edit.rs new file mode 100644 index 0000000000000..ee70a4f569d07 --- /dev/null +++ b/crates/bevy_text/src/text_edit.rs @@ -0,0 +1,65 @@ +use bevy_reflect::Reflect; +use parley::PlainEditorDriver; +use smol_str::SmolStr; + +use crate::TextBrush; + +/// Deferred text input edit and navigation actions applied by the `apply_text_edits` system. +#[derive(Debug, Clone, PartialEq, Eq, Reflect)] +pub enum TextEdit { + /// Insert a character at the cursor. If there is a selection, replaces the selection with the character instead. + /// + /// Typically generated in response to keyboard text input events. + /// + /// This is intended to insert a single Unicode grapheme cluster, such as a letter, digit, punctuation mark, or emoji. + Insert(SmolStr), + /// Delete the character behind the cursor. + /// If there is a selection, deletes the selection instead. + /// + /// Typically generated in response to the backspace key. + /// + /// This operation removes an entire Unicode grapheme cluster, which may consist of multiple bytes, + /// shifting the cursor position accordingly. + Backspace, + /// Delete the character at the cursor. + /// If there is a selection, deletes the selection instead. + /// + /// Typically generated in response to the delete key. + /// + /// This operation removes an entire Unicode grapheme cluster, which may consist of multiple bytes, + /// shifting the cursor position accordingly. + Delete, + /// Moves the cursor by one position to the right. + /// + /// Typically generated in response to the right key. + MoveCursorRight, + /// Moves the cursor by one position to the left. + /// + /// Typically generated in response to the left key. + MoveCursorLeft, + /// Move selection on to the right. + /// + /// Typically generated in response to shift and the right key. + SelectRight, + /// Move selection on to the left. + /// + /// Typically generated in response to shift and the left key. + SelectLeft, +} + +/// Takes a `TextEdit` and applies to `PlainEditorDriver` +pub fn apply_edit<'a>( + edit: TextEdit, + mut driver: PlainEditorDriver<'a, TextBrush>, +) -> PlainEditorDriver<'a, TextBrush> { + match edit { + TextEdit::Insert(str) => driver.insert_or_replace_selection(&str), + TextEdit::Backspace => driver.backdelete(), + TextEdit::Delete => driver.delete(), + TextEdit::MoveCursorRight => driver.move_right(), + TextEdit::MoveCursorLeft => driver.move_left(), + TextEdit::SelectRight => driver.select_right(), + TextEdit::SelectLeft => driver.select_left(), + } + driver +} diff --git a/crates/bevy_text/src/text_editable.rs b/crates/bevy_text/src/text_editable.rs new file mode 100644 index 0000000000000..f4ee9edc5c83d --- /dev/null +++ b/crates/bevy_text/src/text_editable.rs @@ -0,0 +1,190 @@ +//! A simple text input widget for Bevy UI. +//! +//! The [`EditableText`] widget is an undecorated rectangular text input field, +//! which allows users to input and edit text within a Bevy UI application. +//! Every [`EditableText`] component is also a [`Node`](https://docs.rs/bevy/latest/bevy/prelude/struct.Node.html) in the Bevy UI hierarchy, +//! allowing you to position and size it using standard Bevy UI layout techniques. +//! You can think of it as the editable equivalent of [`Text`](https://docs.rs/bevy/latest/bevy/prelude/struct.Text.html), +//! and components such as [`TextFont`] and [`TextColor`] can be used to style it. +//! +//! [`EditableText`] supports the following functionality: +//! +//! - Text entry +//! - Basic keyboard-driven cursor movement (arrow keys, home/end keys) +//! - Backspace and delete operations +//! +//! You might use this widget as the basis for text input fields in forms, chat boxes, for naming characters, +//! or any other scenario where you want to extract an unformatted text string from the user. +//! +//! Reusable widgets that build on top of this basic text input field (as might be found in Bevy's Feathers UI framework), +//! will typically combine this widget with additional UI elements such as borders, backgrounds, and labels, +//! creating a multi-entity widget that matches the semantics and visual appearance required by the application. +//! +//! ## Handling user input +//! +//! User input is handled via a plugin in `bevy_ui_widgets`: +//! [`bevy_text`](crate) is not aware of input events directly. +//! +//! With the correct plugin enabled, when an [`EditableText`] entity is focused, +//! keyboard input events are captured and processed into [`TextEdit`] actions. +//! +//! ## Limitations +//! +//! The formatting of the text is uniform throughout the entire input field. +//! As a result, rich text-editing is out-of-scope: +//! this widget is not intended to form the basis for a full-featured text editor. +//! +//! Similarly, this widget is "headless": it has no built-in styling, and is intended to be used +//! with a themed UI framework of your choice (e.g. Feathers). This means that no text boxes, borders, or other +//! visual elements are provided by default, and must be added separately using Bevy UI entities / components, +//! and any reactive styling (e.g., focus/hover states) must also be implemented separately. +//! +//! However, the following features are planned but currently not implemented: +//! +//! - Home / End key support for moving the cursor to the start / end of the text +//! - Placeholder text (displayed when the input is empty) +//! - Click to place cursor +//! - Cursor blinking +//! - Clipboard operations (copy, cut, paste) +//! - Undo/redo functionality +//! - Newline support for multi-line input +//! - Input Method Editor (IME) support for complex scripts +//! - Text validation (e.g., email format, numeric input, max length) +//! - Password-style character masking +//! - Soft-wrapping of long lines +//! - Vertical scrolling for multi-line input +//! - Horizontal scrolling for long lines +//! - Mobile pop-up keyboard support +//! - Overwrite mode (typically toggled by the `Insert` key) +//! - Bidirectional text support (e.g., mixing left-to-right and right-to-left scripts) +//! - AccessKit integration for screen readers and other assistive technologies +//! - World-space text input +//! - Text input labels (used for accessibility, tooltips or form descriptions) +//! - Input consumption (preventing other systems from receiving keyboard input events when the text input is focused) +//! - Text form submission handling +//! +//! If you require any of these features, please consider contributing it to the crate, +//! one feature at a time! +// Note: this logic is in `bevy_text`, rather than higher up in `bevy_ui` or `bevy_ui_widgets`, +// because doing so allows us to process `EditableText` in the various systems provided by `bevy_text` +// and `bevy_ui`, such as text layout and font management. + +use crate::{ + apply_edit, text_edit::TextEdit, FontCx, FontHinting, LayoutCx, LineHeight, TextBrush, + TextColor, TextFont, TextLayout, +}; +use bevy_ecs::prelude::*; +use parley::{FontContext, LayoutContext, PlainEditor, SplitString}; + +/// A plain-text text input field. +/// +/// Please see this module docs for more details on usage and functionality. +/// +/// Note that text editing operations are trickier than they might first appear, +/// due to the complexities of Unicode text handling. +/// +/// As a result, we store an internal [`PlainEditor`] instance, +/// which manages both the text content and the cursor position, +/// and provides methods for applying text edits and cursor movements correctly +/// according to Unicode rules. +#[derive(Component)] +#[require(TextLayout, TextFont, TextColor, LineHeight, FontHinting)] +pub struct EditableText { + /// A [`parley::PlainEditor`], tracking both the text content and cursor position. + /// + /// This serves as an analogue to [`ComputedTextBlock`](crate::ComputedTextBlock) for editable text. + /// + /// In most cases, you should queue text edits via the [`EditableText::queue_edit`] method instead of directly manipulating the editor, + /// and then allow the [`apply_text_edits`] system to apply the edits at the appropriate time in the update cycle. + /// + /// Note that many more complex editing operations require working with [`PlainEditor::driver`]. + /// These operations should generally be batched together to avoid redundant layout work. + // The B: Brush generic here must match the brush used by `ComputedTextBlock` to ensure that the font system is compatible. + pub editor: PlainEditor, + /// Text edit actions that have been requested but not yet applied. + /// + /// These edits are processed in first-in, first-out order. + pub pending_edits: Vec, + /// Cursor width, relative to font size + pub cursor_width: f32, +} + +impl Default for EditableText { + fn default() -> Self { + Self { + // Defaults selected to match `Text::default()` + editor: PlainEditor::new(100.), + pending_edits: Vec::new(), + cursor_width: 0.2, + } + } +} + +impl EditableText { + /// Access the internal [`PlainEditor`]. + pub fn editor(&self) -> &PlainEditor { + &self.editor + } + + /// Mutably access the internal [`PlainEditor`]. + /// + pub fn editor_mut(&mut self) -> &mut PlainEditor { + &mut self.editor + } + + /// Get the current text input as a [`SplitString`]. + /// + /// A [`SplitString`] can be converted into a [`String`] using `to_string` if needed. + pub fn value(&self) -> SplitString<'_> { + self.editor.text() + } + + /// Queue a [`TextEdit`] action to be applied later by the [`apply_text_edits`] system. + pub fn queue_edit(&mut self, edit: TextEdit) { + self.pending_edits.push(edit); + } + + /// Applies all [`TextEdit`]s in `pending_edits` immediately, updating the [`PlainEditor`] text / cursor state accordingly. + /// + /// [`FontContext`] should be gathered from the [`FontCx`] resource, and [`LayoutContext`] should be gathered from the [`LayoutCx`] resource. + pub fn apply_pending_edits( + &mut self, + font_context: &mut FontContext, + layout_context: &mut LayoutContext, + ) { + let Self { + editor, + pending_edits, + .. + } = self; + + let mut driver = editor.driver(font_context, layout_context); + + for edit in pending_edits.drain(..) { + driver = apply_edit(edit, driver); + } + } + + /// Clears the current input and resets the cursor position. + pub fn clear( + &mut self, + font_context: &mut FontContext, + layout_context: &mut LayoutContext, + ) { + self.editor.set_text(""); + let mut driver = self.editor_mut().driver(font_context, layout_context); + driver.move_to_byte(0); + self.pending_edits.clear(); + } +} + +/// Applies pending text edit actions to all [`EditableText`] widgets. +pub fn apply_text_edits( + mut query: Query<&mut EditableText>, + mut font_context: ResMut, + mut layout_context: ResMut, +) { + for mut editable_text in query.iter_mut() { + editable_text.apply_pending_edits(&mut font_context.0, &mut layout_context.0); + } +} diff --git a/crates/bevy_ui/Cargo.toml b/crates/bevy_ui/Cargo.toml index 893145bad2cd7..fe22a7adb4062 100644 --- a/crates/bevy_ui/Cargo.toml +++ b/crates/bevy_ui/Cargo.toml @@ -50,6 +50,7 @@ thiserror = { version = "2", default-features = false } derive_more = { version = "2", default-features = false, features = ["from"] } smallvec = { version = "1", default-features = false } accesskit = "0.24" +parley = { version = "0.7.0", default-features = false, features = ["std"] } tracing = { version = "0.1", default-features = false, features = ["std"] } [dev-dependencies] diff --git a/crates/bevy_ui/src/lib.rs b/crates/bevy_ui/src/lib.rs index d2e1e46589baf..0420c8698fa25 100644 --- a/crates/bevy_ui/src/lib.rs +++ b/crates/bevy_ui/src/lib.rs @@ -10,6 +10,8 @@ //! Spawn UI elements with [`widget::Button`], [`ImageNode`](widget::ImageNode), [`Text`](prelude::Text) and [`Node`] //! This UI is laid out with the Flexbox and CSS Grid layout models (see ) +extern crate alloc; + pub mod auto_directional_navigation; pub mod interaction_states; pub mod measurement; @@ -35,7 +37,7 @@ mod layout; mod stack; mod ui_node; -use bevy_text::detect_text_needs_rerender; +use bevy_text::{detect_text_needs_rerender, EditableTextSystems}; pub use focus::*; pub use geometry::*; pub use gradients::*; @@ -44,6 +46,7 @@ pub use layout::*; pub use measurement::*; pub use ui_node::*; pub use ui_transform::*; +pub use widget::TextNodeFlags; /// The UI prelude. /// @@ -244,6 +247,11 @@ fn build_text_interop(app: &mut App) { // Text2d and bevy_ui text are entirely on separate entities .ambiguous_with(bevy_sprite::update_text2d_layout) .ambiguous_with(bevy_sprite::calculate_bounds_text2d), + widget::editable_text_system + .in_set(UiSystems::PostLayout) + .ambiguous_with(ui_stack_system) + .ambiguous_with(widget::text_system) + .ambiguous_with(bevy_sprite::calculate_bounds_text2d), ), ); @@ -265,4 +273,7 @@ fn build_text_interop(app: &mut App) { PostUpdate, AmbiguousWithUpdateText2dLayout.ambiguous_with(bevy_sprite::update_text2d_layout), ); + + // We cannot set this up in bevy_text as this would create a circular dependency between bevy_ui and bevy_text + app.configure_sets(PostUpdate, EditableTextSystems.in_set(UiSystems::Prepare)); } diff --git a/crates/bevy_ui/src/widget/mod.rs b/crates/bevy_ui/src/widget/mod.rs index bbd319e986f09..04398a111fe78 100644 --- a/crates/bevy_ui/src/widget/mod.rs +++ b/crates/bevy_ui/src/widget/mod.rs @@ -4,10 +4,12 @@ mod button; mod image; mod label; mod text; +mod text_editable; mod viewport; pub use button::*; pub use image::*; pub use label::*; pub use text::*; +pub use text_editable::*; pub use viewport::*; diff --git a/crates/bevy_ui/src/widget/text.rs b/crates/bevy_ui/src/widget/text.rs index d040ce5c84d61..aa39b8ce9347a 100644 --- a/crates/bevy_ui/src/widget/text.rs +++ b/crates/bevy_ui/src/widget/text.rs @@ -9,7 +9,7 @@ use bevy_ecs::{ change_detection::DetectChanges, component::Component, entity::Entity, - query::With, + query::{With, Without}, reflect::ReflectComponent, system::{Query, Res, ResMut}, world::Ref, @@ -19,11 +19,12 @@ use bevy_log::warn_once; use bevy_math::Vec2; use bevy_reflect::{std_traits::ReflectDefault, Reflect}; use bevy_text::{ - ComputedTextBlock, Font, FontAtlasSet, FontCx, FontHinting, LayoutCx, LetterSpacing, LineBreak, - LineHeight, RemSize, ScaleCx, TextBounds, TextColor, TextError, TextFont, TextLayout, - TextLayoutInfo, TextMeasureInfo, TextPipeline, TextReader, TextRoot, TextSpanAccess, - TextWriter, + ComputedTextBlock, EditableText, Font, FontAtlasSet, FontCx, FontHinting, LayoutCx, + LetterSpacing, LineBreak, LineHeight, RemSize, ScaleCx, TextBounds, TextColor, TextError, + TextFont, TextLayout, TextLayoutInfo, TextMeasureInfo, TextPipeline, TextReader, TextRoot, + TextSpanAccess, TextWriter, }; + use taffy::style::AvailableSpace; use tracing::error; @@ -338,14 +339,17 @@ pub fn text_system( mut textures: ResMut>, mut font_atlas_set: ResMut, mut text_pipeline: ResMut, - mut text_query: Query<( - Ref, - &TextLayout, - &mut TextLayoutInfo, - &mut TextNodeFlags, - &mut ComputedTextBlock, - Ref, - )>, + mut text_query: Query< + ( + Ref, + &TextLayout, + &mut TextLayoutInfo, + &mut TextNodeFlags, + &mut ComputedTextBlock, + Ref, + ), + Without, + >, mut scale_cx: ResMut, ) { for (node, block, mut text_layout_info, mut text_flags, mut computed, hinting) in diff --git a/crates/bevy_ui/src/widget/text_editable.rs b/crates/bevy_ui/src/widget/text_editable.rs new file mode 100644 index 0000000000000..26f2e4647c521 --- /dev/null +++ b/crates/bevy_ui/src/widget/text_editable.rs @@ -0,0 +1,207 @@ +use core::hash::BuildHasher; + +use crate::{ComputedNode, ComputedUiRenderTargetInfo}; +use bevy_asset::Assets; + +use bevy_ecs::{ + change_detection::DetectChanges, + system::{Query, Res, ResMut}, + world::Ref, +}; +use bevy_image::prelude::*; +use bevy_math::{Rect, Vec2}; +use bevy_platform::hash::FixedHasher; +use bevy_text::{ + add_glyph_to_atlas, get_glyph_atlas_info, resolve_font_source, EditableText, Font, + FontAtlasKey, FontAtlasSet, FontCx, FontHinting, GlyphCacheKey, LayoutCx, LineHeight, + PositionedGlyph, RemSize, RunGeometry, ScaleCx, TextBrush, TextFont, TextLayoutInfo, +}; +use parley::{swash::FontRef, BoundingBox}; +use parley::{FontFamily, FontStack, PositionedLayoutItem}; + +/// Updates [`EditableText::editor`] to match e.g. [`TextFont`] +/// Writes layout to [`TextLayoutInfo`] for rendering +/// Adds required glyphs to the texture atlas +// TODO: add change detection logic here to improve performance +pub fn editable_text_system( + fonts: Res>, + mut font_cx: ResMut, + mut layout_cx: ResMut, + mut scale_cx: ResMut, + mut font_atlas_set: ResMut, + mut textures: ResMut>, + mut input_field_query: Query<( + &TextFont, + &LineHeight, + &FontHinting, + Ref, + &mut EditableText, + &mut TextLayoutInfo, + Ref, + )>, + rem_size: Res, +) { + for (text_font, line_height, hinting, target, mut editable_text, mut info, computed_node) in + input_field_query.iter_mut() + { + let Ok(font_family) = resolve_font_source(&text_font.font, fonts.as_ref()) else { + continue; + }; + + let family = match font_family { + FontFamily::Named(name) => FontFamily::Named(name.into_owned().into()), + FontFamily::Generic(generic) => FontFamily::Generic(generic), + }; + let style_set = editable_text.editor.edit_styles(); + style_set.insert(parley::StyleProperty::LineHeight(line_height.eval())); + style_set.insert(parley::StyleProperty::FontStack(FontStack::Single(family))); + + let logical_viewport_size = target.logical_size(); + let font_size = text_font.font_size.eval(logical_viewport_size, rem_size.0); + style_set.insert(parley::StyleProperty::FontSize(font_size)); + style_set.insert(parley::StyleProperty::Brush(TextBrush::new( + 0, + text_font.font_smoothing, + ))); + + if target.is_changed() { + editable_text.editor.set_scale(target.scale_factor()); + } + + if computed_node.is_changed() { + editable_text.editor.set_width(Some(computed_node.size().x)); + } + + let mut driver = editable_text + .editor + .driver(&mut font_cx.0, &mut layout_cx.0); + + driver.refresh_layout(); + + let layout = driver.layout(); + + info.scale_factor = layout.scale(); + info.size = ( + layout.width() / layout.scale(), + layout.height() / layout.scale(), + ) + .into(); + + info.glyphs.clear(); + info.run_geometry.clear(); + + for line in layout.lines() { + for (line_index, item) in line.items().enumerate() { + match item { + PositionedLayoutItem::GlyphRun(glyph_run) => { + let brush = glyph_run.style().brush; + + let run = glyph_run.run(); + + let font_data = run.font(); + let font_size = run.font_size(); + let coords = run.normalized_coords(); + + let font_atlas_key = FontAtlasKey { + id: font_data.data.id() as u32, + index: font_data.index, + font_size_bits: font_size.to_bits(), + variations_hash: FixedHasher.hash_one(coords), + hinting: *hinting, + font_smoothing: brush.font_smoothing, + }; + + for glyph in glyph_run.positioned_glyphs() { + let font_atlases = font_atlas_set.entry(font_atlas_key).or_default(); + let Ok(atlas_info) = get_glyph_atlas_info( + font_atlases, + GlyphCacheKey { + glyph_id: glyph.id as u16, + }, + ) + .map(Ok) + .unwrap_or_else(|| { + let font_ref = FontRef::from_index( + font_data.data.as_ref(), + font_data.index as usize, + ) + .unwrap(); + let mut scaler = scale_cx + .builder(font_ref) + .size(font_size) + .hint(matches!(hinting, FontHinting::Enabled)) + .normalized_coords(coords) + .build(); + add_glyph_to_atlas( + font_atlases, + textures.as_mut(), + &mut scaler, + text_font.font_smoothing, + glyph.id as u16, + ) + }) else { + continue; + }; + + info.glyphs.push(PositionedGlyph { + position: Vec2::new(glyph.x, glyph.y) + + atlas_info.rect.size() / 2. + + atlas_info.offset, + atlas_info, + section_index: brush.section_index as usize, + line_index, + byte_index: line.text_range().start, + byte_length: line.text_range().len(), + }); + } + + info.run_geometry.push(RunGeometry { + section_index: brush.section_index as usize, + bounds: Rect { + min: Vec2::new(glyph_run.offset(), line.metrics().min_coord), + max: Vec2::new( + glyph_run.offset() + glyph_run.advance(), + line.metrics().max_coord, + ), + }, + strikethrough_y: glyph_run.baseline() + - run.metrics().strikethrough_offset, + strikethrough_thickness: run.metrics().strikethrough_size, + underline_y: glyph_run.baseline() - run.metrics().underline_offset, + underline_thickness: run.metrics().underline_size, + }); + } + PositionedLayoutItem::InlineBox(_inline) => { + // TODO: handle inline boxes + } + } + } + } + + let geom = editable_text + .editor + .cursor_geometry(editable_text.cursor_width * font_size); + + info.cursor = geom.map(bounding_box_to_rect); + + info.selection_rects = editable_text + .editor + .selection_geometry() + .iter() + .map(|&b| bounding_box_to_rect(b.0)) + .collect(); + } +} + +fn bounding_box_to_rect(geom: BoundingBox) -> Rect { + Rect { + min: Vec2 { + x: geom.x0 as f32, + y: geom.y0 as f32, + }, + max: Vec2 { + x: geom.x1 as f32, + y: geom.y1 as f32, + }, + } +} diff --git a/crates/bevy_ui_render/src/lib.rs b/crates/bevy_ui_render/src/lib.rs index 9b39386e239ba..adc6d77efcfe2 100644 --- a/crates/bevy_ui_render/src/lib.rs +++ b/crates/bevy_ui_render/src/lib.rs @@ -12,6 +12,7 @@ mod color_space; mod gradient; mod pipeline; mod render_pass; +mod text; pub mod ui_material; mod ui_material_pipeline; pub mod ui_texture_slice_pipeline; @@ -77,6 +78,7 @@ pub use ui_material_pipeline::*; use ui_texture_slice_pipeline::UiTextureSlicerPlugin; use crate::shader_flags::INVERT; +use crate::text::extract_text_cursor; pub mod prelude { #[cfg(feature = "bevy_ui_debug")] @@ -111,6 +113,8 @@ pub mod stack_z_offsets { pub const MATERIAL: f32 = 0.05; pub const TEXT: f32 = 0.06; pub const TEXT_STRIKETHROUGH: f32 = 0.07; + pub const TEXT_SELECTION: f32 = 0.08; + pub const TEXT_CURSOR: f32 = 0.085; } #[derive(Debug, Hash, PartialEq, Eq, Clone, SystemSet)] @@ -125,6 +129,7 @@ pub enum RenderUiSystems { ExtractTextBackgrounds, ExtractTextShadows, ExtractText, + ExtractCursor, ExtractDebug, ExtractGradient, } @@ -222,6 +227,7 @@ impl Plugin for UiRenderPlugin { RenderUiSystems::ExtractTextBackgrounds, RenderUiSystems::ExtractTextShadows, RenderUiSystems::ExtractText, + RenderUiSystems::ExtractCursor, RenderUiSystems::ExtractDebug, ) .chain(), @@ -238,6 +244,7 @@ impl Plugin for UiRenderPlugin { extract_text_decorations.in_set(RenderUiSystems::ExtractTextBackgrounds), extract_text_shadows.in_set(RenderUiSystems::ExtractTextShadows), extract_text_sections.in_set(RenderUiSystems::ExtractText), + extract_text_cursor.in_set(RenderUiSystems::ExtractCursor), #[cfg(feature = "bevy_ui_debug")] debug_overlay::extract_debug_overlay.in_set(RenderUiSystems::ExtractDebug), ), @@ -1073,7 +1080,13 @@ pub fn extract_text_shadows( } for run in text_layout_info.run_geometry.iter() { - let section_entity = computed_block.entities()[run.section_index].entity; + let Some(section_entity) = computed_block + .entities() + .get(run.section_index) + .map(|t| t.entity) + else { + continue; + }; let Ok((has_strikethrough, has_underline)) = text_decoration_query.get(section_entity) else { continue; @@ -1183,7 +1196,13 @@ pub fn extract_text_decorations( Affine2::from(global_transform) * Affine2::from_translation(-0.5 * uinode.size()); for run in text_layout_info.run_geometry.iter() { - let section_entity = computed_block.entities()[run.section_index].entity; + let Some(section_entity) = computed_block + .entities() + .get(run.section_index) + .map(|t| t.entity) + else { + continue; + }; let Ok(( (text_background_color, maybe_strikethrough, maybe_underline), text_color, diff --git a/crates/bevy_ui_render/src/text.rs b/crates/bevy_ui_render/src/text.rs new file mode 100644 index 0000000000000..1b2276e6cd743 --- /dev/null +++ b/crates/bevy_ui_render/src/text.rs @@ -0,0 +1,118 @@ +use bevy_asset::AssetId; +use bevy_camera::visibility::InheritedVisibility; +use bevy_color::Alpha; +use bevy_ecs::prelude::*; +use bevy_math::{Affine2, Rect, Vec2}; +use bevy_render::{sync_world::TemporaryRenderEntity, Extract}; +use bevy_sprite::BorderRect; +use bevy_text::{TextCursorStyle, TextLayoutInfo}; +use bevy_ui::{ + CalculatedClip, ComputedNode, ComputedUiTargetCamera, ResolvedBorderRadius, UiGlobalTransform, +}; + +use crate::{ + stack_z_offsets, ExtractedUiItem, ExtractedUiNode, ExtractedUiNodes, NodeType, UiCameraMap, +}; + +pub fn extract_text_cursor( + mut commands: Commands, + mut extracted_uinodes: ResMut, + text_node_query: Extract< + Query<( + Entity, + &ComputedNode, + &UiGlobalTransform, + &InheritedVisibility, + Option<&CalculatedClip>, + &ComputedUiTargetCamera, + &TextLayoutInfo, + &TextCursorStyle, + )>, + >, + camera_map: Extract, +) { + let mut camera_mapper = camera_map.get_mapper(); + + for ( + entity, + uinode, + global_transform, + inherited_visibility, + maybe_clip, + target_camera, + text_layout_info, + cursor_style, + ) in text_node_query.iter() + { + // Skip if not visible or if size is set to zero (e.g. when a parent is set to `Display::None`) + if !inherited_visibility.get() || uinode.is_empty() { + continue; + } + + let Some(extracted_camera_entity) = camera_mapper.map(target_camera) else { + continue; + }; + + let transform = + Affine2::from(global_transform) * Affine2::from_translation(-0.5 * uinode.size()); + + if !text_layout_info.selection_rects.is_empty() + && !cursor_style.selection_color.is_fully_transparent() + { + let selection_color = cursor_style.selection_color.to_linear(); + + for selection in text_layout_info.selection_rects.iter() { + extracted_uinodes.uinodes.push(ExtractedUiNode { + render_entity: commands.spawn(TemporaryRenderEntity).id(), + z_order: uinode.stack_index as f32 + stack_z_offsets::TEXT_SELECTION, + clip: maybe_clip.map(|clip| clip.clip), + image: AssetId::default(), + extracted_camera_entity, + transform: transform * Affine2::from_translation(selection.center()), + item: ExtractedUiItem::Node { + color: selection_color, + rect: Rect { + min: Vec2::ZERO, + max: selection.size(), + }, + atlas_scaling: None, + flip_x: false, + flip_y: false, + border: BorderRect::default(), + border_radius: ResolvedBorderRadius::default(), + node_type: NodeType::Rect, + }, + main_entity: entity.into(), + }); + } + } + + if let Some(cursor_rect) = text_layout_info.cursor + && !cursor_rect.is_empty() + && !cursor_style.color.is_fully_transparent() + { + extracted_uinodes.uinodes.push(ExtractedUiNode { + render_entity: commands.spawn(TemporaryRenderEntity).id(), + z_order: uinode.stack_index as f32 + stack_z_offsets::TEXT_CURSOR, + clip: maybe_clip.map(|clip| clip.clip), + image: AssetId::default(), + extracted_camera_entity, + transform: transform * Affine2::from_translation(cursor_rect.center()), + item: ExtractedUiItem::Node { + color: cursor_style.color.to_linear(), + rect: Rect { + min: Vec2::ZERO, + max: cursor_rect.size(), + }, + atlas_scaling: None, + flip_x: false, + flip_y: false, + border: BorderRect::default(), + border_radius: ResolvedBorderRadius::default(), + node_type: NodeType::Rect, + }, + main_entity: entity.into(), + }); + } + } +} diff --git a/crates/bevy_ui_widgets/Cargo.toml b/crates/bevy_ui_widgets/Cargo.toml index 136019c6693cf..37f42608109f7 100644 --- a/crates/bevy_ui_widgets/Cargo.toml +++ b/crates/bevy_ui_widgets/Cargo.toml @@ -21,9 +21,12 @@ bevy_math = { path = "../bevy_math", version = "0.19.0-dev" } bevy_picking = { path = "../bevy_picking", version = "0.19.0-dev" } bevy_reflect = { path = "../bevy_reflect", version = "0.19.0-dev" } bevy_ui = { path = "../bevy_ui", version = "0.19.0-dev" } +bevy_text = { path = "../bevy_text", version = "0.19.0-dev" } # other accesskit = "0.24" +parley = { version = "0.7", default-features = false } +smol_str = "0.2" [features] default = [] diff --git a/crates/bevy_ui_widgets/src/editable_text.rs b/crates/bevy_ui_widgets/src/editable_text.rs new file mode 100644 index 0000000000000..4a105aaee04e8 --- /dev/null +++ b/crates/bevy_ui_widgets/src/editable_text.rs @@ -0,0 +1,120 @@ +//! Input handling for [`EditableText`] widgets. +//! +//! This module provides systems to process keyboard input events and apply text edits +//! to focused [`EditableText`] 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. + +use bevy_app::{App, Plugin}; +use bevy_ecs::prelude::*; +use bevy_input::keyboard::{Key, KeyCode, KeyboardInput}; +use bevy_input::ButtonInput; +use bevy_input_focus::FocusedInput; +use bevy_text::{EditableText, TextEdit}; +use bevy_ui::{widget::TextNodeFlags, ContentSize, Node}; +use smol_str::SmolStr; + +/// 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 +/// used by this system. +/// +/// Note that this does not immediately apply the edits; they are queued up in [`EditableText::pending_edits`], +/// and then applied later by the [`apply_text_edits`](`bevy_text::apply_text_edits`) system. +fn on_focused_keyboard_input( + mut trigger: On>, + mut query: Query<&mut EditableText>, + keyboard_button_input: Res>, +) { + let Ok(mut editable_text) = query.get_mut(trigger.focused_entity) else { + return; // Focused entity is not an EditableText, nothing to do + }; + + let shift = keyboard_button_input.pressed(KeyCode::ShiftLeft) + || keyboard_button_input.pressed(KeyCode::ShiftRight); + + let mut consumed = true; + match &trigger.input { + KeyboardInput { + logical_key: Key::Character(c), + state: bevy_input::ButtonState::Pressed, + .. + } => { + editable_text.queue_edit(TextEdit::Insert(c.clone())); + } + KeyboardInput { + logical_key: Key::Space, + state: bevy_input::ButtonState::Pressed, + .. + } => { + editable_text.queue_edit(TextEdit::Insert(SmolStr::new_inline(" "))); + } + KeyboardInput { + logical_key: Key::Backspace, + state: bevy_input::ButtonState::Pressed, + .. + } => { + editable_text.queue_edit(TextEdit::Backspace); + } + KeyboardInput { + logical_key: Key::Delete, + state: bevy_input::ButtonState::Pressed, + .. + } => { + editable_text.queue_edit(TextEdit::Delete); + } + KeyboardInput { + logical_key: Key::ArrowRight, + state: bevy_input::ButtonState::Pressed, + .. + } => { + if shift { + editable_text.queue_edit(TextEdit::SelectRight); + } else { + editable_text.queue_edit(TextEdit::MoveCursorRight); + } + } + KeyboardInput { + logical_key: Key::ArrowLeft, + state: bevy_input::ButtonState::Pressed, + .. + } => { + if shift { + editable_text.queue_edit(TextEdit::SelectLeft); + } else { + editable_text.queue_edit(TextEdit::MoveCursorLeft); + } + } + _ => { + consumed = false; + } + } + + if consumed { + trigger.propagate(false); + } +} + +/// Enables support for the [`EditableText`] widget. +/// +/// Contains the systems and observers necessary to update widget state and handle user input. +/// +/// This plugin is included in the [`UiWidgetsPlugins`](crate::UiWidgetsPlugins) group, but can also be added individually +/// if only editable text input is needed. +/// +/// Note that [`TextEdit`]s are applied during [`PostUpdate`](bevy_app::PostUpdate) +/// in the [`EditableTextSystems`](bevy_text::EditableTextSystems) system set. +pub struct EditableTextInputPlugin; + +impl Plugin for EditableTextInputPlugin { + fn build(&self, app: &mut App) { + app.add_observer(on_focused_keyboard_input); + + // These components cannot be registered in `bevy_text` where `EditableText` is defined, + // because that would create a circular dependency between `bevy_text` and `bevy_ui`. + app.register_required_components::() + .register_required_components::() + .register_required_components::(); + } +} diff --git a/crates/bevy_ui_widgets/src/lib.rs b/crates/bevy_ui_widgets/src/lib.rs index 8f30e2842701a..52d3d665f31ff 100644 --- a/crates/bevy_ui_widgets/src/lib.rs +++ b/crates/bevy_ui_widgets/src/lib.rs @@ -20,6 +20,7 @@ mod button; mod checkbox; +mod editable_text; mod menu; mod observe; pub mod popover; @@ -29,6 +30,7 @@ mod slider; pub use button::*; pub use checkbox::*; +pub use editable_text::*; pub use menu::*; pub use observe::*; pub use radio::*; @@ -54,6 +56,7 @@ impl PluginGroup for UiWidgetsPlugins { .add(RadioGroupPlugin) .add(ScrollbarPlugin) .add(SliderPlugin) + .add(EditableTextInputPlugin) } } diff --git a/deny.toml b/deny.toml index 426ec33d8aa52..aa04dd8f8dcea 100644 --- a/deny.toml +++ b/deny.toml @@ -80,7 +80,9 @@ wildcards = "deny" # Certain crates that we don't want multiple versions of in the dependency tree deny = [ { name = "ahash", deny-multiple-versions = true }, + { name = "accesskit", deny-multiple-versions = true }, { name = "android-activity", deny-multiple-versions = true }, + { name = "parley", deny-multiple-versions = true }, { name = "glam", deny-multiple-versions = true }, { name = "raw-window-handle", deny-multiple-versions = true }, ] diff --git a/examples/README.md b/examples/README.md index 99c3de12e3019..8ec11decef5d1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -591,6 +591,7 @@ Example | Description [Directional Navigation Overrides](../examples/ui/navigation/directional_navigation_overrides.rs) | Demonstration of automatic directional navigation between UI elements with manual overrides [Display and Visibility](../examples/ui/layout/display_and_visibility.rs) | Demonstrates how Display and Visibility work in the UI. [Drag to Scroll](../examples/ui/scroll_and_overflow/drag_to_scroll.rs) | This example tests scale factor, dragging and scrolling +[Editable Text](../examples/ui/text/editable_text.rs) | Demonstrates a simple, unstyled text input widget [Feathers Widgets](../examples/ui/widgets/feathers.rs) | Gallery of Feathers Widgets [Flex Layout](../examples/ui/layout/flex_layout.rs) | Demonstrates how the AlignItems and JustifyContent properties can be composed to layout nodes and position text [Font Atlas Debug](../examples/ui/text/font_atlas_debug.rs) | Illustrates how FontAtlases are populated (used to optimize text rendering internally) diff --git a/examples/ui/text/editable_text.rs b/examples/ui/text/editable_text.rs new file mode 100644 index 0000000000000..5b30f6092773e --- /dev/null +++ b/examples/ui/text/editable_text.rs @@ -0,0 +1,207 @@ +//! Demonstrates a simple, unstyled [`EditableText`] widget. +//! +//! [`EditableText`] 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. +//! +//! See the module documentation for [`editable_text`](bevy::ui_widgets::editable_text) for more details. +use bevy::color::palettes::css::YELLOW; +use bevy::input_focus::{ + tab_navigation::{TabGroup, TabIndex, TabNavigationPlugin}, + InputDispatchPlugin, InputFocus, +}; +use bevy::prelude::*; +use bevy::text::{EditableText, FontCx, LayoutCx, TextCursorStyle}; +use bevy::ui_widgets::EditableTextInputPlugin; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_plugins(( + // This is also part of UiWidgetsPlugins, but we only need EditableText for this example + EditableTextInputPlugin, + // Input focus is required to direct keyboard input to the correct EditableText + InputDispatchPlugin, + TabNavigationPlugin, + )) + .add_systems(Startup, setup) + .add_systems(Update, text_submission) + .run(); +} + +#[derive(Component)] +struct TextOutput; + +fn setup( + mut commands: Commands, + asset_server: Res, + mut input_focus: ResMut, +) { + // Set up a camera + // We need a camera to see the UI + commands.spawn(Camera2d); + + // Create a root UI node, so we can place the input above the output in a column + let root = commands + .spawn(Node { + display: Display::Block, + ..default() + }) + .id(); + + let font: FontSource = asset_server.load("fonts/FiraMono-Medium.ttf").into(); + + // Instructions + let text_instructions = commands + .spawn(( + Node { + width: px(400), + height: px(100), + ..Default::default() + }, + BorderColor::from(Color::from(YELLOW)), + Text::new("Ctrl+Enter to submit text"), + TextFont { + font: asset_server.load("fonts/FiraSans-Bold.ttf").into(), + font_size: FontSize::Px(30.0), + ..default() + }, + UiTransform::from_translation(Val2 { + x: Val::Px(0.0), + y: Val::Px(0.0), + }), + )) + .id(); + + // Set up an EditableText widget + let (text_input_left, text_input_left_edit) = + build_input_text(&mut commands, &font, true, 30.0); + let (text_input_right, _text_input_right_edit) = + build_input_text(&mut commands, &font, false, 50.0); + + // Set the focus to our text input so we can start typing right away + input_focus.set(text_input_left_edit); + + let input_container = commands + .spawn(( + Node { + display: Display::Flex, + ..default() + }, + TabGroup::new(0), + )) + .id(); + + // Set up a text output to see the result of our text input + let text_output = commands + .spawn(( + Node { + width: px(400), + height: px(100), + border: UiRect { + left: px(5), + right: px(5), + top: px(5), + bottom: px(5), + }, + ..Default::default() + }, + BorderColor::from(Color::from(YELLOW)), + Text::new("testing"), + TextOutput, + TextFont { + font: asset_server.load("fonts/FiraSans-Bold.ttf").into(), + font_size: FontSize::Px(70.0), + ..default() + }, + UiTransform::from_translation(Val2 { + x: Val::Px(5.0), + y: Val::Px(200.0), + }), + )) + .id(); + + // Assemble our hierarchy + commands + .entity(input_container) + .add_children(&[text_input_left, text_input_right]); + + commands + .entity(root) + .add_children(&[text_instructions, input_container, text_output]); +} + +fn build_input_text( + commands: &mut Commands, + font: &FontSource, + is_left: bool, + font_size: f32, +) -> (Entity, Entity) { + let outer = commands + .spawn(( + Node { + border: UiRect { + left: px(5), + right: px(5), + top: px(5), + bottom: px(5), + }, + ..Default::default() + }, + BorderColor::from(Color::from(YELLOW)), + UiTransform::from_translation(Val2 { + x: Val::Px(if is_left { 0.0 } else { 300.0 }), + y: Val::Px(50.0), + }), + )) + .id(); + + let edit = commands + .spawn(( + Node { + width: px(200), + height: px(100), + ..Default::default() + }, + Name::new(if is_left { "Left" } else { "Right" }), + EditableText::default(), + TextFont { + font: font.clone(), + font_size: FontSize::Px(font_size), + ..default() + }, + TextCursorStyle::default(), + TabIndex(if is_left { 0 } else { 1 }), + UiTransform::from_translation(Val2 { + x: Val::Px(10.0), + y: Val::Px(10.0), + }), + )) + .id(); + + commands.entity(outer).add_children(&[edit]); + + (outer, edit) +} + +// Submit the text when Ctrl+Enter is pressed +fn text_submission( + input_focus: Res, + keyboard_input: Res>, + mut text_input: Query<(&mut EditableText, &Name)>, + mut text_output: Single<&mut Text, With>, + mut font_context: ResMut, + mut layout_context: ResMut, +) { + if keyboard_input.just_pressed(KeyCode::Enter) + && (keyboard_input.pressed(KeyCode::ControlLeft) + || keyboard_input.pressed(KeyCode::ControlRight)) + && let Some(focused_entity) = input_focus.get() + && let Ok((mut text_input, name)) = text_input.get_mut(focused_entity) + { + let input = text_input.value().clone().to_string(); + text_output.0 = format!("{:}: {:}", name, input); + + text_input.clear(&mut font_context.0, &mut layout_context.0); + } +} diff --git a/release-content/release-notes/text_input.md b/release-content/release-notes/text_input.md new file mode 100644 index 0000000000000..7213014dd8600 --- /dev/null +++ b/release-content/release-notes/text_input.md @@ -0,0 +1,26 @@ +--- +title: "Text input" +authors: ["@ickshonpe", "@Zeophlite", "@alice-i-cecile"] +pull_requests: [23282] +--- + +Entering text into an application is a common task, even for games. +Player names, search bars and chat all rely on the ability to enter and submit plain text. + +In Bevy 0.19, we've added basic support for text entry, in the form of the `EditableText` widget. +Spawning an entity with this component will create a simple unstyled rectangle of editable text. +Our initial text entry supports: + +- Press keys on your keyboard, get text (wow!) +- A cursor that can be moved forward and back in response to the left and right arrow keys +- Selection rectangles (hold shift) +- Backspace and Delete +- Unicode-aware navigation and editing: 1 byte/char != 1 character +- Bidirectional text support, allowing both left-to-right and right-to-left scripts + +`EditableText` integrate with Bevy's `InputFocus` resource, accepting keyboard inputs only when the selected +`EditableText` entity is focused. + +Many important features are currently unimplemented (placeholder text, clipboard support, undo-redo...). +While we've been careful to expose and document the internals so that you can readily implement these features in your own projects, +we would like to continue to expand the functionality of the base widget: please consider making a PR!