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}