From edc879cab0285af6d92c20f86c4650bf5f619f7d Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Wed, 30 Jul 2025 18:25:35 +0100 Subject: [PATCH 01/53] Added `input` module to `bevy_text` with systems and components to support text editing. --- crates/bevy_text/Cargo.toml | 1 + crates/bevy_text/src/input.rs | 1340 ++++++++++++++++++++++++++++++ crates/bevy_text/src/lib.rs | 16 + crates/bevy_text/src/pipeline.rs | 32 +- crates/bevy_text/src/text.rs | 13 +- 5 files changed, 1395 insertions(+), 7 deletions(-) create mode 100644 crates/bevy_text/src/input.rs diff --git a/crates/bevy_text/Cargo.toml b/crates/bevy_text/Cargo.toml index 58bcfb1c5b7b2..413870c0911ad 100644 --- a/crates/bevy_text/Cargo.toml +++ b/crates/bevy_text/Cargo.toml @@ -34,6 +34,7 @@ bevy_platform = { path = "../bevy_platform", version = "0.17.0-dev", default-fea # other cosmic-text = { version = "0.14", features = ["shape-run-cache"] } +cosmic_undo_2 = { version = "0.2.0" } thiserror = { version = "2", default-features = false } serde = { version = "1", features = ["derive"] } smallvec = { version = "1", default-features = false } diff --git a/crates/bevy_text/src/input.rs b/crates/bevy_text/src/input.rs new file mode 100644 index 0000000000000..cbd219b35d218 --- /dev/null +++ b/crates/bevy_text/src/input.rs @@ -0,0 +1,1340 @@ +use crate::buffer_dimensions; +use crate::load_font_to_fontdb; +use crate::CosmicFontSystem; +use crate::Font; +use crate::FontAtlasSets; +use crate::FontSmoothing; +use crate::Justify; +use crate::LineBreak; +use crate::LineHeight; +use crate::PositionedGlyph; +use crate::TextBounds; +use crate::TextError; +use crate::TextFont; +use crate::TextLayoutInfo; +use crate::TextPipeline; +use alloc::collections::VecDeque; +use bevy_asset::Assets; +use bevy_asset::Handle; +use bevy_derive::Deref; +use bevy_derive::DerefMut; +use bevy_ecs::change_detection::DetectChanges; +use bevy_ecs::change_detection::DetectChangesMut; +use bevy_ecs::component::Component; +use bevy_ecs::entity::Entity; +use bevy_ecs::event::EntityEvent; +use bevy_ecs::hierarchy::ChildOf; +use bevy_ecs::lifecycle::HookContext; +use bevy_ecs::prelude::ReflectComponent; +use bevy_ecs::query::Changed; +use bevy_ecs::query::Or; +use bevy_ecs::resource::Resource; +use bevy_ecs::schedule::SystemSet; +use bevy_ecs::system::Commands; +use bevy_ecs::system::Query; +use bevy_ecs::system::Res; +use bevy_ecs::system::ResMut; +use bevy_ecs::world::DeferredWorld; +use bevy_ecs::world::Ref; +use bevy_image::Image; +use bevy_image::TextureAtlasLayout; +use bevy_math::IVec2; +use bevy_math::Rect; +use bevy_math::UVec2; +use bevy_math::Vec2; +use bevy_reflect::prelude::ReflectDefault; +use bevy_reflect::Reflect; +use cosmic_text::Action; +use cosmic_text::BorrowedWithFontSystem; +use cosmic_text::Buffer; +use cosmic_text::BufferLine; +use cosmic_text::Edit; +use cosmic_text::Editor; +use cosmic_text::Metrics; +pub use cosmic_text::Motion; +use cosmic_text::Selection; +/// Systems handling text input update and layout +#[derive(Debug, Hash, PartialEq, Eq, Clone, SystemSet)] +pub struct TextInputSystems; + +/// Basic clipboard implementation that only works within the bevy app. +#[derive(Resource, Default)] +pub struct Clipboard(pub String); + +/// Get the text from a cosmic text buffer +fn get_cosmic_text_buffer_contents(buffer: &Buffer) -> String { + buffer + .lines + .iter() + .map(BufferLine::text) + .fold(String::new(), |mut out, line| { + if !out.is_empty() { + out.push('\n'); + } + out.push_str(line); + out + }) +} + +/// The text input buffer. +/// Primary component that contains the text layout. +/// +/// To determine if the `TextLayoutInfo` needs to be updated check the `redraw` method on the `editor` buffer. +/// Change detection is not reliable as the editor needs to be borrowed mutably during updates. +#[derive(Component, Debug)] +#[require(TextInputAttributes, TextInputTarget, TextInputActions, TextLayoutInfo)] +pub struct TextInputBuffer { + /// The cosmic text editor buffer + pub editor: Editor<'static>, + /// Space advance width for the current font + pub space_advance: f32, +} + +impl Default for TextInputBuffer { + fn default() -> Self { + Self { + editor: Editor::new(Buffer::new_empty(Metrics::new(20.0, 20.0))), + space_advance: 20., + } + } +} + +impl TextInputBuffer { + /// Use the cosmic text buffer mutably + pub fn with_buffer_mut(&mut self, f: F) -> T + where + F: FnOnce(&mut Buffer) -> T, + { + self.editor.with_buffer_mut(f) + } + + /// Use the cosmic text buffer + pub fn with_buffer(&self, f: F) -> T + where + F: FnOnce(&Buffer) -> T, + { + self.editor.with_buffer(f) + } + + /// True if the buffer is empty + pub fn is_empty(&self) -> bool { + self.with_buffer(|buffer| { + buffer.lines.len() == 0 + || (buffer.lines.len() == 1 && buffer.lines[0].text().is_empty()) + }) + } + + /// Get the text contained in the text buffer + pub fn get_text(&self) -> String { + self.editor.with_buffer(get_cosmic_text_buffer_contents) + } +} + +/// Component containing the change history for a text input. +/// Text input entities without this component will ignore undo and redo actions. +#[derive(Component, Debug, Default)] +pub struct TextInputUndoHistory { + /// The commands to undo and undo + pub changes: cosmic_undo_2::Commands, +} + +impl TextInputUndoHistory { + /// Clear the history for the text input + pub fn clear(&mut self) { + self.changes.clear(); + } +} + +/// Details of the target the text input will be rendered to +#[derive(Component, PartialEq, Debug, Default)] +pub struct TextInputTarget { + /// size of the target + pub size: Vec2, + /// scale factor of the target + pub scale_factor: f32, +} + +impl TextInputTarget { + /// Returns true if the target has zero or negative size. + pub fn is_empty(&self) -> bool { + (self.scale_factor * self.size).cmple(Vec2::ZERO).all() + } +} + +/// Contains the current text in the text input buffer +/// If inserted, replaces the current text in the text buffer +#[derive(Component, PartialEq, Debug, Default, Deref)] +#[component( + on_insert = on_insert_text_input_value, +)] +pub struct TextInputValue(String); + +impl TextInputValue { + /// New text, when inserted replaces the current text in the text buffer + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + /// Get the current text + pub fn get(&self) -> &str { + &self.0 + } +} + +/// Set the text input with the text from the `TextInputValue` when inserted. +fn on_insert_text_input_value(mut world: DeferredWorld, context: HookContext) { + if let Some(value) = world.get::(context.entity) { + let value = value.0.clone(); + if let Some(mut actions) = world + .entity_mut(context.entity) + .get_mut::() + { + actions.queue(TextInputAction::SetText(value)); + } + } +} + +/// Common text input properties set by the user that +/// require a layout recomputation or font update on changes. +#[derive(Component, Debug, PartialEq)] +pub struct TextInputAttributes { + /// The text input's font, also used for any prompt or password mask. + /// A text input's glyphs must all be from the same font. + pub font: Handle, + /// The size of the font. + /// A text input's glyphs must all be the same size. + pub font_size: f32, + /// The height of each line. + /// A text input's lines must all be the same height. + pub line_height: LineHeight, + /// Determines how lines will be broken + pub line_break: LineBreak, + /// The horizontal alignment for all the text in the text input buffer. + pub justify: Justify, + /// Controls text antialiasing + pub font_smoothing: FontSmoothing, + /// Maximum number of glyphs the text input buffer can contain. + /// Any edits that extend the length above `max_chars` are ignored. + /// If set on a buffer longer than `max_chars` the buffer will be truncated. + pub max_chars: Option, + /// The number of lines the buffer will display at once. + /// Limited by the size of the target. + /// If None or equal or less than 0, will fill the target space. + pub lines: Option, + /// Clear on submit + pub clear_on_submit: bool, +} + +impl Default for TextInputAttributes { + fn default() -> Self { + Self { + font: Default::default(), + font_size: 20., + line_height: LineHeight::RelativeToFont(1.2), + font_smoothing: Default::default(), + justify: Default::default(), + line_break: Default::default(), + max_chars: None, + lines: None, + clear_on_submit: false, + } + } +} + +/// Any actions that modify a text input's text so that it fails +/// to pass the filter are not applied. +#[derive(Component)] +pub enum TextInputFilter { + /// Positive integer input + /// accepts only digits + PositiveInteger, + /// Integer input + /// accepts only digits and a leading sign + Integer, + /// Decimal input + /// accepts only digits, a decimal point and a leading sign + Decimal, + /// Hexadecimal input + /// accepts only `0-9`, `a-f` and `A-F` + Hex, + /// Alphanumeric input + /// accepts only `0-9`, `a-z` and `A-Z` + Alphanumeric, + /// Custom filter + Custom(Box bool + Send + Sync>), +} + +impl TextInputFilter { + /// Returns true if the text passes the filter + pub fn is_match(&self, text: &str) -> bool { + // Always passes if the input is empty unless using a custom filter + if text.is_empty() && !matches!(self, Self::Custom(_)) { + return true; + } + + match self { + TextInputFilter::PositiveInteger => text.chars().all(|c| c.is_ascii_digit()), + TextInputFilter::Integer => text + .strip_prefix('-') + .unwrap_or(text) + .chars() + .all(|c| c.is_ascii_digit()), + TextInputFilter::Decimal => text + .strip_prefix('-') + .unwrap_or(text) + .chars() + .try_fold(true, |is_int, c| match c { + '.' if is_int => Ok(false), + c if c.is_ascii_digit() => Ok(is_int), + _ => Err(()), + }) + .is_ok(), + TextInputFilter::Hex => text.chars().all(|c| c.is_ascii_hexdigit()), + TextInputFilter::Alphanumeric => text.chars().all(|c| c.is_ascii_alphanumeric()), + TextInputFilter::Custom(is_match) => is_match(text), + } + } + + /// Create a custom filter + pub fn custom(filter_fn: impl Fn(&str) -> bool + Send + Sync + 'static) -> Self { + Self::Custom(Box::new(filter_fn)) + } +} + +/// Add this component to hide the text input buffer contents +/// by replacing the characters with `mask_char`. +/// +/// Should only be used with monospaced fonts. +/// With variable width fonts mouse picking and horizontal scrolling +/// may not work correctly. +#[derive(Component)] +pub struct TextInputPasswordMask { + /// If true the password will not be hidden + pub show_password: bool, + /// Char that will replace the masked input characters, by default `*` + pub mask_char: char, + /// Buffer mirroring the actual text input buffer but only containing `mask_char`s + editor: Editor<'static>, +} + +impl Default for TextInputPasswordMask { + fn default() -> Self { + Self { + show_password: false, + mask_char: '*', + editor: Editor::new(Buffer::new_empty(Metrics::new(20.0, 20.0))), + } + } +} + +/// Text input commands queue +#[derive(Component, Default)] +pub struct TextInputActions { + /// Commands to be applied before the text input is updated + pub queue: VecDeque, +} + +impl TextInputActions { + /// queue an action + pub fn queue(&mut self, command: TextInputAction) { + self.queue.push_back(command); + } +} + +/// Deferred text input edit and navigation actions applied by the `apply_text_input_actions` system. +#[derive(Debug)] +pub enum TextInputAction { + /// Copy the selected text into the clipboard. Does nothing if no text selected. + Copy, + /// Copy the selected text into the clipboard, then delete the selected text. Does nothing if no text selected. + Cut, + /// Insert the contents of the clipboard at the current cursor position. Does nothing if the clipboard is empty. + Paste, + /// Move the cursor with some motion. + Motion { + /// The motion to perform. + motion: Motion, + /// Select the text from the initial cursor position to the end of the motion. + with_select: bool, + }, + /// Insert a character at the cursor. If there is a selection, replaces the selection with the character instead. + Insert(char), + /// Set the character at the cursor, overwriting the previous character. Inserts if cursor is at the end of a line. + /// If there is a selection, replaces the selection with the character instead. + Overwrite(char), + /// Start a new line. + NewLine, + /// Delete the character behind the cursor. + /// If there is a selection, deletes the selection instead. + Backspace, + /// Delete the character a the cursor. + /// If there is a selection, deletes the selection instead. + Delete, + /// Indent at the cursor. + Indent, + /// Unindent at the cursor. + Unindent, + /// Moves the cursor to the character at the given position. + Click(IVec2), + /// Selects the word at the given position. + DoubleClick(IVec2), + /// Selects the line at the given position. + TripleClick(IVec2), + /// Select the text up to the given position + Drag(IVec2), + /// Scroll vertically by the given number of lines. + /// Negative values scroll upwards towards the start of the text, positive downwards to the end of the text. + Scroll { + /// Number of lines to scroll. + /// Negative values scroll upwards towards the start of the text, positive downwards to the end of the text. + lines: i32, + }, + /// Undo the previous action. + Undo, + /// Redo an undone action. Must directly follow an Undo. + Redo, + /// Select the entire contents of the text input buffer. + SelectAll, + /// Select the line at the cursor. + SelectLine, + /// Clear any selection. + Escape, + /// Clear the text input buffer. + Clear, + /// Set the contents of the text input buffer. The existing contents is discarded. + SetText(String), + /// Submit the contents of the text input buffer + Submit, +} + +impl TextInputAction { + /// An action that moves the cursor. + /// If `with_select` is true, it selects as it moves + pub fn motion(motion: Motion, with_select: bool) -> Self { + Self::Motion { + motion, + with_select, + } + } +} + +/// apply a motion action to the editor buffer +pub fn apply_motion<'a>( + editor: &mut BorrowedWithFontSystem>, + shift_pressed: bool, + motion: Motion, +) { + if shift_pressed { + if editor.selection() == Selection::None { + let cursor = editor.cursor(); + editor.set_selection(Selection::Normal(cursor)); + } + } else { + editor.action(Action::Escape); + } + editor.action(Action::Motion(motion)); +} + +/// Returns true if the cursor is at the end of a line +pub fn is_cursor_at_end_of_line(editor: &mut BorrowedWithFontSystem>) -> bool { + let cursor = editor.cursor(); + editor.with_buffer(|buffer| { + buffer + .lines + .get(cursor.line) + .map(|line| cursor.index == line.text().len()) + .unwrap_or(false) + }) +} + +/// apply an action from the undo history to the text input buffer +fn apply_action<'a>( + editor: &mut BorrowedWithFontSystem>, + action: cosmic_undo_2::Action<&cosmic_text::Change>, +) { + match action { + cosmic_undo_2::Action::Do(change) => { + editor.apply_change(change); + } + cosmic_undo_2::Action::Undo(change) => { + let mut reversed = change.clone(); + reversed.reverse(); + editor.apply_change(&reversed); + } + } + editor.set_redraw(true); +} + +/// Apply the queued actions for each text input, with special case for submit actions. +/// Then update [`TextInputValue`]s +pub fn apply_text_input_actions( + mut commands: Commands, + mut font_system: ResMut, + mut text_input_query: Query<( + Entity, + &mut TextInputBuffer, + &mut TextInputActions, + &TextInputAttributes, + Option<&TextInputFilter>, + Option<&mut TextInputUndoHistory>, + Option<&mut TextInputValue>, + )>, + mut clipboard: ResMut, +) { + for ( + entity, + mut buffer, + mut text_input_actions, + attribs, + maybe_filter, + mut maybe_history, + maybe_value, + ) in text_input_query.iter_mut() + { + for action in text_input_actions.queue.drain(..) { + match action { + TextInputAction::Submit => { + commands.trigger_targets( + TextInputEvent::Submission { + text: buffer.get_text(), + text_input: entity, + }, + entity, + ); + + if attribs.clear_on_submit { + apply_text_input_action( + buffer.editor.borrow_with(&mut font_system), + maybe_history.as_mut().map(AsMut::as_mut), + maybe_filter, + attribs.max_chars, + &mut clipboard, + TextInputAction::Clear, + ); + + if let Some(history) = maybe_history.as_mut() { + history.clear(); + } + } + } + action => { + if !apply_text_input_action( + buffer.editor.borrow_with(&mut font_system), + maybe_history.as_mut().map(AsMut::as_mut), + maybe_filter, + attribs.max_chars, + &mut clipboard, + action, + ) { + commands.trigger_targets( + TextInputEvent::InvalidInput { text_input: entity }, + entity, + ); + } + } + } + } + + let contents = buffer.get_text(); + if let Some(mut value) = maybe_value { + if value.0 != contents { + value.0 = contents; + commands + .trigger_targets(TextInputEvent::ValueChanged { text_input: entity }, entity); + } + } + } +} + +/// update the text input buffer when a non-text edit change happens like +/// the font or line height changing and the buffer's metrics and attributes need +/// to be regenerated +pub fn update_text_input_buffers( + mut text_input_query: Query<( + &mut TextInputBuffer, + Ref, + Ref, + )>, + mut font_system: ResMut, + mut text_pipeline: ResMut, + fonts: Res>, +) { + let font_system = &mut font_system.0; + let font_id_map = &mut text_pipeline.map_handle_to_font_id; + for (mut input_buffer, target, attributes) in text_input_query.iter_mut() { + let TextInputBuffer { + editor, + space_advance, + .. + } = input_buffer.as_mut(); + + let _ = editor.with_buffer_mut(|buffer| { + if target.is_changed() || attributes.is_changed() { + let line_height = attributes.line_height.eval(attributes.font_size); + let metrics = + Metrics::new(attributes.font_size, line_height).scale(target.scale_factor); + buffer.set_metrics(font_system, metrics); + + buffer.set_wrap(font_system, attributes.line_break.into()); + + if !fonts.contains(attributes.font.id()) { + return Err(TextError::NoSuchFont); + } + + let face_info = + load_font_to_fontdb(attributes.font.clone(), font_system, font_id_map, &fonts); + + let attrs = cosmic_text::Attrs::new() + .metadata(0) + .family(cosmic_text::Family::Name(&face_info.family_name)) + .stretch(face_info.stretch) + .style(face_info.style) + .weight(face_info.weight); + + let mut text = buffer.lines.iter().map(BufferLine::text).fold( + String::new(), + |mut out, line| { + if !out.is_empty() { + out.push('\n'); + } + out.push_str(line); + out + }, + ); + + if let Some(max_chars) = attributes.max_chars { + text.truncate(max_chars); + } + + buffer.set_text(font_system, &text, &attrs, cosmic_text::Shaping::Advanced); + let align = Some(attributes.justify.into()); + for buffer_line in buffer.lines.iter_mut() { + buffer_line.set_align(align); + } + + *space_advance = font_id_map + .get(&attributes.font.id()) + .and_then(|(id, ..)| font_system.get_font(*id)) + .and_then(|font| { + let face = font.rustybuzz(); + face.glyph_index(' ') + .and_then(|gid| face.glyph_hor_advance(gid)) + .map(|advance| advance as f32 / face.units_per_em() as f32) + }) + .unwrap_or(0.0) + * buffer.metrics().font_size; + + let height = if let Some(lines) = attributes.lines.filter(|lines| 0. < *lines) { + (metrics.line_height * lines).max(target.size.y) + } else { + target.size.y + }; + + buffer.set_size( + font_system, + Some(target.size.x - *space_advance), + Some(height), + ); + + buffer.set_redraw(true); + } + + Ok(()) + }); + } +} + +/// Update password masks to mirror the underlying `TextInputBuffer`. +/// +/// With variable sized fonts the glyph geometry of the password mask editor buffer may not match the +/// underlying editor buffer, possibly resulting in incorrect scrolling and mouse interactions. +pub fn update_password_masks( + mut text_input_query: Query<(&mut TextInputBuffer, &mut TextInputPasswordMask)>, + mut cosmic_font_system: ResMut, +) { + let font_system = &mut cosmic_font_system.0; + for (mut buffer, mut mask) in text_input_query.iter_mut() { + if buffer.editor.redraw() || mask.is_changed() { + buffer.editor.shape_as_needed(font_system, false); + let mask_text: String = buffer.get_text().chars().map(|_| mask.mask_char).collect(); + let mask_editor = &mut mask.bypass_change_detection().editor; + *mask_editor = buffer.editor.clone(); + let mut editor = mask_editor.borrow_with(font_system); + let selection = editor.selection(); + let cursor = editor.cursor(); + editor.action(Action::Motion(Motion::BufferStart)); + let start = editor.cursor(); + editor.set_selection(Selection::Normal(start)); + editor.action(Action::Motion(Motion::BufferEnd)); + editor.action(Action::Delete); + editor.insert_string(&mask_text, None); + editor.set_selection(selection); + editor.set_cursor(cursor); + editor.set_redraw(true); + } + } +} + +/// Based on `LayoutRunIter` from cosmic-text but doesn't crop the +/// bottom line when scrolling up. +#[derive(Debug)] +pub struct ScrollingLayoutRunIter<'b> { + /// Cosmic text buffer + buffer: &'b Buffer, + /// Index of the current `BufferLine` (The paragraphs of text before line-breaking) + paragraph_index: usize, + /// Index of the current `LayoutLine`, a horizontal line of glyphs from the current `BufferLine` (The individual lines of a paragraph after line-breaking) + broken_line_index: usize, + /// Total height of the lines iterated so far + total_height: f32, + /// The y-coordinate of the top of the current `LayoutLine`. + line_top: f32, +} + +impl<'b> ScrollingLayoutRunIter<'b> { + /// Returns a new iterator that iterates the visible lines of the `buffer`. + pub fn new(buffer: &'b Buffer) -> Self { + Self { + buffer, + paragraph_index: buffer.scroll().line, + broken_line_index: 0, + total_height: 0.0, + line_top: 0.0, + } + } +} + +impl<'b> Iterator for ScrollingLayoutRunIter<'b> { + type Item = cosmic_text::LayoutRun<'b>; + + fn next(&mut self) -> Option { + // Iterate paragraphs + while let Some(line) = self.buffer.lines.get(self.paragraph_index) { + let shape = line.shape_opt()?; + let layout = line.layout_opt()?; + + // Iterate the paragraph's lines after line-breaking + while let Some(layout_line) = layout.get(self.broken_line_index) { + self.broken_line_index += 1; + + let line_height = layout_line + .line_height_opt + .unwrap_or(self.buffer.metrics().line_height); + self.total_height += line_height; + + let line_top = self.line_top - self.buffer.scroll().vertical; + let glyph_height = layout_line.max_ascent + layout_line.max_descent; + let centering_offset = (line_height - glyph_height) / 2.0; + let line_bottom = line_top + centering_offset + layout_line.max_ascent; + if let Some(height) = self.buffer.size().1 { + if height + line_height < line_bottom { + // The line is below the target bound's bottom edge. + // No more lines are visible, return `None` to end the iteration. + return None; + } + } + self.line_top += line_height; + if line_bottom < 0.0 { + // The bottom of the line is above the target's bounds top edge and not visible. Skip it. + continue; + } + + return Some(cosmic_text::LayoutRun { + line_i: self.paragraph_index, + text: line.text(), + rtl: shape.rtl, + glyphs: &layout_line.glyphs, + line_y: line_bottom, + line_top, + line_height, + line_w: layout_line.w, + }); + } + self.paragraph_index += 1; + self.broken_line_index = 0; + } + + None + } +} + +/// Updates the `TextLayoutInfo` for each text input for rendering. +pub fn update_text_input_layouts( + mut textures: ResMut>, + mut texture_atlases: ResMut>, + mut text_query: Query<( + &mut TextLayoutInfo, + &mut TextInputBuffer, + &TextInputAttributes, + Option<&mut TextInputPasswordMask>, + )>, + mut font_system: ResMut, + mut swash_cache: ResMut, + mut font_atlas_sets: ResMut, +) { + let font_system = &mut font_system.0; + for (mut layout_info, mut buffer, attributes, mut maybe_password_mask) in text_query.iter_mut() + { + // Force a redraw when a password is revealed or hidden + let force_redraw = maybe_password_mask + .as_mut() + .map(|mask| mask.is_changed() && mask.show_password) + .unwrap_or(false); + + let space_advance = buffer.space_advance; + let editor = if let Some(password_mask) = maybe_password_mask + .as_mut() + .filter(|mask| !mask.show_password) + { + // The underlying buffer isn't visible, but set redraw to false as though it has been to avoid unnecessary reupdates. + buffer.editor.set_redraw(false); + &mut password_mask.bypass_change_detection().editor + } else { + &mut buffer.editor + }; + editor.shape_as_needed(font_system, false); + + if editor.redraw() || force_redraw { + layout_info.glyphs.clear(); + layout_info.section_rects.clear(); + layout_info.selection_rects.clear(); + layout_info.cursor_index = None; + layout_info.cursor = None; + + let selection = editor.selection_bounds(); + let cursor_position = editor.cursor_position(); + let cursor = editor.cursor(); + + let result = editor.with_buffer_mut(|buffer| { + let box_size = buffer_dimensions(buffer); + let line_height = buffer.metrics().line_height; + if let Some((x, y)) = cursor_position { + let size = Vec2::new(space_advance, line_height); + layout_info.cursor = Some(( + IVec2::new(x, y).as_vec2() + 0.5 * size, + size, + cursor.affinity.after(), + )); + } + let result = ScrollingLayoutRunIter::new(buffer).try_for_each(|run| { + if let Some(selection) = selection { + if let Some((x0, w)) = run.highlight(selection.0, selection.1) { + let y0 = run.line_top; + let y1 = y0 + run.line_height; + let x1 = x0 + w; + let r = Rect::new(x0, y0, x1, y1); + layout_info.selection_rects.push(r); + } + } + + run.glyphs + .iter() + .map(move |layout_glyph| (layout_glyph, run.line_y, run.line_i)) + .try_for_each(|(layout_glyph, line_y, line_i)| { + let mut temp_glyph; + let span_index = layout_glyph.metadata; + let font_id = attributes.font.id(); + let font_smoothing = attributes.font_smoothing; + + let layout_glyph = if font_smoothing == FontSmoothing::None { + // If font smoothing is disabled, round the glyph positions and sizes, + // effectively discarding all subpixel layout. + temp_glyph = layout_glyph.clone(); + temp_glyph.x = temp_glyph.x.round(); + temp_glyph.y = temp_glyph.y.round(); + temp_glyph.w = temp_glyph.w.round(); + temp_glyph.x_offset = temp_glyph.x_offset.round(); + temp_glyph.y_offset = temp_glyph.y_offset.round(); + temp_glyph.line_height_opt = + temp_glyph.line_height_opt.map(f32::round); + + &temp_glyph + } else { + layout_glyph + }; + + let font_atlas_set = font_atlas_sets.sets.entry(font_id).or_default(); + + let physical_glyph = layout_glyph.physical((0., 0.), 1.); + + let atlas_info = font_atlas_set + .get_glyph_atlas_info(physical_glyph.cache_key, font_smoothing) + .map(Ok) + .unwrap_or_else(|| { + font_atlas_set.add_glyph_to_atlas( + &mut texture_atlases, + &mut textures, + font_system, + &mut swash_cache.0, + layout_glyph, + font_smoothing, + ) + })?; + + let texture_atlas = + texture_atlases.get(atlas_info.texture_atlas).unwrap(); + let location = atlas_info.location; + let glyph_rect = texture_atlas.textures[location.glyph_index]; + let left = location.offset.x as f32; + let top = location.offset.y as f32; + let glyph_size = UVec2::new(glyph_rect.width(), glyph_rect.height()); + + // offset by half the size because the origin is center + let x = glyph_size.x as f32 / 2.0 + left + physical_glyph.x as f32; + let y = line_y.round() + physical_glyph.y as f32 - top + + glyph_size.y as f32 / 2.0; + + let position = Vec2::new(x, y); + + let pos_glyph = PositionedGlyph { + position, + size: glyph_size.as_vec2(), + atlas_info, + span_index, + byte_index: layout_glyph.start, + byte_length: layout_glyph.end - layout_glyph.start, + line_index: line_i, + }; + layout_info.glyphs.push(pos_glyph); + if cursor.line == line_i && cursor.index == layout_glyph.start { + layout_info.cursor_index = Some(layout_info.glyphs.len() - 1); + if let Some((ref mut position, ref mut size, ..)) = + layout_info.cursor + { + size.x = layout_glyph.w; + if let Some(cursor_position) = cursor_position { + *position = + IVec2::from(cursor_position).as_vec2() + 0.5 * *size; + } + } + } + + Ok(()) + }) + }); + + // Check result. + result?; + layout_info.size = box_size; + Ok(()) + }); + + match result { + Err(TextError::NoSuchFont) => { + // There was an error processing the text layout, try again next frame + } + Err(e @ (TextError::FailedToAddGlyph(_) | TextError::FailedToGetGlyphImage(_))) => { + panic!("Fatal error when processing text: {e}."); + } + Ok(()) => { + layout_info.scroll = + editor.with_buffer(|buffer| Vec2::new(buffer.scroll().horizontal, 0.)); + + editor.set_redraw(false); + } + } + } + } +} + +/// Apply a text input action to a text input +fn apply_text_input_action( + mut editor: BorrowedWithFontSystem<'_, Editor<'static>>, + mut maybe_history: Option<&mut TextInputUndoHistory>, + maybe_filter: Option<&TextInputFilter>, + max_chars: Option, + clipboard_contents: &mut ResMut, + action: TextInputAction, +) -> bool { + editor.start_change(); + + match action { + TextInputAction::Copy => { + if let Some(text) = editor.copy_selection() { + clipboard_contents.0 = text; + } + } + TextInputAction::Cut => { + if let Some(text) = editor.copy_selection() { + clipboard_contents.0 = text; + editor.delete_selection(); + } + } + TextInputAction::Paste => { + editor.insert_string(&clipboard_contents.0, None); + } + TextInputAction::Motion { + motion, + with_select, + } => { + apply_motion(&mut editor, with_select, motion); + } + TextInputAction::Insert(ch) => { + editor.action(Action::Insert(ch)); + } + TextInputAction::Overwrite(ch) => match editor.selection() { + Selection::None => { + if is_cursor_at_end_of_line(&mut editor) { + editor.action(Action::Insert(ch)); + } else { + editor.action(Action::Delete); + editor.action(Action::Insert(ch)); + } + } + _ => editor.action(Action::Insert(ch)), + }, + TextInputAction::NewLine => { + editor.action(Action::Enter); + } + TextInputAction::Backspace => { + if !editor.delete_selection() { + editor.action(Action::Backspace); + } + } + TextInputAction::Delete => { + if !editor.delete_selection() { + editor.action(Action::Delete); + } + } + TextInputAction::Indent => { + editor.action(Action::Indent); + } + TextInputAction::Unindent => { + editor.action(Action::Unindent); + } + TextInputAction::Click(point) => { + editor.action(Action::Click { + x: point.x, + y: point.y, + }); + } + TextInputAction::DoubleClick(point) => { + editor.action(Action::DoubleClick { + x: point.x, + y: point.y, + }); + } + TextInputAction::TripleClick(point) => { + editor.action(Action::TripleClick { + x: point.x, + y: point.y, + }); + } + TextInputAction::Drag(point) => { + editor.action(Action::Drag { + x: point.x, + y: point.y, + }); + } + TextInputAction::Scroll { lines } => { + editor.action(Action::Scroll { lines }); + } + TextInputAction::Undo => { + if let Some(history) = maybe_history.as_mut() { + for action in history.changes.undo() { + apply_action(&mut editor, action); + } + } + } + TextInputAction::Redo => { + if let Some(history) = maybe_history.as_mut() { + for action in history.changes.redo() { + apply_action(&mut editor, action); + } + } + } + TextInputAction::SelectAll => { + editor.action(Action::Motion(Motion::BufferStart)); + let cursor = editor.cursor(); + editor.set_selection(Selection::Normal(cursor)); + editor.action(Action::Motion(Motion::BufferEnd)); + } + TextInputAction::SelectLine => { + editor.action(Action::Motion(Motion::Home)); + let cursor = editor.cursor(); + editor.set_selection(Selection::Normal(cursor)); + editor.action(Action::Motion(Motion::End)); + } + TextInputAction::Escape => { + editor.set_selection(Selection::None); + } + TextInputAction::Clear => { + editor.action(Action::Motion(Motion::BufferStart)); + let cursor = editor.cursor(); + editor.set_selection(Selection::Normal(cursor)); + editor.action(Action::Motion(Motion::BufferEnd)); + editor.action(Action::Delete); + } + TextInputAction::SetText(text) => { + editor.action(Action::Motion(Motion::Home)); + let cursor = editor.cursor(); + editor.set_selection(Selection::Normal(cursor)); + editor.action(Action::Motion(Motion::End)); + editor.insert_string(&text, None); + } + TextInputAction::Submit => {} + } + + let Some(mut change) = editor.finish_change() else { + return true; + }; + + if change.items.is_empty() { + return true; + } + + if maybe_filter.is_some() || max_chars.is_some() { + let text = editor.with_buffer(get_cosmic_text_buffer_contents); + if maybe_filter.is_some_and(|filter| !filter.is_match(&text)) + || max_chars.is_some_and(|max_chars| max_chars <= text.chars().count()) + { + change.reverse(); + editor.apply_change(&change); + return false; + } + } + + if let Some(history) = maybe_history.as_mut() { + history.changes.push(change); + } + + // Set redraw manually, sometimes the editor doesn't set it automatically. + editor.set_redraw(true); + + true +} + +/// Event dispatched when a text input receives the [`TextInputAction::Submit`] action. +/// Contains a copy of the buffer contents at the time when when the action was applied. +#[derive(EntityEvent, Clone, Debug, Component, Reflect)] +#[entity_event(traversal = &'static ChildOf, auto_propagate)] +#[reflect(Component, Clone)] +pub enum TextInputEvent { + /// The input received an invalid input that was filtered + InvalidInput { + /// The source text input entity + text_input: Entity, + }, + /// Text from the input was submitted + Submission { + /// The submitted text + text: String, + /// The source text input entity + text_input: Entity, + }, + /// The contents of the text input changed due to an edit action. + /// Dispatched if a text input entity has a [`TextInputValue`] component. + ValueChanged { + /// The source text input entity + text_input: Entity, + }, +} + +/// Prompt displayed when the input is empty (including whitespace). +/// Optional component. +#[derive(Default, Component, Clone, Debug, Reflect, Deref, DerefMut)] +#[reflect(Component, Default, Debug)] +#[require(PromptLayout)] +pub struct Prompt(pub String); + +impl Prompt { + /// A new prompt. + pub fn new(prompt: impl Into) -> Self { + Self(prompt.into()) + } +} + +/// Layout for the prompt text +#[derive(Component)] +pub struct PromptLayout { + /// Prompt's cosmic-text buffer (not an Editor as isn't editable) + buffer: Buffer, + /// Prompt's text layout, displayed when the text input is empty. + /// Doesn't reuse the editor's `TextLayoutInfo` as otherwise the prompt would need a relayout + /// everytime it was displayed. + layout: TextLayoutInfo, +} + +impl PromptLayout { + /// Get the text layout + pub fn layout(&self) -> &TextLayoutInfo { + &self.layout + } +} + +impl Default for PromptLayout { + fn default() -> Self { + Self { + buffer: Buffer::new_empty(Metrics::new(20.0, 20.0)), + layout: Default::default(), + } + } +} + +/// Generates a new text prompt layout when a prompt's text or its target's geometry has changed. +pub fn update_text_input_prompt_layouts( + mut textures: ResMut>, + fonts: Res>, + mut font_system: ResMut, + mut texture_atlases: ResMut>, + mut text_pipeline: ResMut, + mut swash_cache: ResMut, + mut font_atlas_sets: ResMut, + mut text_query: Query< + ( + &Prompt, + &TextInputAttributes, + &TextInputTarget, + &TextFont, + &mut PromptLayout, + ), + Or<( + Changed, + Changed, + Changed, + Changed, + )>, + >, +) { + for (prompt, style, target, text_font, mut prompt_layout) in text_query.iter_mut() { + let PromptLayout { buffer, layout } = prompt_layout.as_mut(); + + layout.clear(); + + if prompt.0.is_empty() || target.is_empty() { + continue; + } + + if !fonts.contains(text_font.font.id()) { + continue; + } + + let line_height = text_font.line_height.eval(text_font.font_size); + + let metrics = Metrics::new(text_font.font_size, line_height).scale(target.scale_factor); + + if metrics.font_size <= 0. || metrics.line_height <= 0. { + continue; + } + + let bounds: TextBounds = target.size.into(); + let face_info = load_font_to_fontdb( + text_font.font.clone(), + font_system.as_mut(), + &mut text_pipeline.map_handle_to_font_id, + &fonts, + ); + + buffer.set_size(font_system.as_mut(), bounds.width, bounds.height); + + buffer.set_wrap(&mut font_system, style.line_break.into()); + + let attrs = cosmic_text::Attrs::new() + .metadata(0) + .family(cosmic_text::Family::Name(&face_info.family_name)) + .stretch(face_info.stretch) + .style(face_info.style) + .weight(face_info.weight) + .metrics(metrics); + + buffer.set_text( + &mut font_system, + &prompt.0, + &attrs, + cosmic_text::Shaping::Advanced, + ); + + let align = Some(style.justify.into()); + for buffer_line in buffer.lines.iter_mut() { + buffer_line.set_align(align); + } + + buffer.shape_until_scroll(&mut font_system, false); + + let box_size = buffer_dimensions(buffer); + let result = buffer.layout_runs().try_for_each(|run| { + run.glyphs + .iter() + .map(move |layout_glyph| (layout_glyph, run.line_y, run.line_i)) + .try_for_each(|(layout_glyph, line_y, line_i)| { + let mut temp_glyph; + let span_index = layout_glyph.metadata; + let font_id = text_font.font.id(); + let font_smoothing = text_font.font_smoothing; + + let layout_glyph = if font_smoothing == FontSmoothing::None { + // If font smoothing is disabled, round the glyph positions and sizes, + // effectively discarding all subpixel layout. + temp_glyph = layout_glyph.clone(); + temp_glyph.x = temp_glyph.x.round(); + temp_glyph.y = temp_glyph.y.round(); + temp_glyph.w = temp_glyph.w.round(); + temp_glyph.x_offset = temp_glyph.x_offset.round(); + temp_glyph.y_offset = temp_glyph.y_offset.round(); + temp_glyph.line_height_opt = temp_glyph.line_height_opt.map(f32::round); + + &temp_glyph + } else { + layout_glyph + }; + + let font_atlas_set = font_atlas_sets.sets.entry(font_id).or_default(); + + let physical_glyph = layout_glyph.physical((0., 0.), 1.); + + let atlas_info = font_atlas_set + .get_glyph_atlas_info(physical_glyph.cache_key, font_smoothing) + .map(Ok) + .unwrap_or_else(|| { + font_atlas_set.add_glyph_to_atlas( + &mut texture_atlases, + &mut textures, + &mut font_system, + &mut swash_cache.0, + layout_glyph, + font_smoothing, + ) + })?; + + let texture_atlas = texture_atlases.get(atlas_info.texture_atlas).unwrap(); + let location = atlas_info.location; + let glyph_rect = texture_atlas.textures[location.glyph_index]; + let left = location.offset.x as f32; + let top = location.offset.y as f32; + let glyph_size = UVec2::new(glyph_rect.width(), glyph_rect.height()); + + // offset by half the size because the origin is center + let x = glyph_size.x as f32 / 2.0 + left + physical_glyph.x as f32; + let y = + line_y.round() + physical_glyph.y as f32 - top + glyph_size.y as f32 / 2.0; + + let position = Vec2::new(x, y); + + let pos_glyph = PositionedGlyph { + position, + size: glyph_size.as_vec2(), + atlas_info, + span_index, + byte_index: layout_glyph.start, + byte_length: layout_glyph.end - layout_glyph.start, + line_index: line_i, + }; + layout.glyphs.push(pos_glyph); + Ok(()) + }) + }); + + prompt_layout.layout.size = target.scale_factor.recip() * box_size; + + match result { + Err(TextError::NoSuchFont) => { + // There was an error processing the text layout, try again next frame + prompt_layout.layout.clear(); + } + Err(e @ (TextError::FailedToAddGlyph(_) | TextError::FailedToGetGlyphImage(_))) => { + panic!("Fatal error when processing text: {e}."); + } + Ok(()) => {} + } + } +} diff --git a/crates/bevy_text/src/lib.rs b/crates/bevy_text/src/lib.rs index b36f5fa2bb0d7..c0ab14b5a5068 100644 --- a/crates/bevy_text/src/lib.rs +++ b/crates/bevy_text/src/lib.rs @@ -38,6 +38,7 @@ mod font_atlas; mod font_atlas_set; mod font_loader; mod glyph; +mod input; mod pipeline; mod text; mod text2d; @@ -50,6 +51,7 @@ pub use font_atlas::*; pub use font_atlas_set::*; pub use font_loader::*; pub use glyph::*; +pub use input::*; pub use pipeline::*; pub use text::*; pub use text2d::*; @@ -133,6 +135,20 @@ impl Plugin for TextPlugin { ) .add_systems(Last, trim_cosmic_cache); + app.init_resource::().add_systems( + PostUpdate, + ( + update_text_input_buffers, + apply_text_input_actions, + update_password_masks, + update_text_input_layouts, + update_text_input_prompt_layouts, + ) + .chain() + .in_set(TextInputSystems) + .ambiguous_with(Text2dUpdateSystems), + ); + if let Some(render_app) = app.get_sub_app_mut(RenderApp) { render_app.add_systems( ExtractSchedule, diff --git a/crates/bevy_text/src/pipeline.rs b/crates/bevy_text/src/pipeline.rs index 8c1136c0636d4..7fb89a4758421 100644 --- a/crates/bevy_text/src/pipeline.rs +++ b/crates/bevy_text/src/pipeline.rs @@ -1,6 +1,6 @@ use alloc::sync::Arc; -use bevy_asset::{AssetId, Assets}; +use bevy_asset::{AssetId, Assets, Handle}; use bevy_color::Color; use bevy_derive::{Deref, DerefMut}; use bevy_ecs::{ @@ -136,7 +136,7 @@ impl TextPipeline { // Load Bevy fonts into cosmic-text's font system. let face_info = load_font_to_fontdb( - text_font, + text_font.font.clone(), font_system, &mut self.map_handle_to_font_id, fonts, @@ -452,10 +452,31 @@ pub struct TextLayoutInfo { /// Scaled and positioned glyphs in screenspace pub glyphs: Vec, /// Rects bounding the text block's text sections. - /// A text section spanning more than one line will have multiple bounding rects. + /// A text section spanning more than one line will have multiple bounding rects pub section_rects: Vec<(Entity, Rect)>, + /// Rects bounding the selected text + pub selection_rects: Vec, /// The glyphs resulting size pub size: Vec2, + /// Cursor position and size + pub cursor: Option<(Vec2, Vec2, bool)>, + /// Index of glyph under the cursor + pub cursor_index: Option, + /// Offset for scrolled text + pub scroll: Vec2, +} + +impl TextLayoutInfo { + /// Clear the text layout + pub fn clear(&mut self) { + self.glyphs.clear(); + self.section_rects.clear(); + self.selection_rects.clear(); + self.size = Vec2::ZERO; + self.cursor = None; + self.cursor_index = None; + self.scroll = Vec2::ZERO; + } } /// Size information for a corresponding [`ComputedTextBlock`] component. @@ -490,12 +511,11 @@ impl TextMeasureInfo { /// Add the font to the cosmic text's `FontSystem`'s in-memory font database pub fn load_font_to_fontdb( - text_font: &TextFont, + font_handle: Handle, font_system: &mut cosmic_text::FontSystem, map_handle_to_font_id: &mut HashMap, (cosmic_text::fontdb::ID, Arc)>, fonts: &Assets, ) -> FontFaceInfo { - let font_handle = text_font.font.clone(); let (face_id, family_name) = map_handle_to_font_id .entry(font_handle.id()) .or_insert_with(|| { @@ -549,7 +569,7 @@ fn get_attrs<'a>( } /// Calculate the size of the text area for the given buffer. -fn buffer_dimensions(buffer: &Buffer) -> Vec2 { +pub(crate) fn buffer_dimensions(buffer: &Buffer) -> Vec2 { let (width, height) = buffer .layout_runs() .map(|run| (run.line_w, run.line_height)) diff --git a/crates/bevy_text/src/text.rs b/crates/bevy_text/src/text.rs index 330f0d977a279..b6ff61c0b777f 100644 --- a/crates/bevy_text/src/text.rs +++ b/crates/bevy_text/src/text.rs @@ -5,7 +5,7 @@ use bevy_derive::{Deref, DerefMut}; use bevy_ecs::{prelude::*, reflect::ReflectComponent}; use bevy_reflect::prelude::*; use bevy_utils::once; -use cosmic_text::{Buffer, Metrics}; +use cosmic_text::{Buffer, Metrics, Wrap}; use serde::{Deserialize, Serialize}; use smallvec::SmallVec; use tracing::warn; @@ -446,6 +446,17 @@ pub enum LineBreak { NoWrap, } +impl From for Wrap { + fn from(value: LineBreak) -> Self { + match value { + LineBreak::WordBoundary => Wrap::Word, + LineBreak::AnyCharacter => Wrap::Glyph, + LineBreak::WordOrCharacter => Wrap::WordOrGlyph, + LineBreak::NoWrap => Wrap::None, + } + } +} + /// Determines which antialiasing method to use when rendering text. By default, text is /// rendered with grayscale antialiasing, but this can be changed to achieve a pixelated look. /// From f36f9d0eba872c3c959c53be91cdf737f882f9fb Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Wed, 30 Jul 2025 18:28:13 +0100 Subject: [PATCH 02/53] use `is_empty()` instead of checking lengeth is 0 --- crates/bevy_text/src/input.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bevy_text/src/input.rs b/crates/bevy_text/src/input.rs index cbd219b35d218..aa35e8bfc0e15 100644 --- a/crates/bevy_text/src/input.rs +++ b/crates/bevy_text/src/input.rs @@ -119,7 +119,7 @@ impl TextInputBuffer { /// True if the buffer is empty pub fn is_empty(&self) -> bool { self.with_buffer(|buffer| { - buffer.lines.len() == 0 + buffer.lines.is_empty() || (buffer.lines.len() == 1 && buffer.lines[0].text().is_empty()) }) } From 771477cab65ad4b7d2d0e329638610723937c9cc Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Fri, 1 Aug 2025 12:24:14 +0100 Subject: [PATCH 03/53] Renamings: * `TextInputAction` -> `TextEdit` * `TextInputActions` -> `TextEdits` --- crates/bevy_text/src/input.rs | 79 +++++++++++++++++------------------ crates/bevy_text/src/lib.rs | 2 +- 2 files changed, 39 insertions(+), 42 deletions(-) diff --git a/crates/bevy_text/src/input.rs b/crates/bevy_text/src/input.rs index aa35e8bfc0e15..ed115a24691a7 100644 --- a/crates/bevy_text/src/input.rs +++ b/crates/bevy_text/src/input.rs @@ -82,7 +82,7 @@ fn get_cosmic_text_buffer_contents(buffer: &Buffer) -> String { /// To determine if the `TextLayoutInfo` needs to be updated check the `redraw` method on the `editor` buffer. /// Change detection is not reliable as the editor needs to be borrowed mutably during updates. #[derive(Component, Debug)] -#[require(TextInputAttributes, TextInputTarget, TextInputActions, TextLayoutInfo)] +#[require(TextInputAttributes, TextInputTarget, TextEdits, TextLayoutInfo)] pub struct TextInputBuffer { /// The cosmic text editor buffer pub editor: Editor<'static>, @@ -185,11 +185,8 @@ impl TextInputValue { fn on_insert_text_input_value(mut world: DeferredWorld, context: HookContext) { if let Some(value) = world.get::(context.entity) { let value = value.0.clone(); - if let Some(mut actions) = world - .entity_mut(context.entity) - .get_mut::() - { - actions.queue(TextInputAction::SetText(value)); + if let Some(mut actions) = world.entity_mut(context.entity).get_mut::() { + actions.queue(TextEdit::SetText(value)); } } } @@ -329,21 +326,21 @@ impl Default for TextInputPasswordMask { /// Text input commands queue #[derive(Component, Default)] -pub struct TextInputActions { +pub struct TextEdits { /// Commands to be applied before the text input is updated - pub queue: VecDeque, + pub queue: VecDeque, } -impl TextInputActions { +impl TextEdits { /// queue an action - pub fn queue(&mut self, command: TextInputAction) { + pub fn queue(&mut self, command: TextEdit) { self.queue.push_back(command); } } /// Deferred text input edit and navigation actions applied by the `apply_text_input_actions` system. #[derive(Debug)] -pub enum TextInputAction { +pub enum TextEdit { /// Copy the selected text into the clipboard. Does nothing if no text selected. Copy, /// Copy the selected text into the clipboard, then delete the selected text. Does nothing if no text selected. @@ -407,7 +404,7 @@ pub enum TextInputAction { Submit, } -impl TextInputAction { +impl TextEdit { /// An action that moves the cursor. /// If `with_select` is true, it selects as it moves pub fn motion(motion: Motion, with_select: bool) -> Self { @@ -467,13 +464,13 @@ fn apply_action<'a>( /// Apply the queued actions for each text input, with special case for submit actions. /// Then update [`TextInputValue`]s -pub fn apply_text_input_actions( +pub fn apply_text_edits( mut commands: Commands, mut font_system: ResMut, mut text_input_query: Query<( Entity, &mut TextInputBuffer, - &mut TextInputActions, + &mut TextEdits, &TextInputAttributes, Option<&TextInputFilter>, Option<&mut TextInputUndoHistory>, @@ -493,7 +490,7 @@ pub fn apply_text_input_actions( { for action in text_input_actions.queue.drain(..) { match action { - TextInputAction::Submit => { + TextEdit::Submit => { commands.trigger_targets( TextInputEvent::Submission { text: buffer.get_text(), @@ -509,7 +506,7 @@ pub fn apply_text_input_actions( maybe_filter, attribs.max_chars, &mut clipboard, - TextInputAction::Clear, + TextEdit::Clear, ); if let Some(history) = maybe_history.as_mut() { @@ -944,35 +941,35 @@ fn apply_text_input_action( maybe_filter: Option<&TextInputFilter>, max_chars: Option, clipboard_contents: &mut ResMut, - action: TextInputAction, + action: TextEdit, ) -> bool { editor.start_change(); match action { - TextInputAction::Copy => { + TextEdit::Copy => { if let Some(text) = editor.copy_selection() { clipboard_contents.0 = text; } } - TextInputAction::Cut => { + TextEdit::Cut => { if let Some(text) = editor.copy_selection() { clipboard_contents.0 = text; editor.delete_selection(); } } - TextInputAction::Paste => { + TextEdit::Paste => { editor.insert_string(&clipboard_contents.0, None); } - TextInputAction::Motion { + TextEdit::Motion { motion, with_select, } => { apply_motion(&mut editor, with_select, motion); } - TextInputAction::Insert(ch) => { + TextEdit::Insert(ch) => { editor.action(Action::Insert(ch)); } - TextInputAction::Overwrite(ch) => match editor.selection() { + TextEdit::Overwrite(ch) => match editor.selection() { Selection::None => { if is_cursor_at_end_of_line(&mut editor) { editor.action(Action::Insert(ch)); @@ -983,96 +980,96 @@ fn apply_text_input_action( } _ => editor.action(Action::Insert(ch)), }, - TextInputAction::NewLine => { + TextEdit::NewLine => { editor.action(Action::Enter); } - TextInputAction::Backspace => { + TextEdit::Backspace => { if !editor.delete_selection() { editor.action(Action::Backspace); } } - TextInputAction::Delete => { + TextEdit::Delete => { if !editor.delete_selection() { editor.action(Action::Delete); } } - TextInputAction::Indent => { + TextEdit::Indent => { editor.action(Action::Indent); } - TextInputAction::Unindent => { + TextEdit::Unindent => { editor.action(Action::Unindent); } - TextInputAction::Click(point) => { + TextEdit::Click(point) => { editor.action(Action::Click { x: point.x, y: point.y, }); } - TextInputAction::DoubleClick(point) => { + TextEdit::DoubleClick(point) => { editor.action(Action::DoubleClick { x: point.x, y: point.y, }); } - TextInputAction::TripleClick(point) => { + TextEdit::TripleClick(point) => { editor.action(Action::TripleClick { x: point.x, y: point.y, }); } - TextInputAction::Drag(point) => { + TextEdit::Drag(point) => { editor.action(Action::Drag { x: point.x, y: point.y, }); } - TextInputAction::Scroll { lines } => { + TextEdit::Scroll { lines } => { editor.action(Action::Scroll { lines }); } - TextInputAction::Undo => { + TextEdit::Undo => { if let Some(history) = maybe_history.as_mut() { for action in history.changes.undo() { apply_action(&mut editor, action); } } } - TextInputAction::Redo => { + TextEdit::Redo => { if let Some(history) = maybe_history.as_mut() { for action in history.changes.redo() { apply_action(&mut editor, action); } } } - TextInputAction::SelectAll => { + TextEdit::SelectAll => { editor.action(Action::Motion(Motion::BufferStart)); let cursor = editor.cursor(); editor.set_selection(Selection::Normal(cursor)); editor.action(Action::Motion(Motion::BufferEnd)); } - TextInputAction::SelectLine => { + TextEdit::SelectLine => { editor.action(Action::Motion(Motion::Home)); let cursor = editor.cursor(); editor.set_selection(Selection::Normal(cursor)); editor.action(Action::Motion(Motion::End)); } - TextInputAction::Escape => { + TextEdit::Escape => { editor.set_selection(Selection::None); } - TextInputAction::Clear => { + TextEdit::Clear => { editor.action(Action::Motion(Motion::BufferStart)); let cursor = editor.cursor(); editor.set_selection(Selection::Normal(cursor)); editor.action(Action::Motion(Motion::BufferEnd)); editor.action(Action::Delete); } - TextInputAction::SetText(text) => { + TextEdit::SetText(text) => { editor.action(Action::Motion(Motion::Home)); let cursor = editor.cursor(); editor.set_selection(Selection::Normal(cursor)); editor.action(Action::Motion(Motion::End)); editor.insert_string(&text, None); } - TextInputAction::Submit => {} + TextEdit::Submit => {} } let Some(mut change) = editor.finish_change() else { diff --git a/crates/bevy_text/src/lib.rs b/crates/bevy_text/src/lib.rs index c0ab14b5a5068..5a77e2cd3814d 100644 --- a/crates/bevy_text/src/lib.rs +++ b/crates/bevy_text/src/lib.rs @@ -139,7 +139,7 @@ impl Plugin for TextPlugin { PostUpdate, ( update_text_input_buffers, - apply_text_input_actions, + apply_text_edits, update_password_masks, update_text_input_layouts, update_text_input_prompt_layouts, From 11074c07e81a35767250cc3d20985e88c51fc5ce Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Fri, 1 Aug 2025 12:30:06 +0100 Subject: [PATCH 04/53] Renamed `TextInputPasswordMask` to `PasswordMask` --- crates/bevy_text/src/input.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/bevy_text/src/input.rs b/crates/bevy_text/src/input.rs index ed115a24691a7..f2116e6e62ed6 100644 --- a/crates/bevy_text/src/input.rs +++ b/crates/bevy_text/src/input.rs @@ -305,7 +305,7 @@ impl TextInputFilter { /// With variable width fonts mouse picking and horizontal scrolling /// may not work correctly. #[derive(Component)] -pub struct TextInputPasswordMask { +pub struct PasswordMask { /// If true the password will not be hidden pub show_password: bool, /// Char that will replace the masked input characters, by default `*` @@ -314,7 +314,7 @@ pub struct TextInputPasswordMask { editor: Editor<'static>, } -impl Default for TextInputPasswordMask { +impl Default for PasswordMask { fn default() -> Self { Self { show_password: false, @@ -646,7 +646,7 @@ pub fn update_text_input_buffers( /// With variable sized fonts the glyph geometry of the password mask editor buffer may not match the /// underlying editor buffer, possibly resulting in incorrect scrolling and mouse interactions. pub fn update_password_masks( - mut text_input_query: Query<(&mut TextInputBuffer, &mut TextInputPasswordMask)>, + mut text_input_query: Query<(&mut TextInputBuffer, &mut PasswordMask)>, mut cosmic_font_system: ResMut, ) { let font_system = &mut cosmic_font_system.0; @@ -763,7 +763,7 @@ pub fn update_text_input_layouts( &mut TextLayoutInfo, &mut TextInputBuffer, &TextInputAttributes, - Option<&mut TextInputPasswordMask>, + Option<&mut PasswordMask>, )>, mut font_system: ResMut, mut swash_cache: ResMut, From f991426ba54b5a1b8da5349f5fd4038a81d4beca Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Fri, 1 Aug 2025 12:45:12 +0100 Subject: [PATCH 05/53] Updated comments including suggestions from review --- crates/bevy_text/src/input.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/crates/bevy_text/src/input.rs b/crates/bevy_text/src/input.rs index f2116e6e62ed6..e044a81bdcf71 100644 --- a/crates/bevy_text/src/input.rs +++ b/crates/bevy_text/src/input.rs @@ -301,7 +301,7 @@ impl TextInputFilter { /// Add this component to hide the text input buffer contents /// by replacing the characters with `mask_char`. /// -/// Should only be used with monospaced fonts. +/// It is strongly recommended to only use a `PasswordMask` with fixed-widthg fonts. /// With variable width fonts mouse picking and horizontal scrolling /// may not work correctly. #[derive(Component)] @@ -338,7 +338,7 @@ impl TextEdits { } } -/// Deferred text input edit and navigation actions applied by the `apply_text_input_actions` system. +/// Deferred text input edit and navigation actions applied by the `apply_text_edits` system. #[derive(Debug)] pub enum TextEdit { /// Copy the selected text into the clipboard. Does nothing if no text selected. @@ -543,9 +543,9 @@ pub fn apply_text_edits( } } -/// update the text input buffer when a non-text edit change happens like -/// the font or line height changing and the buffer's metrics and attributes need -/// to be regenerated +/// Updates the text input buffer in response to changes +/// that require regeneration of the the buffer's +/// metrics and attributes. pub fn update_text_input_buffers( mut text_input_query: Query<( &mut TextInputBuffer, @@ -643,6 +643,7 @@ pub fn update_text_input_buffers( /// Update password masks to mirror the underlying `TextInputBuffer`. /// +/// The recommended practice is to use fixed-width fonts for password inputs. /// With variable sized fonts the glyph geometry of the password mask editor buffer may not match the /// underlying editor buffer, possibly resulting in incorrect scrolling and mouse interactions. pub fn update_password_masks( @@ -672,8 +673,8 @@ pub fn update_password_masks( } } -/// Based on `LayoutRunIter` from cosmic-text but doesn't crop the -/// bottom line when scrolling up. +/// Based on `LayoutRunIter` from cosmic-text but fixes a bug where the +/// bottom line should be visible but gets cropped when scrolling upwards. #[derive(Debug)] pub struct ScrollingLayoutRunIter<'b> { /// Cosmic text buffer @@ -783,7 +784,7 @@ pub fn update_text_input_layouts( .as_mut() .filter(|mask| !mask.show_password) { - // The underlying buffer isn't visible, but set redraw to false as though it has been to avoid unnecessary reupdates. + // The underlying buffer is hidden, so set redraw to false to avoid unnecessary reupdates. buffer.editor.set_redraw(false); &mut password_mask.bypass_change_detection().editor } else { From 62ca2f8ef78526c772501f7fe9ac5acd2b30a494 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Fri, 1 Aug 2025 12:51:44 +0100 Subject: [PATCH 06/53] Added a `TextEdit::InsertString` variant. Use to insert a string at the cursor. If there is a selection, overwrites it instead. --- crates/bevy_text/src/input.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/bevy_text/src/input.rs b/crates/bevy_text/src/input.rs index e044a81bdcf71..d73f1a4a0c057 100644 --- a/crates/bevy_text/src/input.rs +++ b/crates/bevy_text/src/input.rs @@ -359,7 +359,9 @@ pub enum TextEdit { /// Set the character at the cursor, overwriting the previous character. Inserts if cursor is at the end of a line. /// If there is a selection, replaces the selection with the character instead. Overwrite(char), - /// Start a new line. + /// Insert a string at the cursor. If there is a selection, replaces the selection with the string instead. + InsertString(String), + /// Start a new line. Ignored for single line text inputs. NewLine, /// Delete the character behind the cursor. /// If there is a selection, deletes the selection instead. @@ -970,6 +972,9 @@ fn apply_text_input_action( TextEdit::Insert(ch) => { editor.action(Action::Insert(ch)); } + TextEdit::InsertString(text) => { + editor.insert_string(&text, None); + } TextEdit::Overwrite(ch) => match editor.selection() { Selection::None => { if is_cursor_at_end_of_line(&mut editor) { From 308d0c96a4a93f4b8d7c363ed999497694f0b6bf Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Fri, 1 Aug 2025 12:54:01 +0100 Subject: [PATCH 07/53] Renamed `appy_text_input_action` to `apply_text_edit` --- crates/bevy_text/src/input.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/bevy_text/src/input.rs b/crates/bevy_text/src/input.rs index d73f1a4a0c057..6251d0ad9624a 100644 --- a/crates/bevy_text/src/input.rs +++ b/crates/bevy_text/src/input.rs @@ -502,7 +502,7 @@ pub fn apply_text_edits( ); if attribs.clear_on_submit { - apply_text_input_action( + apply_text_edit( buffer.editor.borrow_with(&mut font_system), maybe_history.as_mut().map(AsMut::as_mut), maybe_filter, @@ -517,7 +517,7 @@ pub fn apply_text_edits( } } action => { - if !apply_text_input_action( + if !apply_text_edit( buffer.editor.borrow_with(&mut font_system), maybe_history.as_mut().map(AsMut::as_mut), maybe_filter, @@ -938,7 +938,7 @@ pub fn update_text_input_layouts( } /// Apply a text input action to a text input -fn apply_text_input_action( +fn apply_text_edit( mut editor: BorrowedWithFontSystem<'_, Editor<'static>>, mut maybe_history: Option<&mut TextInputUndoHistory>, maybe_filter: Option<&TextInputFilter>, From 158f3296086deb6f9f62013fac7b3afabf2acb8c Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Fri, 1 Aug 2025 13:28:06 +0100 Subject: [PATCH 08/53] Updated `TextInputValue`'s comments to explain that it is synchronised automatically with the `TextInputValue `text --- crates/bevy_text/src/input.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/bevy_text/src/input.rs b/crates/bevy_text/src/input.rs index 6251d0ad9624a..bb0d2c44af38a 100644 --- a/crates/bevy_text/src/input.rs +++ b/crates/bevy_text/src/input.rs @@ -161,8 +161,9 @@ impl TextInputTarget { } } -/// Contains the current text in the text input buffer -/// If inserted, replaces the current text in the text buffer +/// Contains the current text in the text input buffer. +/// Automatically synchronised with the buffer by [`apply_text_edits`] after any edits are applied. +/// On insertion, replaces the current text in the text buffer. #[derive(Component, PartialEq, Debug, Default, Deref)] #[component( on_insert = on_insert_text_input_value, From a7d5e45cd7a32a29fe13c55b0398e77a278925bd Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Fri, 1 Aug 2025 13:29:58 +0100 Subject: [PATCH 09/53] Expanded the doc comments for `TextInputAttributes`. --- crates/bevy_text/src/input.rs | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/crates/bevy_text/src/input.rs b/crates/bevy_text/src/input.rs index bb0d2c44af38a..533d70e16d42b 100644 --- a/crates/bevy_text/src/input.rs +++ b/crates/bevy_text/src/input.rs @@ -192,8 +192,8 @@ fn on_insert_text_input_value(mut world: DeferredWorld, context: HookContext) { } } -/// Common text input properties set by the user that -/// require a layout recomputation or font update on changes. +/// Common text input properties set by the user. +/// On changes, the text input systems will automatically update the buffer, layout and fonts as required. #[derive(Component, Debug, PartialEq)] pub struct TextInputAttributes { /// The text input's font, also used for any prompt or password mask. @@ -215,11 +215,13 @@ pub struct TextInputAttributes { /// Any edits that extend the length above `max_chars` are ignored. /// If set on a buffer longer than `max_chars` the buffer will be truncated. pub max_chars: Option, - /// The number of lines the buffer will display at once. - /// Limited by the size of the target. - /// If None or equal or less than 0, will fill the target space. - pub lines: Option, - /// Clear on submit + /// The maximum number of lines the buffer will display without scrolling. + /// * Clamped between zero and target height divided by line height. + /// * If None or equal or less than 0, will fill the target space. + /// * Only restricts the maximum number of visible lines, places no constaint on the text buffer's length. + /// * Supports fractional values, `visible_lines: Some(2.5)` will display two and a half lines of text. + pub visible_lines: Option, + /// Clear on submit (Triggered when [`apply_text_edits`] recieves a [`TextEdit::Submit`] edit for an entity). pub clear_on_submit: bool, } @@ -233,7 +235,7 @@ impl Default for TextInputAttributes { justify: Default::default(), line_break: Default::default(), max_chars: None, - lines: None, + visible_lines: None, clear_on_submit: false, } } @@ -624,11 +626,12 @@ pub fn update_text_input_buffers( .unwrap_or(0.0) * buffer.metrics().font_size; - let height = if let Some(lines) = attributes.lines.filter(|lines| 0. < *lines) { - (metrics.line_height * lines).max(target.size.y) - } else { - target.size.y - }; + let height = + if let Some(lines) = attributes.visible_lines.filter(|lines| 0. < *lines) { + (metrics.line_height * lines).max(target.size.y) + } else { + target.size.y + }; buffer.set_size( font_system, From 785a1d5e705c9c1a070211f9108482b989711271 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Fri, 1 Aug 2025 13:31:11 +0100 Subject: [PATCH 10/53] Renamed `TextInputUndoHistory` to `UndoHistory` --- crates/bevy_text/src/input.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/bevy_text/src/input.rs b/crates/bevy_text/src/input.rs index 533d70e16d42b..9cccf98fc9fd4 100644 --- a/crates/bevy_text/src/input.rs +++ b/crates/bevy_text/src/input.rs @@ -133,12 +133,12 @@ impl TextInputBuffer { /// Component containing the change history for a text input. /// Text input entities without this component will ignore undo and redo actions. #[derive(Component, Debug, Default)] -pub struct TextInputUndoHistory { +pub struct UndoHistory { /// The commands to undo and undo pub changes: cosmic_undo_2::Commands, } -impl TextInputUndoHistory { +impl UndoHistory { /// Clear the history for the text input pub fn clear(&mut self) { self.changes.clear(); @@ -478,7 +478,7 @@ pub fn apply_text_edits( &mut TextEdits, &TextInputAttributes, Option<&TextInputFilter>, - Option<&mut TextInputUndoHistory>, + Option<&mut UndoHistory>, Option<&mut TextInputValue>, )>, mut clipboard: ResMut, @@ -944,7 +944,7 @@ pub fn update_text_input_layouts( /// Apply a text input action to a text input fn apply_text_edit( mut editor: BorrowedWithFontSystem<'_, Editor<'static>>, - mut maybe_history: Option<&mut TextInputUndoHistory>, + mut maybe_history: Option<&mut UndoHistory>, maybe_filter: Option<&TextInputFilter>, max_chars: Option, clipboard_contents: &mut ResMut, From b4589b8e39ec329595ee44e4b6a152cdebc84890 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Fri, 1 Aug 2025 13:48:04 +0100 Subject: [PATCH 11/53] Updated buffer's docs and added a `needs_redraw` function. --- crates/bevy_text/src/input.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/bevy_text/src/input.rs b/crates/bevy_text/src/input.rs index 9cccf98fc9fd4..8155c1cf96f28 100644 --- a/crates/bevy_text/src/input.rs +++ b/crates/bevy_text/src/input.rs @@ -79,14 +79,15 @@ fn get_cosmic_text_buffer_contents(buffer: &Buffer) -> String { /// The text input buffer. /// Primary component that contains the text layout. /// -/// To determine if the `TextLayoutInfo` needs to be updated check the `redraw` method on the `editor` buffer. -/// Change detection is not reliable as the editor needs to be borrowed mutably during updates. +/// The `needs_redraw` method can be used to check if the buffer's contents have changed and need redrawing. +/// Component change detection is not reliable as the editor buffer needs to be borrowed mutably during updates. #[derive(Component, Debug)] #[require(TextInputAttributes, TextInputTarget, TextEdits, TextLayoutInfo)] pub struct TextInputBuffer { - /// The cosmic text editor buffer + /// The cosmic text editor buffer. pub editor: Editor<'static>, - /// Space advance width for the current font + /// Space advance width for the current font, used to determine the width of the cursor when it is at the end of a line + /// or when the buffer is empty. pub space_advance: f32, } @@ -128,6 +129,11 @@ impl TextInputBuffer { pub fn get_text(&self) -> String { self.editor.with_buffer(get_cosmic_text_buffer_contents) } + + /// Returns true if the buffer's contents have changed and need to be redrawn. + pub fn needs_redraw(&self) -> bool { + self.editor.redraw() + } } /// Component containing the change history for a text input. From 8e611050ac53842775d92b7fc0a046b9b4f5bb7f Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Fri, 1 Aug 2025 14:01:34 +0100 Subject: [PATCH 12/53] Updated the doc comments for `apply_text_edits` --- crates/bevy_text/src/input.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/bevy_text/src/input.rs b/crates/bevy_text/src/input.rs index 8155c1cf96f28..13292a17fb8d5 100644 --- a/crates/bevy_text/src/input.rs +++ b/crates/bevy_text/src/input.rs @@ -473,8 +473,9 @@ fn apply_action<'a>( editor.set_redraw(true); } -/// Apply the queued actions for each text input, with special case for submit actions. -/// Then update [`TextInputValue`]s +/// Apply each [`TextInputBuffer`]'s queued [`TextEdit`]s. +/// After all the edits are applied, if the text input entity has a [`TextInputValue`] component, then +/// the [`TextInputValue`]'s text is synchronised with the contents of the [`TextInputBuffer`]. pub fn apply_text_edits( mut commands: Commands, mut font_system: ResMut, From ede9d608fa558aa81fb2e18b1f26689b862b08ae Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Fri, 1 Aug 2025 14:03:39 +0100 Subject: [PATCH 13/53] updated another doc comment --- crates/bevy_text/src/input.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bevy_text/src/input.rs b/crates/bevy_text/src/input.rs index 13292a17fb8d5..817a48696e1e7 100644 --- a/crates/bevy_text/src/input.rs +++ b/crates/bevy_text/src/input.rs @@ -271,7 +271,7 @@ pub enum TextInputFilter { } impl TextInputFilter { - /// Returns true if the text passes the filter + /// Returns `true` if the given `text` passes the filter pub fn is_match(&self, text: &str) -> bool { // Always passes if the input is empty unless using a custom filter if text.is_empty() && !matches!(self, Self::Custom(_)) { From a80a5dc86d2803dae82678f4dbab7fa4168aef74 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Fri, 1 Aug 2025 14:05:28 +0100 Subject: [PATCH 14/53] More doc comments --- crates/bevy_text/src/input.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bevy_text/src/input.rs b/crates/bevy_text/src/input.rs index 817a48696e1e7..861b8ac002861 100644 --- a/crates/bevy_text/src/input.rs +++ b/crates/bevy_text/src/input.rs @@ -473,7 +473,7 @@ fn apply_action<'a>( editor.set_redraw(true); } -/// Apply each [`TextInputBuffer`]'s queued [`TextEdit`]s. +/// Applies the [`TextEdit`]s queued for each [`TextInputBuffer`]. /// After all the edits are applied, if the text input entity has a [`TextInputValue`] component, then /// the [`TextInputValue`]'s text is synchronised with the contents of the [`TextInputBuffer`]. pub fn apply_text_edits( From 0b2c268075eae4348923bb3ba525dded49c6ea57 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Fri, 1 Aug 2025 14:10:27 +0100 Subject: [PATCH 15/53] Edited the doc comment for apply_text_edits --- crates/bevy_text/src/input.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/bevy_text/src/input.rs b/crates/bevy_text/src/input.rs index 861b8ac002861..2654cd1df1ad0 100644 --- a/crates/bevy_text/src/input.rs +++ b/crates/bevy_text/src/input.rs @@ -474,8 +474,9 @@ fn apply_action<'a>( } /// Applies the [`TextEdit`]s queued for each [`TextInputBuffer`]. -/// After all the edits are applied, if the text input entity has a [`TextInputValue`] component, then -/// the [`TextInputValue`]'s text is synchronised with the contents of the [`TextInputBuffer`]. +/// +/// After all edits are applied, if a text input entity has a [TextInputValue] component and its buffer was changed, +/// then the [TextInputValue]'s text is updated with the new contents of the [TextInputBuffer]. pub fn apply_text_edits( mut commands: Commands, mut font_system: ResMut, From 93149bc8ef87c0179c9ae1783dc440ae260b976e Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Fri, 1 Aug 2025 14:13:18 +0100 Subject: [PATCH 16/53] More comment edits --- crates/bevy_text/src/input.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bevy_text/src/input.rs b/crates/bevy_text/src/input.rs index 2654cd1df1ad0..dd4aede7a1104 100644 --- a/crates/bevy_text/src/input.rs +++ b/crates/bevy_text/src/input.rs @@ -655,7 +655,7 @@ pub fn update_text_input_buffers( } } -/// Update password masks to mirror the underlying `TextInputBuffer`. +/// Update each [`PasswordMask`] to mirror its underlying [`TextInputBuffer`]. /// /// The recommended practice is to use fixed-width fonts for password inputs. /// With variable sized fonts the glyph geometry of the password mask editor buffer may not match the From 049e350768f61206fe1fb6df8d15bbfab8fa0799 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Fri, 1 Aug 2025 14:25:59 +0100 Subject: [PATCH 17/53] Renamed `Prompt` to `Placeholder` and also updated the comments and the associated types and systems. --- crates/bevy_text/src/input.rs | 48 +++++++++++++++++------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/crates/bevy_text/src/input.rs b/crates/bevy_text/src/input.rs index dd4aede7a1104..2825cd51bbebf 100644 --- a/crates/bevy_text/src/input.rs +++ b/crates/bevy_text/src/input.rs @@ -202,7 +202,7 @@ fn on_insert_text_input_value(mut world: DeferredWorld, context: HookContext) { /// On changes, the text input systems will automatically update the buffer, layout and fonts as required. #[derive(Component, Debug, PartialEq)] pub struct TextInputAttributes { - /// The text input's font, also used for any prompt or password mask. + /// The text input's font, also used for any [`Placeholder`] text or password mask. /// A text input's glyphs must all be from the same font. pub font: Handle, /// The size of the font. @@ -247,7 +247,7 @@ impl Default for TextInputAttributes { } } -/// Any actions that modify a text input's text so that it fails +/// Any actions that modify a [`TextInputBuffer`]'s text so that it fails /// to pass the filter are not applied. #[derive(Component)] pub enum TextInputFilter { @@ -1145,39 +1145,39 @@ pub enum TextInputEvent { }, } -/// Prompt displayed when the input is empty (including whitespace). +/// Placeholder text displayed when the input is empty (including whitespace). /// Optional component. #[derive(Default, Component, Clone, Debug, Reflect, Deref, DerefMut)] #[reflect(Component, Default, Debug)] -#[require(PromptLayout)] -pub struct Prompt(pub String); +#[require(PlaceholderLayout)] +pub struct Placeholder(pub String); -impl Prompt { - /// A new prompt. +impl Placeholder { + /// A new [`Placeholder`] text. pub fn new(prompt: impl Into) -> Self { Self(prompt.into()) } } -/// Layout for the prompt text +/// Layout for the [`Placeholder`] text #[derive(Component)] -pub struct PromptLayout { - /// Prompt's cosmic-text buffer (not an Editor as isn't editable) +pub struct PlaceholderLayout { + /// A [`Placeholder`] text's cosmic-text buffer (not an Editor as isn't editable) buffer: Buffer, - /// Prompt's text layout, displayed when the text input is empty. - /// Doesn't reuse the editor's `TextLayoutInfo` as otherwise the prompt would need a relayout + /// A [`Placeholder`] text's glyph layout. Displayed when the text input is empty. + /// Doesn't reuse the editor's [`TextLayoutInfo`] as otherwise the placeholder would need a relayout /// everytime it was displayed. layout: TextLayoutInfo, } -impl PromptLayout { - /// Get the text layout +impl PlaceholderLayout { + /// Returns the renderable glyph layout for the associated [`Placeholder`] text pub fn layout(&self) -> &TextLayoutInfo { &self.layout } } -impl Default for PromptLayout { +impl Default for PlaceholderLayout { fn default() -> Self { Self { buffer: Buffer::new_empty(Metrics::new(20.0, 20.0)), @@ -1186,8 +1186,8 @@ impl Default for PromptLayout { } } -/// Generates a new text prompt layout when a prompt's text or its target's geometry has changed. -pub fn update_text_input_prompt_layouts( +/// Generates a new [`PlaceholderLayout`] when a [`Placeholder`]'s text or its target's geometry has changed. +pub fn update_placeholder_layouts( mut textures: ResMut>, fonts: Res>, mut font_system: ResMut, @@ -1197,26 +1197,26 @@ pub fn update_text_input_prompt_layouts( mut font_atlas_sets: ResMut, mut text_query: Query< ( - &Prompt, + &Placeholder, &TextInputAttributes, &TextInputTarget, &TextFont, - &mut PromptLayout, + &mut PlaceholderLayout, ), Or<( - Changed, + Changed, Changed, Changed, Changed, )>, >, ) { - for (prompt, style, target, text_font, mut prompt_layout) in text_query.iter_mut() { - let PromptLayout { buffer, layout } = prompt_layout.as_mut(); + for (placeholder, style, target, text_font, mut prompt_layout) in text_query.iter_mut() { + let PlaceholderLayout { buffer, layout } = prompt_layout.as_mut(); layout.clear(); - if prompt.0.is_empty() || target.is_empty() { + if placeholder.0.is_empty() || target.is_empty() { continue; } @@ -1254,7 +1254,7 @@ pub fn update_text_input_prompt_layouts( buffer.set_text( &mut font_system, - &prompt.0, + &placeholder.0, &attrs, cosmic_text::Shaping::Advanced, ); From 3e9c8606f3b449eb215e59e7d0c64a1779a70f72 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Fri, 1 Aug 2025 14:45:56 +0100 Subject: [PATCH 18/53] Fixed renaming. --- crates/bevy_text/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bevy_text/src/lib.rs b/crates/bevy_text/src/lib.rs index 5a77e2cd3814d..ba72d2e325554 100644 --- a/crates/bevy_text/src/lib.rs +++ b/crates/bevy_text/src/lib.rs @@ -142,7 +142,7 @@ impl Plugin for TextPlugin { apply_text_edits, update_password_masks, update_text_input_layouts, - update_text_input_prompt_layouts, + update_placeholder_layouts, ) .chain() .in_set(TextInputSystems) From 5c956c6bc9ab988521f31cd85618662d50a0061b Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Fri, 1 Aug 2025 14:46:11 +0100 Subject: [PATCH 19/53] Rephrased doc comment. --- crates/bevy_text/src/input.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/bevy_text/src/input.rs b/crates/bevy_text/src/input.rs index 2825cd51bbebf..458e74e9d4a68 100644 --- a/crates/bevy_text/src/input.rs +++ b/crates/bevy_text/src/input.rs @@ -247,8 +247,8 @@ impl Default for TextInputAttributes { } } -/// Any actions that modify a [`TextInputBuffer`]'s text so that it fails -/// to pass the filter are not applied. +/// If a text input entity has a `TextInputFilter` component, after each [TextEdit] is applied, the [TextInputBuffer]’s text is checked +/// against the filter, and if it fails, the `TextEdit is rolled back. #[derive(Component)] pub enum TextInputFilter { /// Positive integer input From 6396a11f514c6630caac6e37533c751d7c872ec2 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Fri, 1 Aug 2025 14:47:28 +0100 Subject: [PATCH 20/53] Spellings. --- crates/bevy_text/src/input.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/bevy_text/src/input.rs b/crates/bevy_text/src/input.rs index 458e74e9d4a68..4416a20bfc6be 100644 --- a/crates/bevy_text/src/input.rs +++ b/crates/bevy_text/src/input.rs @@ -168,7 +168,7 @@ impl TextInputTarget { } /// Contains the current text in the text input buffer. -/// Automatically synchronised with the buffer by [`apply_text_edits`] after any edits are applied. +/// Automatically synchronized with the buffer by [`apply_text_edits`] after any edits are applied. /// On insertion, replaces the current text in the text buffer. #[derive(Component, PartialEq, Debug, Default, Deref)] #[component( @@ -224,10 +224,10 @@ pub struct TextInputAttributes { /// The maximum number of lines the buffer will display without scrolling. /// * Clamped between zero and target height divided by line height. /// * If None or equal or less than 0, will fill the target space. - /// * Only restricts the maximum number of visible lines, places no constaint on the text buffer's length. + /// * Only restricts the maximum number of visible lines, places no constraint on the text buffer's length. /// * Supports fractional values, `visible_lines: Some(2.5)` will display two and a half lines of text. pub visible_lines: Option, - /// Clear on submit (Triggered when [`apply_text_edits`] recieves a [`TextEdit::Submit`] edit for an entity). + /// Clear on submit (Triggered when [`apply_text_edits`] receives a [`TextEdit::Submit`] edit for an entity). pub clear_on_submit: bool, } From 5d34e6dc8b7a19cffd4c26e0ea20d1a6c610ee77 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Fri, 1 Aug 2025 14:57:57 +0100 Subject: [PATCH 21/53] Renamed the `InvalidInput` and `ValueChanged` `TextInputEvent` variants to `InvalidEdit` and `TextChanged` respectively. --- crates/bevy_text/src/input.rs | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/crates/bevy_text/src/input.rs b/crates/bevy_text/src/input.rs index 4416a20bfc6be..1d5006d23d3e8 100644 --- a/crates/bevy_text/src/input.rs +++ b/crates/bevy_text/src/input.rs @@ -537,7 +537,7 @@ pub fn apply_text_edits( action, ) { commands.trigger_targets( - TextInputEvent::InvalidInput { text_input: entity }, + TextInputEvent::InvalidEdit { text_input: entity }, entity, ); } @@ -1119,30 +1119,21 @@ fn apply_text_edit( true } -/// Event dispatched when a text input receives the [`TextInputAction::Submit`] action. -/// Contains a copy of the buffer contents at the time when when the action was applied. +/// Automatically propagated events that can be dispatched by a text input entity. #[derive(EntityEvent, Clone, Debug, Component, Reflect)] #[entity_event(traversal = &'static ChildOf, auto_propagate)] #[reflect(Component, Clone)] pub enum TextInputEvent { - /// The input received an invalid input that was filtered - InvalidInput { - /// The source text input entity - text_input: Entity, - }, + /// The text input received an invalid `TextEdit` that was filtered + InvalidEdit, /// Text from the input was submitted Submission { /// The submitted text text: String, - /// The source text input entity - text_input: Entity, - }, - /// The contents of the text input changed due to an edit action. - /// Dispatched if a text input entity has a [`TextInputValue`] component. - ValueChanged { - /// The source text input entity - text_input: Entity, }, + /// The contents of the text input changed due to a [`TextEdit`]. + /// Only dispatched if a text input entity has a [`TextInputValue`] component. + TextChanged, } /// Placeholder text displayed when the input is empty (including whitespace). From 57a70cd2908ccae8bc3cd4a8e7b6079cbd1e6af0 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Fri, 1 Aug 2025 14:58:50 +0100 Subject: [PATCH 22/53] Fixed event dispatch. --- crates/bevy_text/src/input.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/crates/bevy_text/src/input.rs b/crates/bevy_text/src/input.rs index 1d5006d23d3e8..d8b8652c61478 100644 --- a/crates/bevy_text/src/input.rs +++ b/crates/bevy_text/src/input.rs @@ -507,7 +507,6 @@ pub fn apply_text_edits( commands.trigger_targets( TextInputEvent::Submission { text: buffer.get_text(), - text_input: entity, }, entity, ); @@ -536,10 +535,7 @@ pub fn apply_text_edits( &mut clipboard, action, ) { - commands.trigger_targets( - TextInputEvent::InvalidEdit { text_input: entity }, - entity, - ); + commands.trigger_targets(TextInputEvent::InvalidEdit, entity); } } } @@ -549,8 +545,7 @@ pub fn apply_text_edits( if let Some(mut value) = maybe_value { if value.0 != contents { value.0 = contents; - commands - .trigger_targets(TextInputEvent::ValueChanged { text_input: entity }, entity); + commands.trigger_targets(TextInputEvent::TextChanged, entity); } } } From 85bed33a8737e6243339477a1fef0415e15b3a93 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Fri, 1 Aug 2025 15:28:40 +0100 Subject: [PATCH 23/53] Added release note --- .../release-notes/bevy_text_input_module.md | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 release-content/release-notes/bevy_text_input_module.md diff --git a/release-content/release-notes/bevy_text_input_module.md b/release-content/release-notes/bevy_text_input_module.md new file mode 100644 index 0000000000000..fa72b2d552d22 --- /dev/null +++ b/release-content/release-notes/bevy_text_input_module.md @@ -0,0 +1,33 @@ +--- +title: `bevy_text::input` module +authors: ["@Ickshonpe"] +pull_requests: [20366] +--- + +A long-standing feature request from our users is support for text input. Whether the user is creating a new character, logging in with a username and password, or creating a new save file, it's vitally important for them to be able to enter a string of text. Unfortunately, writing a robust and feature-rich text input widget is not easy, especially one that supports all of the expected capabilities (such as undo, range selection, and scrolling). This effort is made much easier now that Bevy has incorporated the cosmic crate for text handling. + +Features: +* Placeholder text for empty inputs +* Password mode. +* Filters applied at edit. +* Autopropagated events emitted on submission, invalid edits and text changes. +* Input method agnostic, users queue `TextEdit`s to make changes to the text input's buffer. +* Max character limit +* Responsive height sizing. +* Vertical and horizontal scrolling, fixed the cosmic text vertical scrolling clipping bug. +* Full undo and redo. +* Text selection. +* Cut, copy and paste. +* Numeric input modes. +* Single line modes. +* Support for the common navigation actions like home, end, page down, page up, next word, end of paragraph, etc. +* Backspace. +* Overwrite mode. +* Click to place cursor. +* Drag to select. +* Double click to select a word. +* Triple click to set select a line. +* Indent and unident. +* A `TextInputValue` component that contains a copy of the buffer's text and is automatically synchronised on edits. On insertion the `TextInputValue`s contents replace the current text in the `TextInputBuffer`. + +What we are releasing in this milestone is only the lowest-level, foundational elements of text editing. These are not complete, self-contained widgets, which will come in the next milestone, but more like a toolkit with "some assembly required". For now, you can write your own text input widgets, following the provided examples as a guide. From f43c461f110c7deee29eef869a8f615e190ee1dd Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Fri, 1 Aug 2025 15:35:58 +0100 Subject: [PATCH 24/53] Added migration note for `load_font_to_fontdb` function changes. --- .../load_font_to_fontdb_parameter_change.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 release-content/migration-guides/load_font_to_fontdb_parameter_change.md diff --git a/release-content/migration-guides/load_font_to_fontdb_parameter_change.md b/release-content/migration-guides/load_font_to_fontdb_parameter_change.md new file mode 100644 index 0000000000000..0d12a237b4e2a --- /dev/null +++ b/release-content/migration-guides/load_font_to_fontdb_parameter_change.md @@ -0,0 +1,6 @@ +--- +title: `load_font_to_fontdb` parameter change +pull_requests: [20366] +--- + +`load_font_to_fontdb`'s `text_font: &TextFont` parameter has been renamed to `font_handle` and its type has been changed to `Handle`. From 0b6b1d26d2d5fefd7cf31a126155934d5f5379e9 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Fri, 1 Aug 2025 15:42:31 +0100 Subject: [PATCH 25/53] Update release note --- release-content/release-notes/bevy_text_input_module.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/release-content/release-notes/bevy_text_input_module.md b/release-content/release-notes/bevy_text_input_module.md index fa72b2d552d22..437ddc3d021b6 100644 --- a/release-content/release-notes/bevy_text_input_module.md +++ b/release-content/release-notes/bevy_text_input_module.md @@ -14,7 +14,8 @@ Features: * Input method agnostic, users queue `TextEdit`s to make changes to the text input's buffer. * Max character limit * Responsive height sizing. -* Vertical and horizontal scrolling, fixed the cosmic text vertical scrolling clipping bug. +* Vertical and horizontal scrolling +* Fixes the line cropping while vertical scrolling bug in cosmic-text. * Full undo and redo. * Text selection. * Cut, copy and paste. From 2bd021631b8b4191876ad9c4e94f8ae1e79d79a7 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Sat, 2 Aug 2025 22:08:38 +0100 Subject: [PATCH 26/53] Update crates/bevy_text/src/input.rs Co-authored-by: Tim --- crates/bevy_text/src/input.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/bevy_text/src/input.rs b/crates/bevy_text/src/input.rs index d8b8652c61478..277704c5a94ab 100644 --- a/crates/bevy_text/src/input.rs +++ b/crates/bevy_text/src/input.rs @@ -350,9 +350,9 @@ impl TextEdits { /// Deferred text input edit and navigation actions applied by the `apply_text_edits` system. #[derive(Debug)] pub enum TextEdit { - /// Copy the selected text into the clipboard. Does nothing if no text selected. + /// Copy the selected text into the clipboard. Does nothing if no text is selected. Copy, - /// Copy the selected text into the clipboard, then delete the selected text. Does nothing if no text selected. + /// Copy the selected text into the clipboard, then delete the selected text. Does nothing if no text is selected. Cut, /// Insert the contents of the clipboard at the current cursor position. Does nothing if the clipboard is empty. Paste, From 255d2c14eaf0c99c74d82dd51700320ea213ef99 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Sat, 2 Aug 2025 22:08:46 +0100 Subject: [PATCH 27/53] Update crates/bevy_text/src/input.rs Co-authored-by: Tim --- crates/bevy_text/src/input.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bevy_text/src/input.rs b/crates/bevy_text/src/input.rs index 277704c5a94ab..3debf6ec2f61d 100644 --- a/crates/bevy_text/src/input.rs +++ b/crates/bevy_text/src/input.rs @@ -310,7 +310,7 @@ impl TextInputFilter { /// Add this component to hide the text input buffer contents /// by replacing the characters with `mask_char`. /// -/// It is strongly recommended to only use a `PasswordMask` with fixed-widthg fonts. +/// It is strongly recommended to only use a `PasswordMask` with fixed-width fonts. /// With variable width fonts mouse picking and horizontal scrolling /// may not work correctly. #[derive(Component)] From 419bcaf07a99f581aac5204f3a0fbc342933acdc Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Sat, 2 Aug 2025 22:08:54 +0100 Subject: [PATCH 28/53] Update crates/bevy_text/src/input.rs Co-authored-by: Tim --- crates/bevy_text/src/input.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bevy_text/src/input.rs b/crates/bevy_text/src/input.rs index 3debf6ec2f61d..b9018be13c5c8 100644 --- a/crates/bevy_text/src/input.rs +++ b/crates/bevy_text/src/input.rs @@ -375,7 +375,7 @@ pub enum TextEdit { /// Delete the character behind the cursor. /// If there is a selection, deletes the selection instead. Backspace, - /// Delete the character a the cursor. + /// Delete the character at the cursor. /// If there is a selection, deletes the selection instead. Delete, /// Indent at the cursor. From fe38cf2a08a47312c13761adb0e91c22dca40452 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Sat, 2 Aug 2025 22:09:03 +0100 Subject: [PATCH 29/53] Update release-content/release-notes/bevy_text_input_module.md Co-authored-by: Tim --- release-content/release-notes/bevy_text_input_module.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release-content/release-notes/bevy_text_input_module.md b/release-content/release-notes/bevy_text_input_module.md index 437ddc3d021b6..e0648864e4b8f 100644 --- a/release-content/release-notes/bevy_text_input_module.md +++ b/release-content/release-notes/bevy_text_input_module.md @@ -29,6 +29,6 @@ Features: * Double click to select a word. * Triple click to set select a line. * Indent and unident. -* A `TextInputValue` component that contains a copy of the buffer's text and is automatically synchronised on edits. On insertion the `TextInputValue`s contents replace the current text in the `TextInputBuffer`. +* A `TextInputValue` component that contains a copy of the buffer's text and is automatically synchronized on edits. On insertion the `TextInputValue`s contents replace the current text in the `TextInputBuffer`. What we are releasing in this milestone is only the lowest-level, foundational elements of text editing. These are not complete, self-contained widgets, which will come in the next milestone, but more like a toolkit with "some assembly required". For now, you can write your own text input widgets, following the provided examples as a guide. From dea66167061281d281706704e50845c511e84c36 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Sat, 2 Aug 2025 23:29:36 +0100 Subject: [PATCH 30/53] Update crates/bevy_text/src/input.rs Co-authored-by: Tim --- crates/bevy_text/src/input.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bevy_text/src/input.rs b/crates/bevy_text/src/input.rs index b9018be13c5c8..ed6cd19c4909c 100644 --- a/crates/bevy_text/src/input.rs +++ b/crates/bevy_text/src/input.rs @@ -1096,7 +1096,7 @@ fn apply_text_edit( if maybe_filter.is_some() || max_chars.is_some() { let text = editor.with_buffer(get_cosmic_text_buffer_contents); if maybe_filter.is_some_and(|filter| !filter.is_match(&text)) - || max_chars.is_some_and(|max_chars| max_chars <= text.chars().count()) + || max_chars.is_some_and(|max_chars| max_chars < text.chars().count()) { change.reverse(); editor.apply_change(&change); From 48475ffecb2e9d870818da6cabcbed4e49517ba1 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Sat, 2 Aug 2025 23:32:32 +0100 Subject: [PATCH 31/53] FIxed comment --- crates/bevy_text/src/input.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/bevy_text/src/input.rs b/crates/bevy_text/src/input.rs index d8b8652c61478..3345bd70a04d0 100644 --- a/crates/bevy_text/src/input.rs +++ b/crates/bevy_text/src/input.rs @@ -350,9 +350,9 @@ impl TextEdits { /// Deferred text input edit and navigation actions applied by the `apply_text_edits` system. #[derive(Debug)] pub enum TextEdit { - /// Copy the selected text into the clipboard. Does nothing if no text selected. + /// Copy the selected text into the clipboard. Does nothing if no text is selected. Copy, - /// Copy the selected text into the clipboard, then delete the selected text. Does nothing if no text selected. + /// Copy the selected text into the clipboard, then delete the selected text. Does nothing if no text is selected. Cut, /// Insert the contents of the clipboard at the current cursor position. Does nothing if the clipboard is empty. Paste, @@ -375,7 +375,7 @@ pub enum TextEdit { /// Delete the character behind the cursor. /// If there is a selection, deletes the selection instead. Backspace, - /// Delete the character a the cursor. + /// Delete the character at the cursor. /// If there is a selection, deletes the selection instead. Delete, /// Indent at the cursor. @@ -409,7 +409,7 @@ pub enum TextEdit { Escape, /// Clear the text input buffer. Clear, - /// Set the contents of the text input buffer. The existing contents is discarded. + /// Set the contents of the text input buffer. The existing contents are discarded. SetText(String), /// Submit the contents of the text input buffer Submit, From 96bacc1a102a13efb721b63a436abc193add0af1 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Sat, 2 Aug 2025 23:34:47 +0100 Subject: [PATCH 32/53] Fix comments --- crates/bevy_text/src/input.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bevy_text/src/input.rs b/crates/bevy_text/src/input.rs index f9ec5d6e3a247..d5bf5dc36f1b6 100644 --- a/crates/bevy_text/src/input.rs +++ b/crates/bevy_text/src/input.rs @@ -1148,7 +1148,7 @@ impl Placeholder { /// Layout for the [`Placeholder`] text #[derive(Component)] pub struct PlaceholderLayout { - /// A [`Placeholder`] text's cosmic-text buffer (not an Editor as isn't editable) + /// A [`Placeholder`] text's cosmic-text buffer (not an Editor as it isn't editable). buffer: Buffer, /// A [`Placeholder`] text's glyph layout. Displayed when the text input is empty. /// Doesn't reuse the editor's [`TextLayoutInfo`] as otherwise the placeholder would need a relayout From 203572692f64a2f63ae71a9211c78ea87c38eb88 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Mon, 4 Aug 2025 13:48:16 +0100 Subject: [PATCH 33/53] Added text input 2d example --- Cargo.toml | 11 +++++++++++ examples/2d/text_input_2d.rs | 20 ++++++++++++++++++++ examples/README.md | 1 + 3 files changed, 32 insertions(+) create mode 100644 examples/2d/text_input_2d.rs diff --git a/Cargo.toml b/Cargo.toml index 4b4f00b090393..cd1ac16fcf533 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -864,6 +864,17 @@ description = "Generates text in 2D" category = "2D Rendering" wasm = true +[[example]] +name = "text_input_2d" +path = "examples/2d/text_input_2d.rs" +doc-scrape-examples = true + +[package.metadata.example.text_input_2d] +name = "Text Input 2D" +description = "Text input in 2D" +category = "2D Rendering" +wasm = true + [[example]] name = "texture_atlas" path = "examples/2d/texture_atlas.rs" diff --git a/examples/2d/text_input_2d.rs b/examples/2d/text_input_2d.rs new file mode 100644 index 0000000000000..d3edff73ab940 --- /dev/null +++ b/examples/2d/text_input_2d.rs @@ -0,0 +1,20 @@ +//! basic bevy_2d text input +use bevy::{ + color::palettes::css::*, + math::ops, + prelude::*, + sprite::Anchor, + text::{FontSmoothing, LineBreak, TextBounds}, +}; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_systems(Startup, setup) + .add_systems(Update, update) + .run(); +} + +fn setup() {} + +fn update() {} diff --git a/examples/README.md b/examples/README.md index 29420e66c53ac..5fb1afa3f1b9b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -128,6 +128,7 @@ Example | Description [Sprite Slice](../examples/2d/sprite_slice.rs) | Showcases slicing sprites into sections that can be scaled independently via the 9-patch technique [Sprite Tile](../examples/2d/sprite_tile.rs) | Renders a sprite tiled in a grid [Text 2D](../examples/2d/text2d.rs) | Generates text in 2D +[Text Input 2d](../examples/2d/text_input_2d.rs) | Text input in 2D [Texture Atlas](../examples/2d/texture_atlas.rs) | Generates a texture atlas (sprite sheet) from individual sprites [Tilemap Chunk](../examples/2d/tilemap_chunk.rs) | Renders a tilemap chunk [Transparency in 2D](../examples/2d/transparency_2d.rs) | Demonstrates transparency in 2d From a692fd820c1cf2d2a058907ead7cef25a372aec4 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Mon, 4 Aug 2025 13:53:05 +0100 Subject: [PATCH 34/53] cargo run -p build-templated-pages -- build-example-page --- examples/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/README.md b/examples/README.md index 5fb1afa3f1b9b..58778fb223c69 100644 --- a/examples/README.md +++ b/examples/README.md @@ -128,7 +128,7 @@ Example | Description [Sprite Slice](../examples/2d/sprite_slice.rs) | Showcases slicing sprites into sections that can be scaled independently via the 9-patch technique [Sprite Tile](../examples/2d/sprite_tile.rs) | Renders a sprite tiled in a grid [Text 2D](../examples/2d/text2d.rs) | Generates text in 2D -[Text Input 2d](../examples/2d/text_input_2d.rs) | Text input in 2D +[Text Input 2D](../examples/2d/text_input_2d.rs) | Text input in 2D [Texture Atlas](../examples/2d/texture_atlas.rs) | Generates a texture atlas (sprite sheet) from individual sprites [Tilemap Chunk](../examples/2d/tilemap_chunk.rs) | Renders a tilemap chunk [Transparency in 2D](../examples/2d/transparency_2d.rs) | Demonstrates transparency in 2d From 622bbe68bc06a2c93c5e2752aaa7a44052c1bf8e Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Tue, 5 Aug 2025 21:15:57 +0100 Subject: [PATCH 35/53] Added example skeleton. --- examples/2d/text_input_2d.rs | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/examples/2d/text_input_2d.rs b/examples/2d/text_input_2d.rs index d3edff73ab940..1f7f35949c656 100644 --- a/examples/2d/text_input_2d.rs +++ b/examples/2d/text_input_2d.rs @@ -4,17 +4,37 @@ use bevy::{ math::ops, prelude::*, sprite::Anchor, - text::{FontSmoothing, LineBreak, TextBounds}, + text::{ + FontSmoothing, LineBreak, Placeholder, TextBounds, TextInputBuffer, TextInputTarget, + TextLayoutInfo, UndoHistory, + }, }; +use bevy_render::Extract; fn main() { App::new() .add_plugins(DefaultPlugins) .add_systems(Startup, setup) .add_systems(Update, update) + .add_systems(PostUpdate, update_targets) + .add_systems(ExtractSchedule, extract_text_input) .run(); } -fn setup() {} +fn setup(mut commands: Commands) { + commands.spawn(( + Transform::default(), + TextInputBuffer::default(), + UndoHistory::default(), + Placeholder::new("type here.."), + )); +} + +fn update_targets() {} fn update() {} + +fn extract_text_input( + query: Extract>, +) { +} From 307590a10f4626d89e2c55e72c13332d245f5864 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Fri, 8 Aug 2025 17:05:03 +0100 Subject: [PATCH 36/53] Added `TextInputStyle` component. Added cursor blink timer to `TextInputBuffer` --- crates/bevy_text/Cargo.toml | 1 + crates/bevy_text/src/input.rs | 64 +++++++++++++++++++++++++++++++++-- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/crates/bevy_text/Cargo.toml b/crates/bevy_text/Cargo.toml index acd7d6a0bb1f8..0b1545b35069b 100644 --- a/crates/bevy_text/Cargo.toml +++ b/crates/bevy_text/Cargo.toml @@ -24,6 +24,7 @@ bevy_math = { path = "../bevy_math", version = "0.17.0-dev" } bevy_reflect = { path = "../bevy_reflect", version = "0.17.0-dev" } bevy_render = { path = "../bevy_render", version = "0.17.0-dev" } bevy_sprite = { path = "../bevy_sprite", version = "0.17.0-dev" } +bevy_time = { path = "../bevy_time", version = "0.17.0-dev" } bevy_transform = { path = "../bevy_transform", version = "0.17.0-dev" } bevy_window = { path = "../bevy_window", version = "0.17.0-dev" } bevy_utils = { path = "../bevy_utils", version = "0.17.0-dev" } diff --git a/crates/bevy_text/src/input.rs b/crates/bevy_text/src/input.rs index d5bf5dc36f1b6..67c1664398054 100644 --- a/crates/bevy_text/src/input.rs +++ b/crates/bevy_text/src/input.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use crate::buffer_dimensions; use crate::load_font_to_fontdb; use crate::CosmicFontSystem; @@ -16,6 +18,12 @@ use crate::TextPipeline; use alloc::collections::VecDeque; use bevy_asset::Assets; use bevy_asset::Handle; +use bevy_color::palettes::tailwind::BLUE_900; +use bevy_color::palettes::tailwind::GRAY_300; +use bevy_color::palettes::tailwind::GRAY_400; +use bevy_color::palettes::tailwind::GRAY_950; +use bevy_color::palettes::tailwind::SKY_300; +use bevy_color::Color; use bevy_derive::Deref; use bevy_derive::DerefMut; use bevy_ecs::change_detection::DetectChanges; @@ -44,6 +52,7 @@ use bevy_math::UVec2; use bevy_math::Vec2; use bevy_reflect::prelude::ReflectDefault; use bevy_reflect::Reflect; +use bevy_time::Time; use cosmic_text::Action; use cosmic_text::BorrowedWithFontSystem; use cosmic_text::Buffer; @@ -89,6 +98,11 @@ pub struct TextInputBuffer { /// Space advance width for the current font, used to determine the width of the cursor when it is at the end of a line /// or when the buffer is empty. pub space_advance: f32, + /// Controls cursor blinking. + /// If the value is none or greater than the `blink_interval` in `TextCursorStyle` then the cursor + /// is not displayed. + /// The timer is reset when a `TextEdit` is applied. + pub cursor_blink_timer: Option, } impl Default for TextInputBuffer { @@ -96,6 +110,7 @@ impl Default for TextInputBuffer { Self { editor: Editor::new(Buffer::new_empty(Metrics::new(20.0, 20.0))), space_advance: 20., + cursor_blink_timer: None, } } } @@ -198,6 +213,39 @@ fn on_insert_text_input_value(mut world: DeferredWorld, context: HookContext) { } } +/// Visual styling for a text input widget. +#[derive(Component, Clone)] +pub struct TextInputStyle { + /// Text color + pub text_color: Color, + /// Color of text under an overwrite cursor + pub overwrite_text_color: Color, + /// Color of input prompt (if set) + pub prompt_color: Color, + /// Color of the cursor. + pub cursor_color: Color, + /// Size of the insert cursor relative to the space advance width and line height. + pub cursor_size: Vec2, + /// How long the cursor blinks for. + pub cursor_blink_interval: Duration, + /// Color of selection blocks + pub selection_color: Color, +} + +impl Default for TextInputStyle { + fn default() -> Self { + Self { + text_color: Color::from(GRAY_300), + overwrite_text_color: GRAY_950.into(), + prompt_color: SKY_300.into(), + cursor_color: GRAY_400.into(), + cursor_size: Vec2::new(0.2, 1.), + cursor_blink_interval: Duration::from_secs_f32(0.5), + selection_color: BLUE_900.into(), + } + } +} + /// Common text input properties set by the user. /// On changes, the text input systems will automatically update the buffer, layout and fonts as required. #[derive(Component, Debug, PartialEq)] @@ -558,21 +606,33 @@ pub fn update_text_input_buffers( mut text_input_query: Query<( &mut TextInputBuffer, Ref, + &TextEdits, + &TextInputStyle, Ref, )>, + time: Res