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