ailloli_ui_text/edit.rs
1//! Text editing engine: caret, selection, IME, undo/redo, clipboard.
2//!
3//! Shared by `TextInput` and `Editor` widgets. Operates on a [`TextBuffer`] via
4//! [`TextEditState`] and keymap translation ([`TextKeymap`]).
5
6use ailloli_ui_core::event::{ImePreedit, Key, KeyEvent, NamedKey};
7use unicode_segmentation::UnicodeSegmentation;
8
9use crate::TextBuffer;
10
11/// Single-line vs multi-line editing behavior.
12///
13/// This mode only changes keyboard translation for Enter. Direct
14/// [`TextEditAction::InsertText`] and [`TextEditAction::Paste`] actions may
15/// still contain newlines in single-line mode; widgets must enforce any
16/// stronger content invariant themselves.
17///
18/// # Examples
19///
20/// ```
21/// use ailloli_ui_text::TextInputMode;
22/// assert_ne!(TextInputMode::SingleLine, TextInputMode::MultiLine);
23/// ```
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum TextInputMode {
26 /// Ignore Enter in [`TextKeymap`] translation.
27 SingleLine,
28 /// Translate Enter into newline insertion.
29 MultiLine,
30}
31
32/// Platform-specific primary modifier (Ctrl vs Cmd).
33///
34/// # Examples
35///
36/// ```
37/// use ailloli_ui_text::PlatformKeymap;
38/// assert_ne!(PlatformKeymap::LinuxWindows, PlatformKeymap::MacOs);
39/// ```
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum PlatformKeymap {
42 /// Treat Control as the primary shortcut modifier.
43 LinuxWindows,
44 /// Treat Meta/Command as the primary shortcut modifier.
45 MacOs,
46}
47
48impl PlatformKeymap {
49 /// Returns the compile-target platform mapping.
50 ///
51 /// macOS targets select [`Self::MacOs`]; every other target, including
52 /// Linux, Windows, and WebAssembly, selects [`Self::LinuxWindows`]. This is
53 /// compile-time selection, not runtime operating-system detection.
54 ///
55 /// # Examples
56 ///
57 /// ```
58 /// use ailloli_ui_text::PlatformKeymap;
59 /// let current = PlatformKeymap::current();
60 /// assert!(matches!(current, PlatformKeymap::LinuxWindows | PlatformKeymap::MacOs));
61 /// ```
62 pub fn current() -> Self {
63 if cfg!(target_os = "macos") {
64 Self::MacOs
65 } else {
66 Self::LinuxWindows
67 }
68 }
69
70 /// Tests only the platform's primary modifier on a key event.
71 ///
72 /// Other modifiers and the key state do not affect the result.
73 ///
74 /// # Examples
75 ///
76 /// ```
77 /// use ailloli_ui_core::event::{Key, KeyEvent, KeyState, Modifiers};
78 /// use ailloli_ui_text::PlatformKeymap;
79 /// let event = KeyEvent {
80 /// state: KeyState::Pressed,
81 /// key: Key::Character("c".into()),
82 /// modifiers: Modifiers { ctrl: true, ..Modifiers::default() },
83 /// repeat: false,
84 /// pointer_pos: None,
85 /// text: None,
86 /// };
87 /// assert!(PlatformKeymap::LinuxWindows.is_primary(&event));
88 /// assert!(!PlatformKeymap::MacOs.is_primary(&event));
89 /// ```
90 pub fn is_primary(self, event: &KeyEvent) -> bool {
91 match self {
92 Self::LinuxWindows => event.modifiers.ctrl,
93 Self::MacOs => event.modifiers.meta,
94 }
95 }
96}
97
98/// Caret and optional selection anchor (UTF-8 byte indices).
99///
100/// This value does not know the associated text and therefore permits out-of-range
101/// and non-boundary indices. [`TextEditState::clamp_to_buffer`] repairs them by
102/// clamping backward to valid UTF-8 boundaries.
103///
104/// # Examples
105///
106/// ```
107/// use ailloli_ui_text::TextSelection;
108/// let selection = TextSelection { anchor: 8, caret: 3 };
109/// assert_eq!(selection.normalized(), (3, 8));
110/// ```
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub struct TextSelection {
113 /// Fixed end of an extended selection, as a UTF-8 byte index.
114 pub anchor: usize,
115 /// Moving end and active caret, as a UTF-8 byte index.
116 pub caret: usize,
117}
118
119impl TextSelection {
120 /// Creates a zero-width selection at `caret` without validating it.
121 ///
122 /// # Examples
123 ///
124 /// ```
125 /// use ailloli_ui_text::TextSelection;
126 /// let selection = TextSelection::collapsed(5);
127 /// assert_eq!(selection, TextSelection { anchor: 5, caret: 5 });
128 /// assert!(selection.is_collapsed());
129 /// ```
130 pub fn collapsed(caret: usize) -> Self {
131 Self {
132 anchor: caret,
133 caret,
134 }
135 }
136
137 /// Returns `(min(anchor, caret), max(anchor, caret))`.
138 ///
139 /// The pair is numerically ordered but is not clamped to any buffer.
140 ///
141 /// # Examples
142 ///
143 /// ```
144 /// use ailloli_ui_text::TextSelection;
145 /// assert_eq!(TextSelection { anchor: 9, caret: 2 }.normalized(), (2, 9));
146 /// ```
147 pub fn normalized(self) -> (usize, usize) {
148 if self.anchor <= self.caret {
149 (self.anchor, self.caret)
150 } else {
151 (self.caret, self.anchor)
152 }
153 }
154
155 /// Returns `true` when anchor and caret have the same byte index.
156 ///
157 /// # Examples
158 ///
159 /// ```
160 /// use ailloli_ui_text::TextSelection;
161 /// assert!(TextSelection::collapsed(0).is_collapsed());
162 /// assert!(!TextSelection { anchor: 0, caret: 1 }.is_collapsed());
163 /// ```
164 pub fn is_collapsed(self) -> bool {
165 self.anchor == self.caret
166 }
167}
168
169#[derive(Debug, Clone, PartialEq)]
170/// Whole-buffer state retained for one undo or redo entry.
171struct EditSnapshot {
172 /// Complete UTF-8 document copy.
173 text: String,
174 /// Caret byte index at snapshot time.
175 caret_byte: usize,
176 /// Optional selection at snapshot time.
177 selection: Option<TextSelection>,
178}
179
180/// Mutable edit session: caret, selection, IME preedit, scroll, undo stacks.
181///
182/// Byte positions are public for widget integration but should be kept on UTF-8
183/// boundaries; [`Self::apply`] clamps them before and after every action. Undo
184/// and redo snapshots copy the whole buffer and retain at most 256 undo entries.
185/// Scroll positions are logical pixels and are not clamped or interpreted by
186/// this engine. Cloning duplicates all snapshot strings.
187///
188/// # Examples
189///
190/// ```
191/// use ailloli_ui_text::TextEditState;
192/// let state = TextEditState::new();
193/// assert_eq!(state.caret_byte, 0);
194/// assert_eq!(state.selection, None);
195/// assert_eq!((state.scroll_x, state.scroll_y), (0.0, 0.0));
196/// ```
197#[derive(Debug, Clone, PartialEq)]
198pub struct TextEditState {
199 /// Active caret as a UTF-8 byte index.
200 pub caret_byte: usize,
201 /// Optional anchor/caret pair; `Some(collapsed)` remains distinct from `None`.
202 pub selection: Option<TextSelection>,
203 /// Active IME composition, kept outside the committed [`TextBuffer`].
204 pub preedit: Option<ImePreedit>,
205 /// Reserved horizontal target in logical pixels; current movement clears but does not use it.
206 pub desired_x: Option<f32>,
207 /// Widget-managed horizontal scroll offset in logical pixels.
208 pub scroll_x: f32,
209 /// Widget-managed vertical scroll offset in logical pixels.
210 pub scroll_y: f32,
211 /// Widget-managed pointer-drag anchor byte index.
212 pub drag_anchor: Option<usize>,
213 /// Oldest-to-newest whole-document undo snapshots, capped at 256.
214 undo: Vec<EditSnapshot>,
215 /// Whole-document redo snapshots, cleared by each new text mutation.
216 redo: Vec<EditSnapshot>,
217}
218
219impl Default for TextEditState {
220 fn default() -> Self {
221 Self::new()
222 }
223}
224
225impl TextEditState {
226 /// Creates a session at byte zero with no selection, IME, history, or scroll.
227 ///
228 /// # Examples
229 ///
230 /// ```
231 /// use ailloli_ui_text::TextEditState;
232 /// let state = TextEditState::new();
233 /// assert_eq!(state, TextEditState::default());
234 /// assert_eq!(state.desired_x, None);
235 /// ```
236 pub fn new() -> Self {
237 Self {
238 caret_byte: 0,
239 selection: None,
240 preedit: None,
241 desired_x: None,
242 scroll_x: 0.0,
243 scroll_y: 0.0,
244 drag_anchor: None,
245 undo: Vec::new(),
246 redo: Vec::new(),
247 }
248 }
249
250 /// Clamps caret and selection endpoints backward to buffer UTF-8 boundaries.
251 ///
252 /// Values beyond the end become `buffer.len_bytes()`. Values inside a
253 /// multibyte scalar move to its starting byte. The method does not force the
254 /// state's caret to equal `selection.caret`, remove collapsed selections,
255 /// or modify IME, scroll, drag, and history fields.
256 ///
257 /// # Examples
258 ///
259 /// ```
260 /// use ailloli_ui_text::{TextBuffer, TextEditState, TextSelection};
261 /// let buffer = TextBuffer::from_string("é");
262 /// let mut state = TextEditState::new();
263 /// state.caret_byte = 1;
264 /// state.selection = Some(TextSelection { anchor: 1, caret: 99 });
265 /// state.clamp_to_buffer(&buffer);
266 /// assert_eq!(state.caret_byte, 0);
267 /// assert_eq!(state.selection, Some(TextSelection { anchor: 0, caret: 2 }));
268 /// ```
269 pub fn clamp_to_buffer(&mut self, buffer: &TextBuffer) {
270 let text = buffer.as_str();
271 self.caret_byte = clamp_boundary(&text, self.caret_byte);
272 self.selection = self.selection.map(|selection| TextSelection {
273 anchor: clamp_boundary(&text, selection.anchor),
274 caret: clamp_boundary(&text, selection.caret),
275 });
276 }
277
278 /// Copies the current non-collapsed selection from `buffer`.
279 ///
280 /// Reversed selections are normalized. `None` means no selection or a
281 /// collapsed one. Endpoints beyond the buffer are clamped to its end.
282 ///
283 /// # Panics
284 ///
285 /// This method can panic if publicly mutated selection endpoints lie inside
286 /// a multibyte scalar. Call [`Self::clamp_to_buffer`] first when state did not
287 /// come through [`Self::apply`].
288 ///
289 /// # Examples
290 ///
291 /// ```
292 /// use ailloli_ui_text::{TextBuffer, TextEditState, TextSelection};
293 /// let buffer = TextBuffer::from_string("hello");
294 /// let mut state = TextEditState::new();
295 /// state.selection = Some(TextSelection { anchor: 5, caret: 1 });
296 /// assert_eq!(state.selected_text(&buffer).as_deref(), Some("ello"));
297 /// ```
298 pub fn selected_text(&self, buffer: &TextBuffer) -> Option<String> {
299 let selection = self.selection?;
300 if selection.is_collapsed() {
301 return None;
302 }
303 let text = buffer.as_str();
304 let (start, end) = selection.normalized();
305 Some(text[start.min(text.len())..end.min(text.len())].to_string())
306 }
307
308 /// Moves the caret, optionally extending the current selection.
309 ///
310 /// `caret_byte` is clamped backward to a UTF-8 boundary. With `extend`, an
311 /// existing anchor is preserved; otherwise the current caret becomes the
312 /// anchor. Without `extend`, selection is cleared. `desired_x` is always
313 /// cleared. The boolean reports whether caret, selection, or `desired_x`
314 /// changed; it does not report a text mutation.
315 ///
316 /// Existing public state is not pre-clamped by this method, so callers with
317 /// externally modified indices should call [`Self::clamp_to_buffer`] first.
318 ///
319 /// # Examples
320 ///
321 /// ```
322 /// use ailloli_ui_text::{TextBuffer, TextEditState, TextSelection};
323 /// let buffer = TextBuffer::from_string("abc");
324 /// let mut state = TextEditState::new();
325 /// assert!(state.set_caret(&buffer, 2, true));
326 /// assert_eq!(state.selection, Some(TextSelection { anchor: 0, caret: 2 }));
327 /// assert_eq!(state.caret_byte, 2);
328 /// ```
329 pub fn set_caret(&mut self, buffer: &TextBuffer, caret_byte: usize, extend: bool) -> bool {
330 let previous_caret = self.caret_byte;
331 let previous_selection = self.selection;
332 let previous_desired_x = self.desired_x;
333 let text = buffer.as_str();
334 let caret = clamp_boundary(&text, caret_byte);
335 if extend {
336 let anchor = self.selection.map(|s| s.anchor).unwrap_or(self.caret_byte);
337 self.selection = Some(TextSelection { anchor, caret });
338 } else {
339 self.selection = None;
340 }
341 self.caret_byte = caret;
342 self.desired_x = None;
343 self.caret_byte != previous_caret
344 || self.selection != previous_selection
345 || self.desired_x != previous_desired_x
346 }
347
348 /// Applies one editing command and returns requested side effects.
349 ///
350 /// State indices are clamped before and after dispatch. Nonempty committed
351 /// text mutations take a whole-buffer undo snapshot, clear redo, update the
352 /// buffer revision, clear selection/`desired_x`, and cap undo at 256 entries.
353 /// `InsertText` replaces each carriage return with a newline; `Paste` and
354 /// `ImeCommit` preserve their strings exactly. Empty inserts and unavailable
355 /// undo/redo operations are no-ops.
356 ///
357 /// `text_changed` means the action executed a text mutation, not that old and
358 /// new strings were compared for inequality. `state_changed` excludes pure
359 /// clipboard requests: Copy can set `clipboard_write` and RequestPaste sets
360 /// `clipboard_read` while both flags remain false. IME preedit is not part of
361 /// the buffer and is cleared only by commit, end, undo, or redo.
362 ///
363 /// # Examples
364 ///
365 /// ```
366 /// use ailloli_ui_text::{TextBuffer, TextEditAction, TextEditState};
367 /// let mut buffer = TextBuffer::from_string("ab");
368 /// let mut state = TextEditState::new();
369 /// state.caret_byte = 2;
370 /// let outcome = state.apply(&mut buffer, TextEditAction::InsertText { text: "\rc".into() });
371 /// assert_eq!(buffer.as_str(), "ab\nc");
372 /// assert!(outcome.text_changed && outcome.state_changed);
373 /// ```
374 pub fn apply(&mut self, buffer: &mut TextBuffer, action: TextEditAction) -> TextEditOutcome {
375 self.clamp_to_buffer(buffer);
376 let mut outcome = TextEditOutcome::default();
377 match action {
378 TextEditAction::InsertText { text } => {
379 let text = text.replace('\r', "\n");
380 if !text.is_empty() {
381 self.replace_selection_or_insert(buffer, &text);
382 outcome.text_changed = true;
383 outcome.state_changed = true;
384 }
385 }
386 TextEditAction::ImePreedit { preedit } => {
387 let next = if preedit.text.is_empty() {
388 None
389 } else {
390 Some(preedit)
391 };
392 if self.preedit != next {
393 self.preedit = next;
394 outcome.state_changed = true;
395 }
396 }
397 TextEditAction::ImeCommit { text } => {
398 let had_preedit = self.preedit.take().is_some();
399 if !text.is_empty() {
400 self.replace_selection_or_insert(buffer, &text);
401 outcome.text_changed = true;
402 }
403 outcome.state_changed = had_preedit || outcome.text_changed;
404 }
405 TextEditAction::ImeEnd => {
406 if self.preedit.take().is_some() {
407 outcome.state_changed = true;
408 }
409 }
410 TextEditAction::DeleteBackward => {
411 if self.delete_backward(buffer) {
412 outcome.text_changed = true;
413 outcome.state_changed = true;
414 }
415 }
416 TextEditAction::DeleteForward => {
417 if self.delete_forward(buffer) {
418 outcome.text_changed = true;
419 outcome.state_changed = true;
420 }
421 }
422 TextEditAction::Move { movement, extend } => {
423 outcome.state_changed = self.move_caret(buffer, movement, extend);
424 }
425 TextEditAction::SelectAll => {
426 let len = buffer.len_bytes();
427 let next_selection = Some(TextSelection {
428 anchor: 0,
429 caret: len,
430 });
431 if self.selection != next_selection
432 || self.caret_byte != len
433 || self.desired_x.is_some()
434 {
435 self.selection = next_selection;
436 self.caret_byte = len;
437 self.desired_x = None;
438 outcome.state_changed = true;
439 }
440 }
441 TextEditAction::SetSelection { selection } => {
442 let text = buffer.as_str();
443 let next_sel = TextSelection {
444 anchor: clamp_boundary(&text, selection.anchor),
445 caret: clamp_boundary(&text, selection.caret),
446 };
447 let next_selection = Some(next_sel);
448 let next_caret = next_sel.caret;
449 if self.selection != next_selection
450 || self.caret_byte != next_caret
451 || self.desired_x.is_some()
452 {
453 self.selection = next_selection;
454 self.caret_byte = next_caret;
455 self.desired_x = None;
456 outcome.state_changed = true;
457 }
458 }
459 TextEditAction::PointerCaret { byte, extend } => {
460 outcome.state_changed = self.set_caret(buffer, byte, extend);
461 }
462 TextEditAction::Copy => {
463 outcome.clipboard_write = self.selected_text(buffer);
464 }
465 TextEditAction::Cut => {
466 outcome.clipboard_write = self.selected_text(buffer);
467 if self.delete_selection(buffer) {
468 outcome.text_changed = true;
469 outcome.state_changed = true;
470 }
471 }
472 TextEditAction::Paste { text } => {
473 if !text.is_empty() {
474 self.replace_selection_or_insert(buffer, &text);
475 outcome.text_changed = true;
476 outcome.state_changed = true;
477 }
478 }
479 TextEditAction::RequestPaste => {
480 outcome.clipboard_read = true;
481 }
482 TextEditAction::Undo => {
483 if self.restore_undo(buffer) {
484 outcome.text_changed = true;
485 outcome.state_changed = true;
486 }
487 }
488 TextEditAction::Redo => {
489 if self.restore_redo(buffer) {
490 outcome.text_changed = true;
491 outcome.state_changed = true;
492 }
493 }
494 }
495 self.clamp_to_buffer(buffer);
496 outcome
497 }
498
499 /// Pushes a whole-buffer undo snapshot, clears redo, and evicts beyond 256.
500 fn push_undo(&mut self, buffer: &TextBuffer) {
501 self.undo.push(EditSnapshot {
502 text: buffer.as_str(),
503 caret_byte: self.caret_byte,
504 selection: self.selection,
505 });
506 self.redo.clear();
507 const MAX_UNDO: usize = 256;
508 if self.undo.len() > MAX_UNDO {
509 self.undo.remove(0);
510 }
511 }
512
513 /// Records undo, deletes a nonempty selection, and inserts at the caret.
514 fn replace_selection_or_insert(&mut self, buffer: &mut TextBuffer, inserted: &str) {
515 self.push_undo(buffer);
516 self.delete_selection_without_undo(buffer);
517 let at = self.caret_byte;
518 buffer.edit(at..at, inserted);
519 self.caret_byte = at + inserted.len();
520 self.selection = None;
521 self.desired_x = None;
522 }
523
524 /// Deletes a nonempty selection with one undo snapshot.
525 fn delete_selection(&mut self, buffer: &mut TextBuffer) -> bool {
526 if self.selection.is_none_or(TextSelection::is_collapsed) {
527 return false;
528 }
529 self.push_undo(buffer);
530 self.delete_selection_without_undo(buffer)
531 }
532
533 /// Deletes a nonempty selection without changing history.
534 fn delete_selection_without_undo(&mut self, buffer: &mut TextBuffer) -> bool {
535 let Some(selection) = self.selection else {
536 return false;
537 };
538 if selection.is_collapsed() {
539 return false;
540 }
541 let (start, end) = selection.normalized();
542 buffer.edit(start..end, "");
543 self.caret_byte = start;
544 self.selection = None;
545 self.desired_x = None;
546 true
547 }
548
549 /// Deletes the selection or preceding extended grapheme cluster.
550 fn delete_backward(&mut self, buffer: &mut TextBuffer) -> bool {
551 if self.delete_selection(buffer) {
552 return true;
553 }
554 let text = buffer.as_str();
555 if self.caret_byte == 0 {
556 return false;
557 }
558 let start = previous_grapheme_boundary(&text, self.caret_byte);
559 self.push_undo(buffer);
560 buffer.edit(start..self.caret_byte, "");
561 self.caret_byte = start;
562 self.desired_x = None;
563 true
564 }
565
566 /// Deletes the selection or following extended grapheme cluster.
567 fn delete_forward(&mut self, buffer: &mut TextBuffer) -> bool {
568 if self.delete_selection(buffer) {
569 return true;
570 }
571 let text = buffer.as_str();
572 if self.caret_byte >= text.len() {
573 return false;
574 }
575 let end = next_grapheme_boundary(&text, self.caret_byte);
576 self.push_undo(buffer);
577 buffer.edit(self.caret_byte..end, "");
578 self.desired_x = None;
579 true
580 }
581
582 /// Resolves logical movement against newline lines and grapheme boundaries.
583 fn move_caret(&mut self, buffer: &TextBuffer, movement: TextMovement, extend: bool) -> bool {
584 let text = buffer.as_str();
585 let from = self.caret_byte.min(text.len());
586 let target = match movement {
587 TextMovement::Left => previous_grapheme_boundary(&text, from),
588 TextMovement::Right => next_grapheme_boundary(&text, from),
589 TextMovement::WordLeft => previous_word_boundary(&text, from),
590 TextMovement::WordRight => next_word_boundary(&text, from),
591 TextMovement::LineStart => line_bounds(&text, from).0,
592 TextMovement::LineEnd => line_bounds(&text, from).1,
593 TextMovement::DocumentStart => 0,
594 TextMovement::DocumentEnd => text.len(),
595 TextMovement::LineUp => vertical_line_move(&text, from, -1),
596 TextMovement::LineDown => vertical_line_move(&text, from, 1),
597 TextMovement::PageUp => vertical_line_move(&text, from, -10),
598 TextMovement::PageDown => vertical_line_move(&text, from, 10),
599 };
600 self.set_caret(buffer, target, extend)
601 }
602
603 /// Restores the newest undo snapshot and records current state for redo.
604 fn restore_undo(&mut self, buffer: &mut TextBuffer) -> bool {
605 let Some(snapshot) = self.undo.pop() else {
606 return false;
607 };
608 self.redo.push(EditSnapshot {
609 text: buffer.as_str(),
610 caret_byte: self.caret_byte,
611 selection: self.selection,
612 });
613 buffer.edit(0..buffer.len_bytes(), &snapshot.text);
614 self.caret_byte = snapshot.caret_byte;
615 self.selection = snapshot.selection;
616 self.preedit = None;
617 true
618 }
619
620 /// Restores the newest redo snapshot and records current state for undo.
621 fn restore_redo(&mut self, buffer: &mut TextBuffer) -> bool {
622 let Some(snapshot) = self.redo.pop() else {
623 return false;
624 };
625 self.undo.push(EditSnapshot {
626 text: buffer.as_str(),
627 caret_byte: self.caret_byte,
628 selection: self.selection,
629 });
630 buffer.edit(0..buffer.len_bytes(), &snapshot.text);
631 self.caret_byte = snapshot.caret_byte;
632 self.selection = snapshot.selection;
633 self.preedit = None;
634 true
635 }
636}
637
638/// Editing command applied to a buffer through [`TextEditState`].
639///
640/// Actions are backend-neutral: clipboard reads/writes are reported in
641/// [`TextEditOutcome`] for the host widget to perform. Directly constructed
642/// actions are not restricted by [`TextInputMode`].
643///
644/// # Examples
645///
646/// ```
647/// use ailloli_ui_text::{TextBuffer, TextEditAction, TextEditState};
648/// let mut buffer = TextBuffer::new();
649/// let mut state = TextEditState::new();
650/// state.apply(&mut buffer, TextEditAction::InsertText { text: "hello".into() });
651/// assert_eq!(buffer.as_str(), "hello");
652/// ```
653#[derive(Debug, Clone, PartialEq)]
654pub enum TextEditAction {
655 /// Replace the selection or insert committed text at the caret.
656 ///
657 /// Carriage returns in `text` are individually normalized to newlines.
658 InsertText {
659 /// Owned UTF-8 content to insert; empty content is a no-op.
660 text: String,
661 },
662 /// Replace the active IME composition state without committing it.
663 ImePreedit {
664 /// Composition text and optional byte selection; empty text clears preedit.
665 preedit: ImePreedit,
666 },
667 /// Clear preedit and commit text at the selection or caret.
668 ImeCommit {
669 /// Exact committed UTF-8 content; carriage returns are preserved.
670 text: String,
671 },
672 /// Clear an active IME preedit without mutating committed text.
673 ImeEnd,
674 /// Delete a nonempty selection or the preceding extended grapheme cluster.
675 DeleteBackward,
676 /// Delete a nonempty selection or the following extended grapheme cluster.
677 DeleteForward,
678 /// Move the caret in logical document space.
679 Move {
680 /// Direction and granularity of navigation.
681 movement: TextMovement,
682 /// Preserve/create an anchor when true; clear selection when false.
683 extend: bool,
684 },
685 /// Select bytes `0..len` and place the caret at the end.
686 SelectAll,
687 /// Install an explicit selection after clamping both endpoints.
688 SetSelection {
689 /// Requested anchor and caret byte indices.
690 selection: TextSelection,
691 },
692 /// Place a caret from pointer hit testing, optionally extending selection.
693 PointerCaret {
694 /// Requested UTF-8 byte index, clamped backward to a valid boundary.
695 byte: usize,
696 /// Preserve/create the selection anchor when true.
697 extend: bool,
698 },
699 /// Request writing the selected text to the host clipboard.
700 Copy,
701 /// Request a clipboard write and delete a nonempty selection.
702 Cut,
703 /// Insert exact host-provided clipboard content.
704 Paste {
705 /// UTF-8 clipboard content; empty content is a no-op.
706 text: String,
707 },
708 /// Request asynchronous clipboard text from the host.
709 RequestPaste,
710 /// Restore the newest whole-buffer undo snapshot if present.
711 Undo,
712 /// Restore the newest whole-buffer redo snapshot if present.
713 Redo,
714}
715
716/// Caret movement direction for keyboard navigation.
717///
718/// Horizontal character movement follows Unicode extended grapheme clusters.
719/// Words are whitespace-delimited; punctuation remains part of a word. Vertical
720/// movement uses newline-delimited logical lines and preserves a grapheme column,
721/// not shaped X geometry. Page movement is exactly ten logical lines.
722///
723/// # Examples
724///
725/// ```
726/// use ailloli_ui_text::TextMovement;
727/// assert_ne!(TextMovement::Left, TextMovement::WordLeft);
728/// assert_ne!(TextMovement::LineUp, TextMovement::PageUp);
729/// ```
730#[derive(Debug, Clone, Copy, PartialEq, Eq)]
731pub enum TextMovement {
732 /// Move to the previous extended grapheme boundary.
733 Left,
734 /// Move to the next extended grapheme boundary.
735 Right,
736 /// Move left across whitespace and then to the current word's start.
737 WordLeft,
738 /// Move right across whitespace and through the next non-whitespace run.
739 WordRight,
740 /// Move after the previous newline, or byte zero on the first line.
741 LineStart,
742 /// Move before the next newline, or to document end on the last line.
743 LineEnd,
744 /// Move to UTF-8 byte zero.
745 DocumentStart,
746 /// Move to the document's UTF-8 byte length.
747 DocumentEnd,
748 /// Move one logical line up at the same grapheme column when possible.
749 LineUp,
750 /// Move one logical line down at the same grapheme column when possible.
751 LineDown,
752 /// Move ten logical lines up at the same grapheme column when possible.
753 PageUp,
754 /// Move ten logical lines down at the same grapheme column when possible.
755 PageDown,
756}
757
758/// Side effects after applying an edit action.
759///
760/// Clipboard fields are requests for the host integration; this crate performs
761/// no operating-system clipboard I/O. The default represents a complete no-op.
762///
763/// # Examples
764///
765/// ```
766/// use ailloli_ui_text::TextEditOutcome;
767/// let outcome = TextEditOutcome::default();
768/// assert!(!outcome.text_changed);
769/// assert!(!outcome.state_changed);
770/// assert_eq!(outcome.clipboard_write, None);
771/// assert!(!outcome.clipboard_read);
772/// ```
773#[derive(Debug, Clone, Default, PartialEq, Eq)]
774pub struct TextEditOutcome {
775 /// Whether the action executed a committed-buffer mutation.
776 pub text_changed: bool,
777 /// Whether caret, selection, IME, history-restored state, or text state changed.
778 pub state_changed: bool,
779 /// Selected UTF-8 text the host should write, or `None` for no write request.
780 pub clipboard_write: Option<String>,
781 /// Whether the host should read text and later dispatch [`TextEditAction::Paste`].
782 pub clipboard_read: bool,
783}
784
785/// Maps [`KeyEvent`] to [`TextEditAction`] for a given input mode and platform.
786///
787/// This translator is stateless. It ignores key releases, dead keys, pointer
788/// positions, and does not suppress repeated pressed events. Clipboard commands
789/// remain host requests rather than direct I/O.
790///
791/// # Examples
792///
793/// ```
794/// use ailloli_ui_text::{PlatformKeymap, TextInputMode, TextKeymap};
795/// let keymap = TextKeymap::for_platform(TextInputMode::SingleLine, PlatformKeymap::LinuxWindows);
796/// assert_eq!(keymap.mode, TextInputMode::SingleLine);
797/// assert_eq!(keymap.platform, PlatformKeymap::LinuxWindows);
798/// ```
799pub struct TextKeymap {
800 /// Whether Enter is ignored or translated into newline insertion.
801 pub mode: TextInputMode,
802 /// Modifier used for selection, clipboard, word, and history shortcuts.
803 pub platform: PlatformKeymap,
804}
805
806impl TextKeymap {
807 /// Creates a keymap using [`PlatformKeymap::current`].
808 ///
809 /// # Examples
810 ///
811 /// ```
812 /// use ailloli_ui_text::{PlatformKeymap, TextInputMode, TextKeymap};
813 /// let keymap = TextKeymap::new(TextInputMode::MultiLine);
814 /// assert_eq!(keymap.mode, TextInputMode::MultiLine);
815 /// assert_eq!(keymap.platform, PlatformKeymap::current());
816 /// ```
817 pub fn new(mode: TextInputMode) -> Self {
818 Self {
819 mode,
820 platform: PlatformKeymap::current(),
821 }
822 }
823
824 /// Creates a keymap with an explicit, deterministic platform mapping.
825 ///
826 /// This constructor is useful for tests and remote input whose shortcut
827 /// convention differs from the compilation target.
828 ///
829 /// # Examples
830 ///
831 /// ```
832 /// use ailloli_ui_text::{PlatformKeymap, TextInputMode, TextKeymap};
833 /// let keymap = TextKeymap::for_platform(TextInputMode::SingleLine, PlatformKeymap::MacOs);
834 /// assert_eq!(keymap.platform, PlatformKeymap::MacOs);
835 /// ```
836 pub fn for_platform(mode: TextInputMode, platform: PlatformKeymap) -> Self {
837 Self { mode, platform }
838 }
839
840 /// Translates a pressed key event into one editing action.
841 ///
842 /// Primary-modifier character shortcuts are ASCII case-insensitive: A/C/X/V,
843 /// Z (Shift-Z for redo), and Y. Unknown primary characters return `None`.
844 /// Left/right become word moves with the primary modifier; Home/End become
845 /// document moves. Shift extends movement selections. Plain character input
846 /// prefers `event.text` over the logical key, allowing composed characters;
847 /// Alt or Meta suppress it, and named Space falls back to one ASCII space.
848 /// Enter inserts only in multiline mode. `event.repeat` is intentionally
849 /// ignored, so a repeated pressed event maps identically.
850 ///
851 /// # Examples
852 ///
853 /// ```
854 /// use ailloli_ui_core::event::{Key, KeyEvent, KeyState, Modifiers};
855 /// use ailloli_ui_text::{PlatformKeymap, TextEditAction, TextInputMode, TextKeymap};
856 /// let keymap = TextKeymap::for_platform(TextInputMode::SingleLine, PlatformKeymap::LinuxWindows);
857 /// let event = KeyEvent {
858 /// state: KeyState::Pressed,
859 /// key: Key::Character("c".into()),
860 /// modifiers: Modifiers { ctrl: true, ..Modifiers::default() },
861 /// repeat: false,
862 /// pointer_pos: None,
863 /// text: None,
864 /// };
865 /// assert_eq!(keymap.action_for_key(&event), Some(TextEditAction::Copy));
866 /// ```
867 pub fn action_for_key(&self, event: &KeyEvent) -> Option<TextEditAction> {
868 if event.state != ailloli_ui_core::event::KeyState::Pressed {
869 return None;
870 }
871 let primary = self.platform.is_primary(event);
872 if primary {
873 if let Key::Character(ch) = &event.key {
874 return match ch.to_ascii_lowercase().as_str() {
875 "a" => Some(TextEditAction::SelectAll),
876 "c" => Some(TextEditAction::Copy),
877 "x" => Some(TextEditAction::Cut),
878 "v" => Some(TextEditAction::RequestPaste),
879 "z" if event.modifiers.shift => Some(TextEditAction::Redo),
880 "z" => Some(TextEditAction::Undo),
881 "y" => Some(TextEditAction::Redo),
882 _ => None,
883 };
884 }
885 }
886
887 match &event.key {
888 Key::Named(NamedKey::Backspace) => Some(TextEditAction::DeleteBackward),
889 Key::Named(NamedKey::Delete) => Some(TextEditAction::DeleteForward),
890 Key::Named(NamedKey::ArrowLeft) => Some(TextEditAction::Move {
891 movement: if primary {
892 TextMovement::WordLeft
893 } else {
894 TextMovement::Left
895 },
896 extend: event.modifiers.shift,
897 }),
898 Key::Named(NamedKey::ArrowRight) => Some(TextEditAction::Move {
899 movement: if primary {
900 TextMovement::WordRight
901 } else {
902 TextMovement::Right
903 },
904 extend: event.modifiers.shift,
905 }),
906 Key::Named(NamedKey::ArrowUp) => Some(TextEditAction::Move {
907 movement: TextMovement::LineUp,
908 extend: event.modifiers.shift,
909 }),
910 Key::Named(NamedKey::ArrowDown) => Some(TextEditAction::Move {
911 movement: TextMovement::LineDown,
912 extend: event.modifiers.shift,
913 }),
914 Key::Named(NamedKey::Home) => Some(TextEditAction::Move {
915 movement: if primary {
916 TextMovement::DocumentStart
917 } else {
918 TextMovement::LineStart
919 },
920 extend: event.modifiers.shift,
921 }),
922 Key::Named(NamedKey::End) => Some(TextEditAction::Move {
923 movement: if primary {
924 TextMovement::DocumentEnd
925 } else {
926 TextMovement::LineEnd
927 },
928 extend: event.modifiers.shift,
929 }),
930 Key::Named(NamedKey::PageUp) => Some(TextEditAction::Move {
931 movement: TextMovement::PageUp,
932 extend: event.modifiers.shift,
933 }),
934 Key::Named(NamedKey::PageDown) => Some(TextEditAction::Move {
935 movement: TextMovement::PageDown,
936 extend: event.modifiers.shift,
937 }),
938 Key::Named(NamedKey::Space)
939 if !primary && !event.modifiers.alt && !event.modifiers.meta =>
940 {
941 Some(TextEditAction::InsertText {
942 text: event.text.clone().unwrap_or_else(|| " ".to_string()),
943 })
944 }
945 Key::Named(NamedKey::Enter) if self.mode == TextInputMode::MultiLine => {
946 Some(TextEditAction::InsertText {
947 text: event.text.clone().unwrap_or_else(|| "\n".to_string()),
948 })
949 }
950 Key::Character(_) if !primary && !event.modifiers.alt && !event.modifiers.meta => {
951 let text = event.text.clone().or_else(|| match &event.key {
952 Key::Character(ch) => Some(ch.clone()),
953 _ => None,
954 })?;
955 if text.is_empty() {
956 None
957 } else {
958 Some(TextEditAction::InsertText { text })
959 }
960 }
961 _ => None,
962 }
963 }
964}
965
966/// Clamps a byte index backward to a valid UTF-8 boundary in `text`.
967///
968/// Inputs beyond the byte length become `text.len()`. An input in the middle of
969/// a multibyte scalar walks backward to that scalar's leading byte. Zero and all
970/// already valid boundaries are unchanged.
971///
972/// # Examples
973///
974/// ```
975/// use ailloli_ui_text::edit::clamp_boundary;
976/// let text = "aé";
977/// assert_eq!(clamp_boundary(text, 2), 1);
978/// assert_eq!(clamp_boundary(text, usize::MAX), text.len());
979/// ```
980pub fn clamp_boundary(text: &str, byte: usize) -> usize {
981 let mut b = byte.min(text.len());
982 while b > 0 && !text.is_char_boundary(b) {
983 b -= 1;
984 }
985 b
986}
987
988/// Returns the previous extended grapheme start, clamped at zero.
989fn previous_grapheme_boundary(text: &str, byte: usize) -> usize {
990 let byte = clamp_boundary(text, byte);
991 UnicodeSegmentation::grapheme_indices(text, true)
992 .map(|(idx, _)| idx)
993 .take_while(|idx| *idx < byte)
994 .last()
995 .unwrap_or(0)
996}
997
998/// Returns the end of the next extended grapheme, clamped at text length.
999fn next_grapheme_boundary(text: &str, byte: usize) -> usize {
1000 let byte = clamp_boundary(text, byte);
1001 if byte >= text.len() {
1002 return text.len();
1003 }
1004 UnicodeSegmentation::grapheme_indices(text, true)
1005 .map(|(idx, g)| idx + g.len())
1006 .find(|idx| *idx > byte)
1007 .unwrap_or(text.len())
1008}
1009
1010/// Moves left across Unicode whitespace and a whitespace-delimited token.
1011fn previous_word_boundary(text: &str, byte: usize) -> usize {
1012 let mut idx = previous_grapheme_boundary(text, byte);
1013 while idx > 0 && text[idx..].chars().next().is_some_and(char::is_whitespace) {
1014 idx = previous_grapheme_boundary(text, idx);
1015 }
1016 while idx > 0 {
1017 let prev = previous_grapheme_boundary(text, idx);
1018 if text[prev..idx].chars().all(char::is_whitespace) {
1019 break;
1020 }
1021 idx = prev;
1022 }
1023 idx
1024}
1025
1026/// Moves right across Unicode whitespace and a whitespace-delimited token.
1027fn next_word_boundary(text: &str, byte: usize) -> usize {
1028 let mut idx = next_grapheme_boundary(text, byte);
1029 while idx < text.len() && text[..idx].chars().last().is_some_and(char::is_whitespace) {
1030 idx = next_grapheme_boundary(text, idx);
1031 }
1032 while idx < text.len() {
1033 let next = next_grapheme_boundary(text, idx);
1034 if text[idx..next].chars().all(char::is_whitespace) {
1035 break;
1036 }
1037 idx = next;
1038 }
1039 idx
1040}
1041
1042/// Returns the current logical line excluding its newline terminator.
1043fn line_bounds(text: &str, byte: usize) -> (usize, usize) {
1044 let byte = clamp_boundary(text, byte);
1045 let start = text[..byte].rfind('\n').map(|idx| idx + 1).unwrap_or(0);
1046 let end = text[byte..]
1047 .find('\n')
1048 .map(|idx| byte + idx)
1049 .unwrap_or(text.len());
1050 (start, end)
1051}
1052
1053/// Moves by signed logical-line count while preserving grapheme column.
1054fn vertical_line_move(text: &str, byte: usize, delta_lines: isize) -> usize {
1055 let (start, _) = line_bounds(text, byte);
1056 let column = text[start..byte].graphemes(true).count();
1057 let mut line_start = start;
1058 if delta_lines < 0 {
1059 for _ in 0..delta_lines.unsigned_abs() {
1060 if line_start == 0 {
1061 return column_to_byte(text, line_start, column);
1062 }
1063 let previous_end = line_start.saturating_sub(1);
1064 line_start = text[..previous_end]
1065 .rfind('\n')
1066 .map(|idx| idx + 1)
1067 .unwrap_or(0);
1068 }
1069 } else {
1070 for _ in 0..delta_lines as usize {
1071 let (_, line_end) = line_bounds(text, line_start);
1072 if line_end >= text.len() {
1073 return column_to_byte(text, line_start, column);
1074 }
1075 line_start = line_end + 1;
1076 }
1077 }
1078 column_to_byte(text, line_start, column)
1079}
1080
1081/// Maps a grapheme column into one newline-delimited line, clamping at its end.
1082fn column_to_byte(text: &str, line_start: usize, column: usize) -> usize {
1083 let (_, line_end) = line_bounds(text, line_start);
1084 let line = &text[line_start..line_end];
1085 let mut out = line_end;
1086 for (idx, _) in line.grapheme_indices(true).take(column) {
1087 out = line_start + idx;
1088 }
1089 if column == 0 {
1090 line_start
1091 } else {
1092 next_grapheme_boundary(text, out).min(line_end)
1093 }
1094}
1095
1096#[cfg(test)]
1097mod tests {
1098 use super::*;
1099 use ailloli_ui_core::event::{KeyState, Modifiers};
1100
1101 #[test]
1102 fn delete_backward_removes_whole_grapheme() {
1103 let mut buffer = TextBuffer::from_string("a👨👩👧👦b");
1104 let mut state = TextEditState::new();
1105 state.caret_byte = "a👨👩👧👦".len();
1106
1107 state.apply(&mut buffer, TextEditAction::DeleteBackward);
1108
1109 assert_eq!(buffer.as_str(), "ab");
1110 assert_eq!(state.caret_byte, 1);
1111 }
1112
1113 #[test]
1114 fn insert_replaces_selection() {
1115 let mut buffer = TextBuffer::from_string("hello world");
1116 let mut state = TextEditState::new();
1117 state.selection = Some(TextSelection {
1118 anchor: 6,
1119 caret: 11,
1120 });
1121 state.caret_byte = 11;
1122 let insertion = "ailloli_ui";
1123
1124 state.apply(
1125 &mut buffer,
1126 TextEditAction::InsertText {
1127 text: insertion.into(),
1128 },
1129 );
1130
1131 assert_eq!(buffer.as_str(), "hello ailloli_ui");
1132 assert_eq!(state.caret_byte, "hello ".len() + insertion.len());
1133 }
1134
1135 #[test]
1136 fn keymap_uses_text_for_character_input() {
1137 let keymap =
1138 TextKeymap::for_platform(TextInputMode::SingleLine, PlatformKeymap::LinuxWindows);
1139 let action = keymap.action_for_key(&KeyEvent {
1140 state: KeyState::Pressed,
1141 key: Key::Character("a".into()),
1142 modifiers: Modifiers::default(),
1143 repeat: false,
1144 pointer_pos: None,
1145 text: Some("à".into()),
1146 });
1147
1148 assert_eq!(
1149 action,
1150 Some(TextEditAction::InsertText { text: "à".into() })
1151 );
1152 }
1153
1154 #[test]
1155 fn undo_redo_restores_text_and_caret() {
1156 let mut buffer = TextBuffer::from_string("a");
1157 let mut state = TextEditState::new();
1158 state.caret_byte = 1;
1159 state.apply(&mut buffer, TextEditAction::InsertText { text: "b".into() });
1160 assert_eq!(buffer.as_str(), "ab");
1161
1162 state.apply(&mut buffer, TextEditAction::Undo);
1163 assert_eq!(buffer.as_str(), "a");
1164
1165 state.apply(&mut buffer, TextEditAction::Redo);
1166 assert_eq!(buffer.as_str(), "ab");
1167 }
1168
1169 #[test]
1170 fn ime_commit_inserts_text_and_clears_preedit() {
1171 let mut buffer = TextBuffer::from_string("caf");
1172 let mut state = TextEditState::new();
1173 state.caret_byte = 3;
1174
1175 state.apply(
1176 &mut buffer,
1177 TextEditAction::ImePreedit {
1178 preedit: ImePreedit::try_new("e", Some((0, 1))).unwrap(),
1179 },
1180 );
1181 state.apply(&mut buffer, TextEditAction::ImeCommit { text: "é".into() });
1182
1183 assert_eq!(buffer.as_str(), "café");
1184 assert_eq!(state.preedit, None);
1185 assert_eq!(state.caret_byte, "café".len());
1186 }
1187
1188 #[test]
1189 fn ime_end_without_preedit_is_idempotent() {
1190 let mut buffer = TextBuffer::from_string("abc");
1191 let mut state = TextEditState::new();
1192
1193 let outcome = state.apply(&mut buffer, TextEditAction::ImeEnd);
1194
1195 assert_eq!(outcome, TextEditOutcome::default());
1196 assert_eq!(buffer.as_str(), "abc");
1197 assert_eq!(state.preedit, None);
1198 }
1199
1200 #[test]
1201 fn repeated_identical_ime_preedit_is_idempotent() {
1202 let mut buffer = TextBuffer::from_string("abc");
1203 let mut state = TextEditState::new();
1204 let action = TextEditAction::ImePreedit {
1205 preedit: ImePreedit::try_new("`", Some((0, 1))).unwrap(),
1206 };
1207
1208 let first = state.apply(&mut buffer, action.clone());
1209 let second = state.apply(&mut buffer, action);
1210
1211 assert!(first.state_changed);
1212 assert_eq!(second, TextEditOutcome::default());
1213 }
1214
1215 #[test]
1216 fn repeated_empty_ime_preedit_and_end_are_idempotent() {
1217 let mut buffer = TextBuffer::from_string("abc");
1218 let mut state = TextEditState::new();
1219 let empty = TextEditAction::ImePreedit {
1220 preedit: ImePreedit::new(""),
1221 };
1222
1223 let preedit = state.apply(&mut buffer, empty);
1224 let end = state.apply(&mut buffer, TextEditAction::ImeEnd);
1225
1226 assert_eq!(preedit, TextEditOutcome::default());
1227 assert_eq!(end, TextEditOutcome::default());
1228 }
1229
1230 #[test]
1231 fn empty_ime_commit_without_preedit_is_idempotent() {
1232 let mut buffer = TextBuffer::from_string("abc");
1233 let mut state = TextEditState::new();
1234
1235 let outcome = state.apply(
1236 &mut buffer,
1237 TextEditAction::ImeCommit {
1238 text: String::new(),
1239 },
1240 );
1241
1242 assert_eq!(outcome, TextEditOutcome::default());
1243 assert_eq!(buffer.as_str(), "abc");
1244 }
1245
1246 #[test]
1247 fn pointer_caret_to_same_position_is_idempotent() {
1248 let mut buffer = TextBuffer::from_string("abc");
1249 let mut state = TextEditState::new();
1250 state.caret_byte = 1;
1251
1252 let outcome = state.apply(
1253 &mut buffer,
1254 TextEditAction::PointerCaret {
1255 byte: 1,
1256 extend: false,
1257 },
1258 );
1259
1260 assert_eq!(outcome, TextEditOutcome::default());
1261 assert_eq!(state.caret_byte, 1);
1262 }
1263
1264 #[test]
1265 fn cut_copy_and_paste_use_selection_and_clipboard_outcome() {
1266 let mut buffer = TextBuffer::from_string("hello world");
1267 let mut state = TextEditState::new();
1268 state.selection = Some(TextSelection {
1269 anchor: 6,
1270 caret: 11,
1271 });
1272 state.caret_byte = 11;
1273
1274 let copy = state.apply(&mut buffer, TextEditAction::Copy);
1275 assert_eq!(copy.clipboard_write.as_deref(), Some("world"));
1276 assert_eq!(buffer.as_str(), "hello world");
1277
1278 let cut = state.apply(&mut buffer, TextEditAction::Cut);
1279 assert_eq!(cut.clipboard_write.as_deref(), Some("world"));
1280 assert_eq!(buffer.as_str(), "hello ");
1281
1282 state.apply(
1283 &mut buffer,
1284 TextEditAction::Paste {
1285 text: "Ailloli UI".into(),
1286 },
1287 );
1288 assert_eq!(buffer.as_str(), "hello Ailloli UI");
1289 }
1290
1291 #[test]
1292 fn keymap_maps_selection_movement_and_multiline_enter() {
1293 let keymap =
1294 TextKeymap::for_platform(TextInputMode::MultiLine, PlatformKeymap::LinuxWindows);
1295 let action = keymap.action_for_key(&KeyEvent {
1296 state: KeyState::Pressed,
1297 key: Key::Named(NamedKey::ArrowLeft),
1298 modifiers: Modifiers {
1299 shift: true,
1300 ..Modifiers::default()
1301 },
1302 repeat: false,
1303 pointer_pos: None,
1304 text: None,
1305 });
1306 assert_eq!(
1307 action,
1308 Some(TextEditAction::Move {
1309 movement: TextMovement::Left,
1310 extend: true,
1311 })
1312 );
1313
1314 let enter = keymap.action_for_key(&KeyEvent {
1315 state: KeyState::Pressed,
1316 key: Key::Named(NamedKey::Enter),
1317 modifiers: Modifiers::default(),
1318 repeat: false,
1319 pointer_pos: None,
1320 text: None,
1321 });
1322 assert_eq!(
1323 enter,
1324 Some(TextEditAction::InsertText { text: "\n".into() })
1325 );
1326 }
1327
1328 #[test]
1329 fn single_line_enter_and_dead_keys_do_not_insert_text() {
1330 let keymap =
1331 TextKeymap::for_platform(TextInputMode::SingleLine, PlatformKeymap::LinuxWindows);
1332 let enter = keymap.action_for_key(&KeyEvent {
1333 state: KeyState::Pressed,
1334 key: Key::Named(NamedKey::Enter),
1335 modifiers: Modifiers::default(),
1336 repeat: false,
1337 pointer_pos: None,
1338 text: None,
1339 });
1340 let dead = keymap.action_for_key(&KeyEvent {
1341 state: KeyState::Pressed,
1342 key: Key::Dead(Some("^".into())),
1343 modifiers: Modifiers::default(),
1344 repeat: false,
1345 pointer_pos: None,
1346 text: None,
1347 });
1348
1349 assert_eq!(enter, None);
1350 assert_eq!(dead, None);
1351 }
1352}