diff --git a/Cargo.toml b/Cargo.toml index 261cb23632208..085e3732c1a6e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -874,6 +874,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/crates/bevy_text/Cargo.toml b/crates/bevy_text/Cargo.toml index e8b237f5e691d..a9e3a8939d2f4 100644 --- a/crates/bevy_text/Cargo.toml +++ b/crates/bevy_text/Cargo.toml @@ -25,6 +25,7 @@ bevy_reflect = { path = "../bevy_reflect", version = "0.17.0-dev" } bevy_camera = { path = "../bevy_camera", 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" } @@ -35,6 +36,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..b16a200c1773f --- /dev/null +++ b/crates/bevy_text/src/input.rs @@ -0,0 +1,1382 @@ +use std::time::Duration; + +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 bevy_time::Time; +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. +/// +/// 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. + pub editor: Editor<'static>, + /// 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 { + fn default() -> Self { + Self { + editor: Editor::new(Buffer::new_empty(Metrics::new(20.0, 20.0))), + space_advance: 20., + cursor_blink_timer: None, + } + } +} + +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.is_empty() + || (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) + } + + /// 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. +/// Text input entities without this component will ignore undo and redo actions. +#[derive(Component, Debug, Default)] +pub struct UndoHistory { + /// The commands to undo and undo + pub changes: cosmic_undo_2::Commands, +} + +impl UndoHistory { + /// 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 in physical pixels + 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. +/// 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( + 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(TextEdit::SetText(value)); + } + } +} + +/// The time taken for the cursor to blink +#[derive(Resource)] +pub struct TextCursorBlinkInterval(pub Duration); + +impl Default for TextCursorBlinkInterval { + fn default() -> Self { + Self(Duration::from_secs_f32(0.5)) + } +} + +/// 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 [`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. + /// 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 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 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`] receives a [`TextEdit::Submit`] edit for an entity). + 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, + visible_lines: None, + clear_on_submit: false, + } + } +} + +/// 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 + /// 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 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(_)) { + 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`. +/// +/// 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)] +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 `*` + pub mask_char: char, + /// Buffer mirroring the actual text input buffer but only containing `mask_char`s + editor: Editor<'static>, +} + +impl Default for PasswordMask { + 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 TextEdits { + /// Commands to be applied before the text input is updated + pub queue: VecDeque, +} + +impl TextEdits { + /// queue an action + pub fn queue(&mut self, command: TextEdit) { + self.queue.push_back(command); + } +} + +/// Deferred text input edit and navigation actions applied by the `apply_text_edits` system. +#[derive(Debug, Clone)] +pub enum TextEdit { + /// 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 is 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), + /// 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. + Backspace, + /// Delete the character at 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 are discarded. + SetText(String), + /// Submit the contents of the text input buffer + Submit, +} + +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 { + 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 Editor<'_>) -> 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); +} + +/// Applies the [`TextEdit`]s queued for each [`TextInputBuffer`] and emits [`TextInputEvent`]s in response. +/// +/// 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, + mut text_input_query: Query<( + Entity, + &mut TextInputBuffer, + &mut TextEdits, + &TextInputAttributes, + Option<&TextInputFilter>, + Option<&mut UndoHistory>, + 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 edit in text_input_actions.queue.drain(..) { + match edit { + TextEdit::Submit => { + commands.trigger_targets( + TextInputEvent::Submission { + text: buffer.get_text(), + }, + entity, + ); + + if attribs.clear_on_submit { + let _ = apply_text_edit( + buffer.editor.borrow_with(&mut font_system), + maybe_history.as_mut().map(AsMut::as_mut), + maybe_filter, + attribs.max_chars, + &mut clipboard, + &TextEdit::Clear, + ); + + if let Some(history) = maybe_history.as_mut() { + history.clear(); + } + } + } + edit => { + if let Err(error) = apply_text_edit( + buffer.editor.borrow_with(&mut font_system), + maybe_history.as_mut().map(AsMut::as_mut), + maybe_filter, + attribs.max_chars, + &mut clipboard, + &edit, + ) { + commands.trigger_targets(TextInputEvent::InvalidEdit(error, edit), entity); + } + } + } + } + + if let Some(mut value) = maybe_value { + let contents = buffer.get_text(); + if value.0 != contents { + value.0 = contents; + commands.trigger_targets(TextInputEvent::TextChanged, entity); + } + } + } +} + +/// 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, + Ref, + &TextEdits, + Ref, + )>, + time: Res