use bevy_clipboard::ClipboardRead;1use bevy_math::Vec2;2use bevy_reflect::Reflect;3use parley::PlainEditorDriver;4use smol_str::SmolStr;56use crate::TextBrush;78/// A selection within IME preedit text, expressed as byte offsets from the start of the preedit.9///10/// The anchor and focus map directly onto parley's `Selection::new(anchor, focus)` when11/// the preedit is applied. If `anchor == focus`, the selection is a caret.12///13/// This corresponds to [`ImePredit::Commit.cursor`](https://docs.rs/bevy/latest/bevy/prelude/enum.Ime.html#variant.Preedit.field.cursor)14/// from `bevy_window`.15#[derive(Debug, Clone, Copy, PartialEq, Eq, Reflect)]16pub struct PreeditCursor {17/// Anchor byte offset within the preedit text.18pub anchor: usize,19/// Focus (caret) byte offset within the preedit text.20pub focus: usize,21}2223/// Deferred text input edit and navigation actions applied by the `apply_text_edits` system.24#[derive(Debug, Clone, PartialEq, Reflect)]25pub enum TextEdit {26/// Copy the current selection into the clipboard.27///28/// Typically generated in response to copy commands such as Ctrl + C or Cmd + C.29Copy,30/// Copy the current selection into the clipboard, and the delete the selected text.31///32/// Typically generated in response to cut commands such as Ctrl + X or Cmd + X.33Cut,34/// Paste the current clipboard contents at the cursor. If there is a selection, replaces the selection with the clipboard contents instead.35///36/// Typically generated in response to paste commands such as Ctrl + V or Cmd + V.37Paste,38/// Insert a character or string at the cursor. If there is a selection, replaces the selection with the character instead.39///40/// Ordinarily, this is derived from `bevy_input::keyboard::KeyboardInput::logical_key`,41/// which stores a [`SmolStr`] inside of the `Key::Character` variant, which may represent multiple bytes.42Insert(SmolStr),43/// Delete the character behind the cursor.44/// If there is a selection, deletes the selection instead.45///46/// Typically generated in response to the Backspace key.47///48/// This operation removes an entire Unicode grapheme cluster, which may consist of multiple bytes,49/// shifting the cursor position accordingly.50Backspace,51/// Delete the word behind the cursor.52/// If there is a selection, deletes the selection instead.53///54/// Typically generated in response to Ctrl + Backspace or Option + Backspace.55BackspaceWord,56/// Delete the character at the cursor.57/// If there is a selection, deletes the selection instead.58///59/// Typically generated in response to the Delete key.60///61/// This operation removes an entire Unicode grapheme cluster, which may consist of multiple bytes,62/// shifting the cursor position accordingly.63Delete,64/// Delete the word at the cursor.65/// If there is a selection, deletes the selection instead.66///67/// Typically generated in response to Ctrl + Delete or Option + Delete.68DeleteWord,69/// Moves the cursor by one position to the left.70/// `true` moves and extends selection.71///72/// Typically generated in response to the Left key.73Left(bool),74/// Moves the cursor by one position to the right.75/// `true` moves and extends selection.76///77/// Typically generated in response to the Right key.78Right(bool),79/// Moves the cursor a word to the left.80/// `true` moves and extends selection.81///82/// Typically generated in response to Ctrl + Left or Option + Left.83WordLeft(bool),84/// Moves the cursor a word to the right.85/// `true` moves and extends selection.86///87/// Typically generated in response to Ctrl + Right or Option + Right.88WordRight(bool),89/// Moves the cursor up by one visual line.90/// `true` moves and extends selection.91///92/// Typically generated in response to the Up key.93Up(bool),94/// Moves the cursor down by one visual line.95/// `true` moves and extends selection.96///97/// Typically generated in response to the Down key.98Down(bool),99/// Moves the cursor to the start of the text.100/// `true` moves and extends selection.101///102/// Typically generated in response to Ctrl + Home or Command + Up.103TextStart(bool),104/// Moves the cursor to the end of the text.105/// `true` moves and extends selection.106///107/// Typically generated in response to Ctrl + End or Command + Down.108TextEnd(bool),109/// Moves the cursor to the start of the current hard line.110/// A hardline is a line separated by a newline character.111/// `true` moves and extends selection.112///113/// Typically generated in response to Command + Left.114HardLineStart(bool),115/// Moves the cursor to the end of the current hard line.116/// `true` moves and extends selection.117///118/// Typically generated in response to Command + Right.119HardLineEnd(bool),120/// Moves the cursor to the start of the current visual line.121/// `true` moves and extends selection.122///123/// Typically generated in response to the Home key.124LineStart(bool),125/// Moves the cursor to the end of the current visual line.126/// `true` moves and extends selection.127///128/// Typically generated in response to the End key.129LineEnd(bool),130/// Collapses the current selection to a caret.131///132/// Typically generated in response to the Escape key.133CollapseSelection,134/// Selects all text.135///136/// Typically generated in response to select-all commands such as Ctrl + A or Cmd + A.137SelectAll,138/// Selects all text if the current selection is collapsed.139///140/// Typically generated in response to a chain of focus gained by pointer press into141/// pointer release events.142SelectAllIfCollapsed,143/// Moves the cursor to the given point.144///145/// Typically generated in response to a pointer press within the text area.146MoveToPoint(Vec2),147/// Selects the word at the given point.148///149/// Typically generated in response to a double-click within the text area.150SelectWordAtPoint(Vec2),151/// Selects the line at the given point.152///153/// A line here means a single row of glyphs, all sharing the same baseline.154///155/// Typically generated in response to a triple-click within the text area.156SelectLineAtPoint(Vec2),157/// Selects the hard line at the given point.158///159/// A “hard line” is the portion of text between explicit newline characters.160///161/// Typically generated in response to a triple-click within the text area.162SelectedHardLineAtPoint(Vec2),163/// Extends the current selection to the given point.164///165/// Typically generated in response to dragging a pointer within the text area.166ExtendSelectionToPoint(Vec2),167/// Extends the current selection from the existing anchor to the given point.168///169/// Typically generated in response to shift-clicking within the text area.170ShiftClickExtension(Vec2),171/// Set the IME preedit/composing text at the cursor, or clear it if `value` is empty.172///173/// The preedit text is excluded from [`EditableText::value`](crate::EditableText::value).174/// `cursor` describes the selection within the preedit text, or `None` to hide the cursor.175///176/// Passing an empty `value` clears any in-progress composition; `cursor` is ignored in177/// that case. Use [`TextEdit::clear_ime_compose`] as a convenience constructor.178///179/// Typically generated in response to [`bevy_window::Ime::Preedit`] events.180///181/// [`bevy_window::Ime::Preedit`]: https://docs.rs/bevy/latest/bevy/prelude/enum.Ime.html#variant.Preedit182ImeSetCompose {183/// The current preedit string. An empty string clears the composition.184value: SmolStr,185/// Selection within the preedit text, or `None` to hide the cursor.186cursor: Option<PreeditCursor>,187},188/// Accept IME composition and insert `value` at the cursor.189///190/// Clears any in-progress preedit first, then inserts the committed string,191/// respecting [`EditableText::max_characters`](crate::EditableText::max_characters)192/// and the `char_filter`.193///194/// Typically generated in response to [`bevy_window::Ime::Commit`] events.195///196/// [`bevy_window::Ime::Commit`]: https://docs.rs/bevy/latest/bevy/prelude/enum.Ime.html#variant.Commit197ImeCommit {198/// The committed text to insert at the cursor.199value: SmolStr,200},201}202203impl TextEdit {204/// Convenience constructor for a [`TextEdit::ImeSetCompose`] that clears the preedit.205pub fn clear_ime_compose() -> Self {206Self::ImeSetCompose {207value: SmolStr::new_inline(""),208cursor: None,209}210}211212/// Apply the [`TextEdit`] to the text editor driver.213///214/// Note that some edits, such as [`TextEdit::Paste`], may need to be deferred across frames due to asynchronous clipboard I/O.215/// For proper handling of deferred edits, use [`EditableText::apply_pending_edits`](super::EditableText::apply_pending_edits) instead,216/// which manages the queuing and application of edits by storing them in the [`EditableText`](super::EditableText) component.217pub fn apply<'a>(218self,219driver: &'a mut PlainEditorDriver<TextBrush>,220clipboard: &mut bevy_clipboard::Clipboard,221max_characters: Option<usize>,222char_filter: impl Fn(char) -> bool,223) {224match self {225TextEdit::Copy => {226if let Some(text) = driver.editor.selected_text()227&& let Err(e) = clipboard.set_text(text)228{229bevy_log::warn!("Failed to write selection to clipboard: {e:?}");230}231}232TextEdit::Cut => {233if let Some(text) = driver.editor.selected_text() {234match clipboard.set_text(text) {235Ok(()) => driver.delete(),236Err(e) => bevy_log::warn!("Failed to write selection to clipboard: {e:?}"),237}238}239}240TextEdit::Paste => {241// It's nice to be able to provide apply as a public method, but Paste is a little buggy.242// We'll try our best since that works on native, but we should warn users away from doing so.243bevy_log::warn_once!("Directly applying a Paste edit is not recommended, as it cannot defer asynchronous clipboard reads.244For proper handling of async clipboard operations, use `EditableText::apply_pending_edits` instead.");245246let mut read = clipboard.fetch_text();247poll_and_apply_paste(&mut read, driver, max_characters, char_filter);248}249TextEdit::Insert(text) => {250let _ = insert_filtered(driver, text.as_str(), max_characters, char_filter);251}252TextEdit::Backspace => driver.backdelete(),253TextEdit::BackspaceWord => driver.backdelete_word(),254TextEdit::Delete => driver.delete(),255TextEdit::DeleteWord => driver.delete_word(),256TextEdit::Left(false) => driver.move_left(),257TextEdit::Right(false) => driver.move_right(),258TextEdit::WordLeft(false) => driver.move_word_left(),259TextEdit::WordRight(false) => driver.move_word_right(),260TextEdit::Up(false) => driver.move_up(),261TextEdit::Down(false) => driver.move_down(),262TextEdit::TextStart(false) => driver.move_to_text_start(),263TextEdit::TextEnd(false) => driver.move_to_text_end(),264TextEdit::HardLineStart(false) => driver.move_to_hard_line_start(),265TextEdit::HardLineEnd(false) => driver.move_to_hard_line_end(),266TextEdit::LineStart(false) => driver.move_to_line_start(),267TextEdit::LineEnd(false) => driver.move_to_line_end(),268TextEdit::Left(true) => driver.select_left(),269TextEdit::Right(true) => driver.select_right(),270TextEdit::WordLeft(true) => driver.select_word_left(),271TextEdit::WordRight(true) => driver.select_word_right(),272TextEdit::Up(true) => driver.select_up(),273TextEdit::Down(true) => driver.select_down(),274TextEdit::TextStart(true) => driver.select_to_text_start(),275TextEdit::TextEnd(true) => driver.select_to_text_end(),276TextEdit::HardLineStart(true) => driver.select_to_hard_line_start(),277TextEdit::HardLineEnd(true) => driver.select_to_hard_line_end(),278TextEdit::LineStart(true) => driver.select_to_line_start(),279TextEdit::LineEnd(true) => driver.select_to_line_end(),280TextEdit::CollapseSelection => driver.collapse_selection(),281TextEdit::SelectAll => driver.select_all(),282TextEdit::SelectAllIfCollapsed => {283if driver.editor.raw_selection().is_collapsed() {284driver.select_all();285}286}287TextEdit::MoveToPoint(point) => driver.move_to_point(point.x, point.y),288TextEdit::SelectWordAtPoint(point) => driver.select_word_at_point(point.x, point.y),289TextEdit::SelectLineAtPoint(point) => driver.select_line_at_point(point.x, point.y),290TextEdit::SelectedHardLineAtPoint(point) => {291driver.select_hard_line_at_point(point.x, point.y);292}293TextEdit::ExtendSelectionToPoint(point) => {294driver.extend_selection_to_point(point.x, point.y);295}296TextEdit::ShiftClickExtension(point) => driver.shift_click_extension(point.x, point.y),297TextEdit::ImeSetCompose { value, cursor } => {298if value.is_empty() {299driver.clear_compose();300} else {301let cursor = cursor.map(|c| (c.anchor, c.focus));302driver.set_compose(&value, cursor);303}304}305TextEdit::ImeCommit { value: text } => {306driver.clear_compose();307if text.chars().all(&char_filter)308&& max_characters.is_none_or(|max| {309driver.editor.text().chars().count() + text.chars().count() <= max310})311{312driver.insert_or_replace_selection(text.as_str());313}314}315}316}317}318319/// Reason an [`insert_filtered`] call was rejected.320///321/// The two branches matter to callers (paste warns on [`CharFilter`](Self::CharFilter) but322/// not on [`MaxLength`](Self::MaxLength)), so a bool return wouldn't suffice.323enum InsertRejection {324/// At least one character failed the user-supplied filter.325CharFilter,326/// The insertion would exceed `max_characters`.327MaxLength,328}329330/// Insert (or replace the current selection with) `text`, subject to the char filter and331/// `max_characters`.332///333/// Shared by [`TextEdit::Insert`] and [`TextEdit::Paste`] paths to ensure consistent behavior.334fn insert_filtered(335driver: &mut PlainEditorDriver<TextBrush>,336text: &str,337max_characters: Option<usize>,338char_filter: impl Fn(char) -> bool,339) -> Result<(), InsertRejection> {340if !text.chars().all(char_filter) {341return Err(InsertRejection::CharFilter);342}343if let Some(max) = max_characters {344let select_len = driver345.editor346.selected_text()347.map(str::chars)348.map(Iterator::count)349.unwrap_or(0);350if max < driver.editor.text().chars().count() - select_len + text.chars().count() {351return Err(InsertRejection::MaxLength);352}353}354driver.insert_or_replace_selection(text);355Ok(())356}357358/// Polls a clipboard read and, if ready, applies the resulting text as a paste.359///360/// Returns `true` when the read has resolved (applied, filter-rejected, or errored)361/// and the caller should move on.362/// Returns `false` when the read is still pending363/// and the caller should hold onto the [`ClipboardRead`] to poll again on a later frame.364pub(crate) fn poll_and_apply_paste(365read: &mut ClipboardRead,366driver: &mut PlainEditorDriver<TextBrush>,367max_characters: Option<usize>,368char_filter: impl Fn(char) -> bool,369) -> bool {370match read.poll_result() {371Some(Ok(text)) => {372if matches!(373insert_filtered(driver, &text, max_characters, char_filter),374Err(InsertRejection::CharFilter)375) {376bevy_log::debug!(377"Paste rejected: clipboard contents contained characters not allowed by the char filter."378);379}380true381}382Some(Err(e)) => {383bevy_log::warn!("Failed to read clipboard for paste: {e:?}");384true385}386None => false,387}388}389390391