Skip to main content

frust_text/
editor.rs

1//! [`TextEditor`]: the editing engine behind the `TextInput` widget and the
2//! platform IME bridges.
3//!
4//! Wraps parley 0.11's [`parley::PlainEditor`] behind a renderer-agnostic API:
5//! all mutation flows through [`TextEditor::apply`] (an [`EditOp`] plus a
6//! borrowed [`TextContext`], from which the transient
7//! [`parley::PlainEditorDriver`] borrow-triple is constructed), and all readback
8//! is expressed in `kurbo`/`frust-scene` types plus the two editing-state
9//! DTOs defined here — **no parley type leaks through the public API**
10//! (scene-layer purity, see `docs/ARCHITECTURE.md`).
11//!
12//! # Two composition paths
13//!
14//! - **Desktop / winit** drives composition through parley's own IME machinery
15//!   ([`EditOp::Compose`]/[`EditOp::FinishCompose`]/[`EditOp::ClearCompose`]):
16//!   parley owns the preedit buffer and reports it via `raw_compose`.
17//! - **Android / iOS** own composition against a platform-side mirror buffer and
18//!   push the whole editing value ([`EditOp::ApplyEditingState`]) — a state-sync
19//!   model, not op-forwarding (the Flutter-canonical contract). On this path parley's compose machinery is *not*
20//!   used: the platform is authoritative for the composing region, so we track
21//!   it in [`TextEditor::platform_composing`] purely for paint styling and echo
22//!   it back out in the editing state. A value-equality short-circuit guards the
23//!   IMM feedback loop (re-applying an identical state is a no-op).
24//!
25//! # Coordinate / unit conventions
26//!
27//! Selection and composing offsets are **byte** offsets into the text buffer at
28//! the parley boundary and in [`EditingStateBytes`]; the platform bridges speak
29//! UTF-16 code-unit offsets, so [`EditingState`] (and the [`byte_to_utf16`] /
30//! [`utf16_to_byte`] helpers) do the conversion in one concentrated, tested
31//! place. Geometry is Y-down logical pixels (parley 0.11's convention).
32//!
33//! # Single-line vs. wrapped multi-line
34//!
35//! The editor is built with no wrapping width, so it is single-line by default
36//! (one visual line, horizontal overflow). Calling
37//! [`set_wrap_width(Some(px))`](TextEditor::set_wrap_width) switches it into a
38//! wrapped multi-line mode: text soft-wraps at `px`,
39//! [`layout_size`](TextEditor::layout_size) reports the full wrapped height, a
40//! literal newline can be inserted with [`EditOp::InsertNewline`], and the
41//! [`EditOp::MoveUp`]/[`EditOp::MoveDown`] vertical caret motions (no-ops while
42//! single-line, since there is only one line) become real line-to-line motion
43//! through parley's line structure. Passing `None` restores single-line mode.
44//! Selection/IME byte↔UTF-16 indexing is unaffected by newlines — a `'\n'` is
45//! one byte / one UTF-16 unit and flows through the same conversion helpers.
46
47use std::borrow::Cow;
48use std::ops::Range;
49
50use kurbo::{Point, Rect, Size};
51use parley::{BoundingBox, PlainEditor, StyleProperty};
52use peniko::Brush;
53
54use frust_scene::GlyphRun;
55
56use crate::context::TextContext;
57use crate::style::{
58    TextStyle, to_parley_family, to_parley_line_height, to_parley_style, to_parley_weight,
59};
60
61/// A single editing command applied to a [`TextEditor`].
62///
63/// Movement variants carry a `select` flag: `false` moves the caret
64/// (collapsing any selection), `true` extends the selection to the new
65/// position (parley's `move_*`/`select_*` twins).
66#[derive(Clone, Debug, PartialEq)]
67pub enum EditOp {
68    /// Insert text, replacing the current selection.
69    Insert(String),
70    /// Insert a literal newline (`'\n'`), replacing the current selection.
71    ///
72    /// Only meaningful in wrapped multi-line mode (see
73    /// [`TextEditor::set_wrap_width`]); a single-line field routes Enter to
74    /// submit instead of emitting this op.
75    InsertNewline,
76    /// Delete the grapheme before the caret (Backspace), or the selection.
77    Backdelete,
78    /// Delete the grapheme after the caret (Delete/Forward-delete), or the
79    /// selection.
80    Delete,
81    /// Move/extend one grapheme left (visual order).
82    MoveLeft { select: bool },
83    /// Move/extend one grapheme right (visual order).
84    MoveRight { select: bool },
85    /// Move/extend one line up.
86    MoveUp { select: bool },
87    /// Move/extend one line down.
88    MoveDown { select: bool },
89    /// Move/extend one word left.
90    MoveWordLeft { select: bool },
91    /// Move/extend one word right.
92    MoveWordRight { select: bool },
93    /// Move/extend to the start of the line (Home).
94    Home { select: bool },
95    /// Move/extend to the end of the line (End).
96    End { select: bool },
97    /// Select the whole buffer.
98    SelectAll,
99    /// Collapse the selection to its focus (drop the anchor).
100    CollapseSelection,
101    /// Move the caret to (or extend the selection to) a layout-local point.
102    MoveToPoint { x: f32, y: f32, select: bool },
103    /// Select the word under a layout-local point (double-click / long-press).
104    SelectWordAtPoint { x: f32, y: f32 },
105    /// Begin/update an IME preedit with `text` and an optional caret range
106    /// (byte offsets *within* `text`). Desktop/winit composition path.
107    Compose {
108        text: String,
109        cursor: Option<(usize, usize)>,
110    },
111    /// Commit the current IME preedit into the buffer. Desktop/winit path.
112    FinishCompose,
113    /// Discard the current IME preedit. Desktop/winit path.
114    ClearCompose,
115    /// Replace the whole editing value from a platform mirror (Android/iOS
116    /// state-sync path). Byte offsets; see [`EditingStateBytes`].
117    ApplyEditingState(EditingStateBytes),
118}
119
120/// The editing value in **byte** offsets — the parley-native representation and
121/// the shape [`EditOp::ApplyEditingState`] consumes.
122#[derive(Clone, Debug, PartialEq, Eq)]
123pub struct EditingStateBytes {
124    /// The full text buffer (including any composing preedit).
125    pub text: String,
126    /// Selection anchor, byte offset into `text`.
127    pub base: usize,
128    /// Selection focus, byte offset into `text`.
129    pub extent: usize,
130    /// Composing (preedit) region as a byte range, or `None` when not
131    /// composing.
132    pub composing: Option<Range<usize>>,
133}
134
135/// The editing value in **UTF-16** code-unit offsets — the Flutter-canonical
136/// shape the Android/iOS bridges exchange (`-1` denotes "none").
137///
138/// Selection offsets are always valid (`base == extent` when the selection is
139/// collapsed to a caret). Composing offsets are `-1` when not composing.
140#[derive(Clone, Debug, PartialEq, Eq)]
141pub struct EditingState {
142    /// The full text buffer (including any composing preedit).
143    pub text: String,
144    /// Selection anchor, UTF-16 offset into `text`.
145    pub selection_base: i32,
146    /// Selection focus, UTF-16 offset into `text`.
147    pub selection_extent: i32,
148    /// Composing region start (UTF-16), or `-1` when not composing.
149    pub composing_base: i32,
150    /// Composing region end (UTF-16), or `-1` when not composing.
151    pub composing_extent: i32,
152}
153
154/// The editing engine wrapping a parley [`PlainEditor`].
155///
156/// Constructed once per focused `TextInput`; driven by [`apply`](Self::apply)
157/// and read back through the geometry / editing-state accessors. The brush type
158/// is fixed to [`peniko::Brush`] so glyph-run conversion is shared with
159/// [`crate::TextLayout`].
160pub struct TextEditor {
161    editor: PlainEditor<Brush>,
162    /// Platform-owned composing region (byte range), tracked for paint styling
163    /// on the [`EditOp::ApplyEditingState`] path only. `None` on the desktop
164    /// path, where parley's own `raw_compose` is authoritative.
165    platform_composing: Option<Range<usize>>,
166}
167
168impl TextEditor {
169    /// Builds an empty single-line editor styled by `style`.
170    ///
171    /// Every knob on `style` (family, weight, style, size, color,
172    /// letter-spacing, line-height) is applied as an edit-style default,
173    /// mirroring [`crate::TextContext::layout`]. No wrapping width is set —
174    /// v1 is single-line.
175    ///
176    /// **`style.align` is deliberately not applied here**:
177    /// [`parley::PlainEditor`] manages its own layout
178    /// internally and exposes no alignment override through
179    /// [`parley::PlainEditor::edit_styles`] or any other hook this fn (or
180    /// [`Self::apply`]'s driver) reaches — unlike the other style knobs
181    /// above, there is no `StyleProperty` for it to `insert`. A live-edited
182    /// [`TextEditor`] (the engine behind `TextInput`) therefore always
183    /// renders start-aligned, regardless of `style.align`. Only
184    /// [`crate::TextContext::layout`] (static, non-editable text) honors it.
185    pub fn new(style: &TextStyle) -> Self {
186        let mut editor = PlainEditor::<Brush>::new(style.size);
187        // Single-line by default: no wrapping width. A widget opts into wrapped
188        // multi-line editing with `set_wrap_width(Some(px))`.
189        editor.set_width(None);
190        let styles = editor.edit_styles();
191        styles.insert(StyleProperty::FontFamily(to_parley_family(&style.family)));
192        styles.insert(StyleProperty::FontWeight(to_parley_weight(style.weight)));
193        styles.insert(StyleProperty::FontStyle(to_parley_style(style.style)));
194        styles.insert(StyleProperty::LetterSpacing(style.letter_spacing));
195        styles.insert(StyleProperty::LineHeight(to_parley_line_height(
196            style.line_height,
197        )));
198        styles.insert(StyleProperty::Brush(Brush::Solid(style.color)));
199        Self {
200            editor,
201            platform_composing: None,
202        }
203    }
204
205    /// Applies one editing command, threading the shell-owned font/layout
206    /// contexts through the transient parley driver.
207    ///
208    /// The layout is refreshed at the end so the geometry accessors
209    /// ([`cursor_rect`](Self::cursor_rect), [`selection_rects`](Self::selection_rects),
210    /// [`to_scene_runs`](Self::to_scene_runs), …) observe up-to-date positions
211    /// without needing a context of their own.
212    pub fn apply(&mut self, op: EditOp, ctx: &mut TextContext) {
213        let (font_cx, layout_cx) = ctx.driver_contexts();
214
215        match op {
216            EditOp::ApplyEditingState(state) => {
217                // State-sync path: platform owns composition; parley's compose
218                // machinery is intentionally bypassed (see module docs).
219                //
220                // Value-equality short-circuit: re-applying an identical state
221                // is a no-op, guarding the IMM feedback loop.
222                if self.editing_state_bytes() == state {
223                    return;
224                }
225                if self.editor.raw_text() != state.text {
226                    self.editor.set_text(&state.text);
227                }
228                {
229                    let mut drv = self.editor.driver(font_cx, layout_cx);
230                    drv.select_byte_range(state.base, state.extent);
231                }
232                self.platform_composing = state.composing;
233            }
234            other => {
235                let mut drv = self.editor.driver(font_cx, layout_cx);
236                match other {
237                    EditOp::Insert(s) => drv.insert_or_replace_selection(&s),
238                    EditOp::InsertNewline => drv.insert_or_replace_selection("\n"),
239                    EditOp::Backdelete => drv.backdelete(),
240                    EditOp::Delete => drv.delete(),
241                    EditOp::MoveLeft { select } => {
242                        sel(&mut drv, select, |d| d.move_left(), |d| d.select_left())
243                    }
244                    EditOp::MoveRight { select } => {
245                        sel(&mut drv, select, |d| d.move_right(), |d| d.select_right())
246                    }
247                    EditOp::MoveUp { select } => {
248                        sel(&mut drv, select, |d| d.move_up(), |d| d.select_up())
249                    }
250                    EditOp::MoveDown { select } => {
251                        sel(&mut drv, select, |d| d.move_down(), |d| d.select_down())
252                    }
253                    EditOp::MoveWordLeft { select } => sel(
254                        &mut drv,
255                        select,
256                        |d| d.move_word_left(),
257                        |d| d.select_word_left(),
258                    ),
259                    EditOp::MoveWordRight { select } => sel(
260                        &mut drv,
261                        select,
262                        |d| d.move_word_right(),
263                        |d| d.select_word_right(),
264                    ),
265                    EditOp::Home { select } => sel(
266                        &mut drv,
267                        select,
268                        |d| d.move_to_line_start(),
269                        |d| d.select_to_line_start(),
270                    ),
271                    EditOp::End { select } => sel(
272                        &mut drv,
273                        select,
274                        |d| d.move_to_line_end(),
275                        |d| d.select_to_line_end(),
276                    ),
277                    EditOp::SelectAll => drv.select_all(),
278                    EditOp::CollapseSelection => drv.collapse_selection(),
279                    EditOp::MoveToPoint { x, y, select } => {
280                        if select {
281                            drv.extend_selection_to_point(x, y);
282                        } else {
283                            drv.move_to_point(x, y);
284                        }
285                    }
286                    EditOp::SelectWordAtPoint { x, y } => drv.select_word_at_point(x, y),
287                    EditOp::Compose { text, cursor } => drv.set_compose(&text, cursor),
288                    EditOp::FinishCompose => drv.finish_compose(),
289                    EditOp::ClearCompose => drv.clear_compose(),
290                    // Handled in the outer arm; unreachable here.
291                    EditOp::ApplyEditingState(_) => unreachable!(),
292                }
293            }
294        }
295
296        self.editor.refresh_layout(font_cx, layout_cx);
297    }
298
299    /// Sets (or clears) the soft-wrap width, switching between wrapped
300    /// multi-line and single-line modes.
301    ///
302    /// `Some(px)` soft-wraps the text at `px` logical pixels and makes vertical
303    /// caret motion ([`EditOp::MoveUp`]/[`EditOp::MoveDown`]) and newline
304    /// insertion ([`EditOp::InsertNewline`]) meaningful; `None` restores the
305    /// single-line default. The layout is refreshed immediately so
306    /// [`layout_size`](Self::layout_size) and the geometry accessors observe the
307    /// new wrapping without waiting for the next [`apply`](Self::apply).
308    pub fn set_wrap_width(&mut self, width: Option<f32>, ctx: &mut TextContext) {
309        self.editor.set_width(width);
310        let (font_cx, layout_cx) = ctx.driver_contexts();
311        self.editor.refresh_layout(font_cx, layout_cx);
312    }
313
314    /// The number of visual lines in the current layout (1 for a non-empty
315    /// single-line field; more once wrapping or explicit newlines split it).
316    ///
317    /// Returns 0 before the first layout refresh.
318    pub fn line_count(&self) -> usize {
319        self.editor
320            .try_layout()
321            .map(|layout| layout.len())
322            .unwrap_or(0)
323    }
324
325    /// The current buffer text, including any composing preedit.
326    pub fn text(&self) -> &str {
327        self.editor.raw_text()
328    }
329
330    /// The selected text slice, or `None` if the selection is collapsed.
331    ///
332    /// Returns the `text()[min(base, extent)..max(base, extent)]` slice when
333    /// a non-collapsed selection exists. Returns `None` when the selection is
334    /// collapsed (`base == extent`).
335    ///
336    /// On a preedit/composing state, this operation is infallible (asserted in
337    /// debug, returns `None` in release if byte offsets are not valid char
338    /// boundaries). This mirrors Flutter's TextEditingValue.selection.textBefore
339    /// and textAfter derivatives on the selected slice.
340    pub fn selected_text(&self) -> Option<&str> {
341        let text = self.text();
342        let selection = self.editor.raw_selection();
343        let base = selection.anchor().index();
344        let extent = selection.focus().index();
345
346        if base == extent {
347            return None;
348        }
349
350        let start = base.min(extent);
351        let end = base.max(extent);
352
353        // Debug: assert char boundaries; release: return None if invalid.
354        #[cfg(debug_assertions)]
355        assert!(
356            text.is_char_boundary(start) && text.is_char_boundary(end),
357            "selected text byte offsets are not char boundaries: start={}, end={}, text.len()={}",
358            start,
359            end,
360            text.len()
361        );
362
363        #[cfg(not(debug_assertions))]
364        if !text.is_char_boundary(start) || !text.is_char_boundary(end) {
365            return None;
366        }
367
368        Some(&text[start..end])
369    }
370
371    /// The measured size of the laid-out text (Y-down logical pixels).
372    ///
373    /// Returns [`Size::ZERO`] before the first [`apply`](Self::apply) has
374    /// refreshed the layout.
375    pub fn layout_size(&self) -> Size {
376        match self.editor.try_layout() {
377            Some(layout) => Size::new(layout.width() as f64, layout.height() as f64),
378            None => Size::ZERO,
379        }
380    }
381
382    /// The current editing value in byte offsets.
383    pub fn editing_state_bytes(&self) -> EditingStateBytes {
384        let selection = self.editor.raw_selection();
385        EditingStateBytes {
386            text: self.editor.raw_text().to_string(),
387            base: selection.anchor().index(),
388            extent: selection.focus().index(),
389            composing: self.composing_range(),
390        }
391    }
392
393    /// The current editing value in UTF-16 offsets (Flutter-canonical shape).
394    pub fn editing_state_utf16(&self) -> EditingState {
395        let text = self.editor.raw_text();
396        let selection = self.editor.raw_selection();
397        let (composing_base, composing_extent) = match self.composing_range() {
398            Some(range) => (
399                byte_to_utf16(text, range.start) as i32,
400                byte_to_utf16(text, range.end) as i32,
401            ),
402            None => (-1, -1),
403        };
404        EditingState {
405            text: text.to_string(),
406            selection_base: byte_to_utf16(text, selection.anchor().index()) as i32,
407            selection_extent: byte_to_utf16(text, selection.focus().index()) as i32,
408            composing_base,
409            composing_extent,
410        }
411    }
412
413    /// The caret rectangle at the current focus, `caret_width` wide.
414    ///
415    /// `None` when the caret is hidden (e.g. the IME requested it).
416    pub fn cursor_rect(&self, caret_width: f32) -> Option<Rect> {
417        self.editor.cursor_geometry(caret_width).map(bbox_to_rect)
418    }
419
420    /// The rectangles covering the current (non-collapsed) selection.
421    pub fn selection_rects(&self) -> Vec<Rect> {
422        self.editor
423            .selection_geometry()
424            .into_iter()
425            .map(|(bbox, _line)| bbox_to_rect(bbox))
426            .collect()
427    }
428
429    /// The area bounding the text currently being edited — a placement hint for
430    /// the platform IME candidate box.
431    pub fn ime_cursor_area(&self) -> Rect {
432        bbox_to_rect(self.editor.ime_cursor_area())
433    }
434
435    /// Converts the current layout into [`frust_scene::GlyphRun`]s at
436    /// `origin` (reusing [`crate::TextLayout`]'s converter).
437    ///
438    /// Empty before the first [`apply`](Self::apply).
439    pub fn to_scene_runs(&self, origin: Point) -> Vec<GlyphRun> {
440        match self.editor.try_layout() {
441            Some(layout) => crate::convert::layout_to_scene_runs(layout, origin),
442            None => Vec::new(),
443        }
444    }
445
446    /// The active composing region (byte range): parley's own preedit on the
447    /// desktop path, or the platform-tracked region on the state-sync path.
448    fn composing_range(&self) -> Option<Range<usize>> {
449        self.editor
450            .raw_compose()
451            .clone()
452            .or_else(|| self.platform_composing.clone())
453    }
454}
455
456/// Applies `mv` or `ext` to `drv` depending on `select`.
457fn sel<'a>(
458    drv: &mut parley::PlainEditorDriver<'a, Brush>,
459    select: bool,
460    mv: impl FnOnce(&mut parley::PlainEditorDriver<'a, Brush>),
461    ext: impl FnOnce(&mut parley::PlainEditorDriver<'a, Brush>),
462) {
463    if select {
464        ext(drv);
465    } else {
466        mv(drv);
467    }
468}
469
470/// Converts a parley [`BoundingBox`] (Y-down logical pixels) into a kurbo
471/// [`Rect`].
472fn bbox_to_rect(bbox: BoundingBox) -> Rect {
473    Rect::new(bbox.x0, bbox.y0, bbox.x1, bbox.y1)
474}
475
476/// Converts a byte offset into `text` to a UTF-16 code-unit offset.
477///
478/// Out-of-range offsets clamp to `text.len()`; an offset landing inside a
479/// multi-byte UTF-8 sequence snaps back to the enclosing char boundary.
480pub fn byte_to_utf16(text: &str, byte_idx: usize) -> usize {
481    let mut boundary = byte_idx.min(text.len());
482    while boundary > 0 && !text.is_char_boundary(boundary) {
483        boundary -= 1;
484    }
485    text[..boundary].chars().map(char::len_utf16).sum()
486}
487
488/// Converts a UTF-16 code-unit offset into `text` to a byte offset.
489///
490/// Out-of-range offsets clamp to `text.len()`; an offset landing inside a
491/// surrogate pair snaps back to the start of the enclosing char.
492pub fn utf16_to_byte(text: &str, utf16_idx: usize) -> usize {
493    let mut units = 0usize;
494    for (byte_off, ch) in text.char_indices() {
495        if utf16_idx <= units {
496            return byte_off;
497        }
498        let next = units + ch.len_utf16();
499        if utf16_idx < next {
500            // Landing inside a surrogate pair: clamp to this char's start.
501            return byte_off;
502        }
503        units = next;
504    }
505    text.len()
506}
507
508/// Normalizes pasted text for insertion, handling line-ending differences.
509///
510/// This function prepares text from the clipboard or external sources by
511/// normalizing line endings according to the target field's mode:
512///
513/// - **Single-line mode** (`single_line: true`): removes every `'\r'` and `'\n'`
514///   entirely, denying multi-line input. This mirrors Flutter's
515///   [`singleLineFormatter`](https://api.flutter.dev/flutter/services/TextInputFormatter-class.html),
516///   which denies newlines to prevent multi-line input in fields like password
517///   fields or search boxes.
518/// - **Multi-line mode** (`single_line: false`): replaces `"\r\n"` and lone `'\r'`
519///   with `'\n'`, normalizing Windows and old Mac line endings to Unix. All other
520///   characters pass through unchanged.
521///
522/// Returns a `Cow::Borrowed` reference when the input text is unchanged;
523/// returns `Cow::Owned` when filtering has occurred.
524///
525/// This function performs no other filtering: no trimming, no stripping of
526/// control characters, no Unicode normalization. It is the caller's
527/// responsibility to apply additional validation if needed.
528pub fn sanitize_paste(text: &str, single_line: bool) -> Cow<'_, str> {
529    if single_line {
530        // Single-line: remove all CR and LF.
531        if text.contains('\r') || text.contains('\n') {
532            let sanitized = text.replace(['\r', '\n'], "");
533            Cow::Owned(sanitized)
534        } else {
535            Cow::Borrowed(text)
536        }
537    } else {
538        // Multi-line: normalize CRLF and lone CR to LF.
539        if text.contains('\r') {
540            // Replace CRLF with LF first, then lone CR with LF.
541            let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
542            Cow::Owned(normalized)
543        } else {
544            Cow::Borrowed(text)
545        }
546    }
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552    use crate::style::{FontStyle, FontWeight};
553    use peniko::Color;
554
555    fn style() -> TextStyle {
556        TextStyle::new(24.0, Color::BLACK)
557    }
558
559    fn editor() -> (TextEditor, TextContext) {
560        (TextEditor::new(&style()), TextContext::new())
561    }
562
563    /// Emoji with an astral-plane code point (grinning face) — a UTF-16
564    /// surrogate pair, 4 UTF-8 bytes.
565    const EMOJI: &str = "\u{1F600}";
566    /// A ZWJ "family" sequence (man+ZWJ+woman+ZWJ+girl+ZWJ+boy).
567    const FAMILY: &str = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}";
568
569    #[test]
570    fn insert_appends_text_and_advances_caret() {
571        let (mut ed, mut cx) = editor();
572        ed.apply(EditOp::Insert("Hi".into()), &mut cx);
573        assert_eq!(ed.text(), "Hi");
574        let st = ed.editing_state_bytes();
575        assert_eq!(st.base, 2);
576        assert_eq!(st.extent, 2);
577    }
578
579    #[test]
580    fn backdelete_over_emoji_removes_whole_code_point() {
581        let (mut ed, mut cx) = editor();
582        ed.apply(EditOp::Insert(format!("a{EMOJI}")), &mut cx);
583        assert_eq!(ed.text(), format!("a{EMOJI}"));
584        ed.apply(EditOp::Backdelete, &mut cx);
585        // The 4-byte emoji is removed as a unit, not a single byte.
586        assert_eq!(ed.text(), "a");
587    }
588
589    #[test]
590    fn backdelete_over_zwj_sequence_removes_a_whole_code_point() {
591        let (mut ed, mut cx) = editor();
592        ed.apply(EditOp::Insert(format!("x{FAMILY}")), &mut cx);
593        let before = ed.text().len();
594        ed.apply(EditOp::Backdelete, &mut cx);
595        let after = ed.text();
596        // parley deletes by code point, not byte: the trailing 4-byte emoji
597        // is removed as a unit and the result stays valid UTF-8. (Full
598        // ZWJ-grapheme-cluster awareness is a widget/bridge-layer concern,
599        // not parley's guarantee.)
600        assert_eq!(before - after.len(), 4, "one 4-byte code point removed");
601        assert!(after.starts_with('x'));
602    }
603
604    #[test]
605    fn forward_delete_removes_following_grapheme() {
606        let (mut ed, mut cx) = editor();
607        ed.apply(EditOp::Insert("abc".into()), &mut cx);
608        ed.apply(EditOp::Home { select: false }, &mut cx);
609        ed.apply(EditOp::Delete, &mut cx);
610        assert_eq!(ed.text(), "bc");
611    }
612
613    #[test]
614    fn move_left_right_adjust_caret_without_selecting() {
615        let (mut ed, mut cx) = editor();
616        ed.apply(EditOp::Insert("abc".into()), &mut cx);
617        ed.apply(EditOp::MoveLeft { select: false }, &mut cx);
618        assert_eq!(ed.editing_state_bytes().extent, 2);
619        let st = ed.editing_state_bytes();
620        assert_eq!(st.base, st.extent, "plain move must collapse the selection");
621        ed.apply(EditOp::MoveRight { select: false }, &mut cx);
622        assert_eq!(ed.editing_state_bytes().extent, 3);
623    }
624
625    #[test]
626    fn move_left_with_select_extends_selection() {
627        let (mut ed, mut cx) = editor();
628        ed.apply(EditOp::Insert("abc".into()), &mut cx);
629        ed.apply(EditOp::MoveLeft { select: true }, &mut cx);
630        let st = ed.editing_state_bytes();
631        assert_ne!(st.base, st.extent, "select variant must extend selection");
632        assert_eq!(st.base, 3);
633        assert_eq!(st.extent, 2);
634    }
635
636    #[test]
637    fn word_move_jumps_over_a_word() {
638        let (mut ed, mut cx) = editor();
639        ed.apply(EditOp::Insert("hello world".into()), &mut cx);
640        ed.apply(EditOp::Home { select: false }, &mut cx);
641        assert_eq!(ed.editing_state_bytes().extent, 0);
642        ed.apply(EditOp::MoveWordRight { select: false }, &mut cx);
643        let after = ed.editing_state_bytes().extent;
644        assert!(
645            after > 0 && after <= 6,
646            "word-right should land near the space, got {after}"
647        );
648    }
649
650    #[test]
651    fn home_and_end_move_to_line_bounds() {
652        let (mut ed, mut cx) = editor();
653        ed.apply(EditOp::Insert("hello".into()), &mut cx);
654        ed.apply(EditOp::Home { select: false }, &mut cx);
655        assert_eq!(ed.editing_state_bytes().extent, 0);
656        ed.apply(EditOp::End { select: false }, &mut cx);
657        assert_eq!(ed.editing_state_bytes().extent, 5);
658    }
659
660    #[test]
661    fn select_all_then_collapse() {
662        let (mut ed, mut cx) = editor();
663        ed.apply(EditOp::Insert("hello".into()), &mut cx);
664        ed.apply(EditOp::SelectAll, &mut cx);
665        let st = ed.editing_state_bytes();
666        assert_eq!(st.base.min(st.extent), 0);
667        assert_eq!(st.base.max(st.extent), 5);
668        ed.apply(EditOp::CollapseSelection, &mut cx);
669        let st = ed.editing_state_bytes();
670        assert_eq!(st.base, st.extent, "collapse must drop the anchor");
671    }
672
673    #[test]
674    fn insert_replaces_selection() {
675        let (mut ed, mut cx) = editor();
676        ed.apply(EditOp::Insert("hello".into()), &mut cx);
677        ed.apply(EditOp::SelectAll, &mut cx);
678        ed.apply(EditOp::Insert("bye".into()), &mut cx);
679        assert_eq!(ed.text(), "bye");
680    }
681
682    #[test]
683    fn compose_lifecycle_commit() {
684        let (mut ed, mut cx) = editor();
685        ed.apply(
686            EditOp::Compose {
687                text: "ni".into(),
688                cursor: Some((2, 2)),
689            },
690            &mut cx,
691        );
692        // Composing region is exposed in the editing state.
693        let st = ed.editing_state_bytes();
694        assert_eq!(st.composing, Some(0..2), "preedit range should be reported");
695        assert_eq!(ed.text(), "ni");
696        ed.apply(EditOp::FinishCompose, &mut cx);
697        let st = ed.editing_state_bytes();
698        assert_eq!(st.composing, None, "commit clears the composing region");
699        assert_eq!(ed.text(), "ni");
700    }
701
702    #[test]
703    fn compose_lifecycle_clear_reverts() {
704        let (mut ed, mut cx) = editor();
705        ed.apply(EditOp::Insert("a".into()), &mut cx);
706        ed.apply(
707            EditOp::Compose {
708                text: "xyz".into(),
709                cursor: None,
710            },
711            &mut cx,
712        );
713        assert_eq!(ed.text(), "axyz");
714        ed.apply(EditOp::ClearCompose, &mut cx);
715        // Clearing the preedit reverts to the pre-compose buffer.
716        assert_eq!(ed.text(), "a");
717        assert_eq!(ed.editing_state_bytes().composing, None);
718    }
719
720    #[test]
721    fn apply_editing_state_sets_text_selection_and_composing() {
722        let (mut ed, mut cx) = editor();
723        let state = EditingStateBytes {
724            text: "hello".into(),
725            base: 1,
726            extent: 3,
727            composing: Some(0..5),
728        };
729        ed.apply(EditOp::ApplyEditingState(state.clone()), &mut cx);
730        assert_eq!(ed.text(), "hello");
731        assert_eq!(ed.editing_state_bytes(), state);
732    }
733
734    #[test]
735    fn apply_editing_state_is_idempotent_and_short_circuits() {
736        let (mut ed, mut cx) = editor();
737        let state = EditingStateBytes {
738            text: "world".into(),
739            base: 2,
740            extent: 2,
741            composing: None,
742        };
743        ed.apply(EditOp::ApplyEditingState(state.clone()), &mut cx);
744        let first = ed.editing_state_bytes();
745        // Re-applying the identical state must be a no-op (feedback-loop guard).
746        ed.apply(EditOp::ApplyEditingState(state.clone()), &mut cx);
747        assert_eq!(ed.editing_state_bytes(), first);
748        assert_eq!(ed.editing_state_bytes(), state);
749    }
750
751    #[test]
752    fn apply_editing_state_selection_only_change_keeps_text() {
753        let (mut ed, mut cx) = editor();
754        ed.apply(
755            EditOp::ApplyEditingState(EditingStateBytes {
756                text: "abcd".into(),
757                base: 0,
758                extent: 0,
759                composing: None,
760            }),
761            &mut cx,
762        );
763        ed.apply(
764            EditOp::ApplyEditingState(EditingStateBytes {
765                text: "abcd".into(),
766                base: 1,
767                extent: 3,
768                composing: None,
769            }),
770            &mut cx,
771        );
772        let st = ed.editing_state_bytes();
773        assert_eq!(st.text, "abcd");
774        assert_eq!((st.base, st.extent), (1, 3));
775    }
776
777    #[test]
778    fn editing_state_utf16_converts_offsets_for_emoji() {
779        let (mut ed, mut cx) = editor();
780        // Buffer: "a😀" — 'a' = 1 byte / 1 unit, emoji = 4 bytes / 2 units.
781        ed.apply(
782            EditOp::ApplyEditingState(EditingStateBytes {
783                text: format!("a{EMOJI}"),
784                base: 5,
785                extent: 5,
786                composing: Some(1..5),
787            }),
788            &mut cx,
789        );
790        let st = ed.editing_state_utf16();
791        assert_eq!(st.selection_base, 3, "1 + 2 UTF-16 units");
792        assert_eq!(st.selection_extent, 3);
793        assert_eq!(st.composing_base, 1);
794        assert_eq!(st.composing_extent, 3);
795    }
796
797    #[test]
798    fn editing_state_utf16_reports_minus_one_when_not_composing() {
799        let (mut ed, mut cx) = editor();
800        ed.apply(EditOp::Insert("hi".into()), &mut cx);
801        let st = ed.editing_state_utf16();
802        assert_eq!(st.composing_base, -1);
803        assert_eq!(st.composing_extent, -1);
804    }
805
806    #[test]
807    fn cursor_and_selection_geometry_are_sane() {
808        let (mut ed, mut cx) = editor();
809        ed.apply(EditOp::Insert("hello".into()), &mut cx);
810
811        let caret = ed
812            .cursor_rect(2.0)
813            .expect("caret should exist after typing");
814        assert!(
815            caret.width() > 0.0,
816            "caret width should reflect the requested size"
817        );
818        assert!(caret.height() > 0.0, "caret should have positive height");
819
820        ed.apply(EditOp::SelectAll, &mut cx);
821        let rects = ed.selection_rects();
822        assert!(
823            !rects.is_empty(),
824            "a non-empty selection should yield rects"
825        );
826        for r in &rects {
827            assert!(
828                r.width() > 0.0 && r.height() > 0.0,
829                "selection rect must be non-degenerate: {r:?}"
830            );
831            assert!(r.x1 >= r.x0 && r.y1 >= r.y0, "rect must be ordered: {r:?}");
832        }
833
834        let ime = ed.ime_cursor_area();
835        assert!(
836            ime.x1 >= ime.x0 && ime.y1 >= ime.y0,
837            "ime area must be ordered"
838        );
839    }
840
841    #[test]
842    fn to_scene_runs_translates_by_origin_and_is_empty_when_fresh() {
843        let (mut ed, mut cx) = editor();
844        // Fresh editor: no layout refreshed yet.
845        assert!(ed.to_scene_runs(Point::ORIGIN).is_empty());
846
847        ed.apply(EditOp::Insert("Ag".into()), &mut cx);
848        let runs = ed.to_scene_runs(Point::new(10.0, 20.0));
849        assert!(!runs.is_empty(), "typed text should produce glyph runs");
850        for run in &runs {
851            assert_eq!(run.transform, kurbo::Affine::translate((10.0, 20.0)));
852        }
853    }
854
855    // --- conversion helper matrix ---
856
857    #[test]
858    fn byte_to_utf16_matrix() {
859        // ASCII
860        assert_eq!(byte_to_utf16("abc", 0), 0);
861        assert_eq!(byte_to_utf16("abc", 2), 2);
862        // é (U+00E9): 2 bytes, 1 UTF-16 unit
863        assert_eq!(byte_to_utf16("é", 0), 0);
864        assert_eq!(byte_to_utf16("é", 2), 1);
865        // Mid-char byte snaps back to the boundary.
866        assert_eq!(byte_to_utf16("é", 1), 0);
867        // Emoji: 4 bytes, 2 UTF-16 units.
868        assert_eq!(byte_to_utf16(EMOJI, 4), 2);
869        assert_eq!(byte_to_utf16(EMOJI, 2), 0, "mid-surrogate byte snaps back");
870        // ZWJ family: 25 bytes, 11 UTF-16 units (4 emoji × 2 + 3 ZWJ × 1).
871        assert_eq!(byte_to_utf16(FAMILY, FAMILY.len()), 11);
872        // Overflow clamps to the end.
873        assert_eq!(byte_to_utf16("abc", 99), 3);
874    }
875
876    #[test]
877    fn utf16_to_byte_matrix() {
878        // ASCII
879        assert_eq!(utf16_to_byte("abc", 0), 0);
880        assert_eq!(utf16_to_byte("abc", 2), 2);
881        // é
882        assert_eq!(utf16_to_byte("é", 1), 2);
883        // Emoji
884        assert_eq!(utf16_to_byte(EMOJI, 2), 4);
885        assert_eq!(
886            utf16_to_byte(EMOJI, 1),
887            0,
888            "mid-surrogate snaps to char start"
889        );
890        // ZWJ family: all 11 units -> full byte length.
891        assert_eq!(utf16_to_byte(FAMILY, 11), FAMILY.len());
892        // Overflow clamps to the end.
893        assert_eq!(utf16_to_byte("abc", 99), 3);
894    }
895
896    #[test]
897    fn conversion_helpers_round_trip_on_boundaries() {
898        for text in ["", "abc", "é", EMOJI, FAMILY, "aé😀b"] {
899            let mut byte = 0;
900            for ch in text.chars() {
901                let units = byte_to_utf16(text, byte);
902                assert_eq!(
903                    utf16_to_byte(text, units),
904                    byte,
905                    "round-trip failed at byte {byte} in {text:?}"
906                );
907                byte += ch.len_utf8();
908            }
909            // End boundary.
910            let units = byte_to_utf16(text, text.len());
911            assert_eq!(utf16_to_byte(text, units), text.len());
912        }
913    }
914
915    // --- editor/context style parity (task: TextStyle expansion) ---
916
917    #[test]
918    fn editor_letter_spacing_widens_layout_like_context_layout() {
919        // TextEditor::new's edit-style defaults must apply the same
920        // letter-spacing TextContext::layout does (see frust_text::tests
921        // in lib.rs for the equivalent context-side assertion).
922        let narrow_style = TextStyle::new(24.0, Color::BLACK);
923        let (mut narrow_ed, mut narrow_cx) = (TextEditor::new(&narrow_style), TextContext::new());
924        narrow_ed.apply(EditOp::Insert("Hello".into()), &mut narrow_cx);
925        let narrow_width = narrow_ed.layout_size().width;
926
927        let mut wide_style = TextStyle::new(24.0, Color::BLACK);
928        wide_style.letter_spacing = 20.0;
929        let (mut wide_ed, mut wide_cx) = (TextEditor::new(&wide_style), TextContext::new());
930        wide_ed.apply(EditOp::Insert("Hello".into()), &mut wide_cx);
931        let wide_width = wide_ed.layout_size().width;
932
933        assert!(
934            wide_width > narrow_width,
935            "the editor's own layout should widen with extra letter-spacing \
936             the same way TextContext::layout's does: {narrow_width} vs {wide_width}"
937        );
938    }
939
940    // --- wrapped multi-line mode (task: textinput-multiline) ---
941
942    #[test]
943    fn single_line_mode_keeps_one_line_and_grows_horizontally() {
944        // With no wrap width, a long string stays on a single visual line and
945        // the layout width reflects the whole run.
946        let (mut ed, mut cx) = editor();
947        ed.apply(
948            EditOp::Insert("the quick brown fox jumps over the lazy dog".into()),
949            &mut cx,
950        );
951        assert_eq!(ed.line_count(), 1, "single-line mode never wraps");
952        let one_line_h = ed.layout_size().height;
953        assert!(one_line_h > 0.0);
954        assert!(
955            ed.layout_size().width > 100.0,
956            "single line grows horizontally"
957        );
958    }
959
960    #[test]
961    fn wrap_width_produces_multiple_lines_and_grows_vertically() {
962        let (mut ed, mut cx) = editor();
963        ed.apply(
964            EditOp::Insert("the quick brown fox jumps over the lazy dog".into()),
965            &mut cx,
966        );
967        let single_h = ed.layout_size().height;
968
969        // Constrain to a narrow width: the same text must wrap onto >1 line and
970        // the reported layout height must grow accordingly.
971        ed.set_wrap_width(Some(60.0), &mut cx);
972        assert!(
973            ed.line_count() > 1,
974            "narrow wrap width splits the run into multiple lines, got {}",
975            ed.line_count()
976        );
977        assert!(
978            ed.layout_size().height > single_h,
979            "wrapped multi-line layout is taller than the single line: {single_h} -> {}",
980            ed.layout_size().height
981        );
982
983        // Clearing the wrap width collapses back to a single line.
984        ed.set_wrap_width(None, &mut cx);
985        assert_eq!(ed.line_count(), 1, "None restores single-line mode");
986    }
987
988    #[test]
989    fn insert_newline_splits_into_two_lines() {
990        let (mut ed, mut cx) = editor();
991        // A wrap width is what makes vertical layout meaningful; wide enough that
992        // only the explicit newline splits the text.
993        ed.set_wrap_width(Some(400.0), &mut cx);
994        ed.apply(EditOp::Insert("ab".into()), &mut cx);
995        ed.apply(EditOp::InsertNewline, &mut cx);
996        ed.apply(EditOp::Insert("cd".into()), &mut cx);
997        assert_eq!(ed.text(), "ab\ncd");
998        assert_eq!(ed.line_count(), 2, "the newline creates a second line");
999        // Caret sits after the last char (byte offset 5: 'a''b''\n''c''d').
1000        assert_eq!(ed.editing_state_bytes().extent, 5);
1001    }
1002
1003    #[test]
1004    fn move_up_down_navigate_across_a_newline() {
1005        let (mut ed, mut cx) = editor();
1006        ed.set_wrap_width(Some(400.0), &mut cx);
1007        ed.apply(EditOp::Insert("abc".into()), &mut cx);
1008        ed.apply(EditOp::InsertNewline, &mut cx);
1009        ed.apply(EditOp::Insert("xyz".into()), &mut cx);
1010        // Caret on line 2, at its end (byte 7).
1011        let bottom = ed.editing_state_bytes().extent;
1012        assert_eq!(bottom, 7);
1013
1014        // MoveUp lands on line 1 (byte offset within [0, 3]).
1015        ed.apply(EditOp::MoveUp { select: false }, &mut cx);
1016        let up = ed.editing_state_bytes().extent;
1017        assert!(up <= 3, "move-up crosses the newline onto line 1, got {up}");
1018
1019        // MoveDown returns to line 2 (byte offset >= 4, past the newline).
1020        ed.apply(EditOp::MoveDown { select: false }, &mut cx);
1021        let down = ed.editing_state_bytes().extent;
1022        assert!(
1023            down >= 4,
1024            "move-down crosses the newline back onto line 2, got {down}"
1025        );
1026    }
1027
1028    #[test]
1029    fn utf16_selection_indices_are_correct_across_a_newline() {
1030        let (mut ed, mut cx) = editor();
1031        ed.set_wrap_width(Some(400.0), &mut cx);
1032        // Line 1 "a😀" (1 byte + 4 bytes / 1 + 2 UTF-16 units), newline (1/1),
1033        // line 2 "b" — select the whole buffer.
1034        ed.apply(
1035            EditOp::ApplyEditingState(EditingStateBytes {
1036                text: format!("a{EMOJI}\nb"),
1037                base: 0,
1038                extent: format!("a{EMOJI}\nb").len(),
1039                composing: None,
1040            }),
1041            &mut cx,
1042        );
1043        assert_eq!(ed.line_count(), 2, "the newline still splits the layout");
1044        let st = ed.editing_state_utf16();
1045        // 1 (a) + 2 (emoji) + 1 (\n) + 1 (b) = 5 UTF-16 units.
1046        assert_eq!(st.selection_base, 0);
1047        assert_eq!(
1048            st.selection_extent, 5,
1049            "the newline counts as exactly one UTF-16 unit"
1050        );
1051    }
1052
1053    #[test]
1054    fn editor_applies_weight_and_style_edit_defaults_without_panicking() {
1055        let mut style = TextStyle::new(24.0, Color::BLACK);
1056        style.weight = FontWeight::BOLD;
1057        style.style = FontStyle::Italic;
1058        let (mut ed, mut cx) = (TextEditor::new(&style), TextContext::new());
1059        ed.apply(EditOp::Insert("Hi".into()), &mut cx);
1060        assert!(
1061            ed.layout_size().width > 0.0,
1062            "bold+italic edit-style defaults should still shape text"
1063        );
1064    }
1065
1066    // --- selected_text ---
1067
1068    #[test]
1069    fn selected_text_returns_none_when_collapsed() {
1070        let (mut ed, mut cx) = editor();
1071        ed.apply(EditOp::Insert("hello".into()), &mut cx);
1072        ed.apply(EditOp::Home { select: false }, &mut cx);
1073        // Caret is at start; selection is collapsed.
1074        assert_eq!(ed.selected_text(), None);
1075    }
1076
1077    #[test]
1078    fn selected_text_returns_slice_with_forward_selection() {
1079        let (mut ed, mut cx) = editor();
1080        ed.apply(EditOp::Insert("hello".into()), &mut cx);
1081        ed.apply(EditOp::Home { select: false }, &mut cx);
1082        ed.apply(EditOp::MoveRight { select: true }, &mut cx);
1083        ed.apply(EditOp::MoveRight { select: true }, &mut cx);
1084        // Selection is "he" (bytes 0..2).
1085        assert_eq!(ed.selected_text(), Some("he"));
1086    }
1087
1088    #[test]
1089    fn selected_text_returns_same_slice_with_backward_selection() {
1090        let (mut ed, mut cx) = editor();
1091        ed.apply(EditOp::Insert("hello".into()), &mut cx);
1092        ed.apply(EditOp::End { select: false }, &mut cx);
1093        ed.apply(EditOp::MoveLeft { select: true }, &mut cx);
1094        ed.apply(EditOp::MoveLeft { select: true }, &mut cx);
1095        // Backward selection from end (byte 5): two moves left lands at byte 3 = "lo".
1096        assert_eq!(ed.selected_text(), Some("lo"));
1097    }
1098
1099    #[test]
1100    fn selected_text_spans_multi_byte_emoji_correctly() {
1101        let (mut ed, mut cx) = editor();
1102        // Insert text with an emoji in the middle.
1103        ed.apply(EditOp::Insert(format!("a{EMOJI}b")), &mut cx);
1104        // Select all.
1105        ed.apply(EditOp::SelectAll, &mut cx);
1106        // Full slice includes the emoji.
1107        let selected = ed.selected_text().expect("full selection should exist");
1108        assert_eq!(selected, format!("a{EMOJI}b"));
1109    }
1110
1111    #[test]
1112    fn selected_text_can_select_just_the_emoji() {
1113        let (mut ed, mut cx) = editor();
1114        // Buffer: "a😀b" — "a" is 1 byte, emoji is 4 bytes, "b" is 1 byte.
1115        ed.apply(EditOp::Insert(format!("a{EMOJI}b")), &mut cx);
1116        // Move to after "a" (byte 1), then extend right 1 char (the emoji).
1117        ed.apply(EditOp::Home { select: false }, &mut cx);
1118        ed.apply(EditOp::MoveRight { select: false }, &mut cx);
1119        ed.apply(EditOp::MoveRight { select: true }, &mut cx);
1120        // Selection is just the emoji (bytes 1..5).
1121        assert_eq!(ed.selected_text(), Some(EMOJI));
1122    }
1123
1124    // --- sanitize_paste ---
1125
1126    #[test]
1127    fn sanitize_paste_single_line_removes_crlf() {
1128        let result = sanitize_paste("a\r\nb\n", true);
1129        assert_eq!(result, Cow::Borrowed::<str>("ab") as Cow<str>);
1130        assert_eq!(result.as_ref(), "ab");
1131    }
1132
1133    #[test]
1134    fn sanitize_paste_single_line_removes_lone_cr() {
1135        let result = sanitize_paste("a\rb", true);
1136        assert_eq!(result.as_ref(), "ab");
1137    }
1138
1139    #[test]
1140    fn sanitize_paste_single_line_removes_lone_lf() {
1141        let result = sanitize_paste("a\nb", true);
1142        assert_eq!(result.as_ref(), "ab");
1143    }
1144
1145    #[test]
1146    fn sanitize_paste_multi_line_normalizes_crlf_and_cr() {
1147        let result = sanitize_paste("a\r\nb\rc", false);
1148        assert_eq!(result.as_ref(), "a\nb\nc");
1149    }
1150
1151    #[test]
1152    fn sanitize_paste_multi_line_keeps_existing_lf() {
1153        let result = sanitize_paste("a\nb\nc", false);
1154        assert_eq!(result, Cow::Borrowed("a\nb\nc"));
1155    }
1156
1157    #[test]
1158    fn sanitize_paste_returns_borrowed_when_unchanged() {
1159        let text = "hello world";
1160        let result_single = sanitize_paste(text, true);
1161        assert_eq!(result_single, Cow::Borrowed(text));
1162
1163        let result_multi = sanitize_paste(text, false);
1164        assert_eq!(result_multi, Cow::Borrowed(text));
1165    }
1166
1167    #[test]
1168    fn sanitize_paste_single_line_multiple_newlines() {
1169        let result = sanitize_paste("line1\nline2\nline3", true);
1170        assert_eq!(result.as_ref(), "line1line2line3");
1171    }
1172
1173    #[test]
1174    fn sanitize_paste_multi_line_complex_mixed_endings() {
1175        // Mix of CRLF, lone CR, and lone LF.
1176        let result = sanitize_paste("a\r\nb\rc\nd", false);
1177        assert_eq!(result.as_ref(), "a\nb\nc\nd");
1178    }
1179
1180    #[test]
1181    fn sanitize_paste_empty_string() {
1182        let result_single = sanitize_paste("", true);
1183        assert_eq!(result_single, Cow::Borrowed(""));
1184
1185        let result_multi = sanitize_paste("", false);
1186        assert_eq!(result_multi, Cow::Borrowed(""));
1187    }
1188
1189    #[test]
1190    fn sanitize_paste_only_newlines_single_line() {
1191        let result = sanitize_paste("\r\n\n\r", true);
1192        assert_eq!(result.as_ref(), "");
1193    }
1194
1195    #[test]
1196    fn sanitize_paste_preserves_other_whitespace() {
1197        let text = "a  \t  b";
1198        let result = sanitize_paste(text, true);
1199        assert_eq!(result.as_ref(), text);
1200    }
1201}