Skip to main content

lumen_text/
edit.rs

1//! W3.2-W3.4: Lumen text-editing systems.
2//!
3//! Single-mutator architecture. Every text mutation (keyboard, IME,
4//! script, paste, undo, pointer drag) flows through
5//! [`lumen_core::text_events::TextEditRequest`]; the single
6//! [`text_apply_edits`] system in this crate is the only place that
7//! mutates [`lumen_core::text_model::TextBuffer`].
8//!
9//! Coexistence strategy. The existing `TextContent` + `TextInput` model
10//! (bd23f51 surgical fix) keeps working unchanged through the W3
11//! rollout: [`text_attach_buffer`] adds a [`TextBuffer`] / [`TextCursor`]
12//! / [`UndoStack`] alongside every `TextInput`, and
13//! [`text_mirror_buffer_to_content`] writes the rope back into
14//! `TextContent` after each edit so the existing renderer / binding
15//! systems keep reading the same shape.
16//!
17//! See `docs/audits/text-editing.md:172-275` for the rewrite spec.
18
19#![allow(missing_docs)]
20
21use crate::TextGeometry;
22use crate::undo::UndoStack;
23
24use bevy_ecs::message::{MessageReader, MessageWriter};
25use bevy_ecs::prelude::*;
26use lumen_core::prelude::*;
27use lumen_core::text_events::AppliedKind;
28use lumen_core::time::Instant;
29use std::ops::Range;
30use std::sync::Arc;
31use unicode_segmentation::{GraphemeCursor, UnicodeSegmentation};
32
33/// Query data for [`text_attach_buffer`]: `TextInput` entities that haven't
34/// yet had a `TextBuffer` attached. Factored out to keep clippy's
35/// `type_complexity` lint quiet.
36type UnattachedTextInputData<'a> = (Entity, &'a TextContent, &'a TextInput);
37/// Query filter for [`text_attach_buffer`]; see [`UnattachedTextInputData`].
38type UnattachedTextInputFilter = (With<TextInput>, Without<TextBuffer>);
39
40/// Bootstrap. For every `TextInput` entity without a `TextBuffer`, attach
41/// `TextBuffer` (seeded from `TextContent`), `TextCursor` (seeded from
42/// `TextInput.cursor`), an empty `UndoStack`, and the `TextEditable`
43/// marker. Idempotent - runs every tick at near-zero cost when nothing
44/// new is spawned.
45pub fn text_attach_buffer(
46    mut commands: Commands,
47    q: Query<UnattachedTextInputData, UnattachedTextInputFilter>,
48) {
49    for (e, tc, ti) in &q {
50        let kind = if ti.multiline {
51            TextBufferKind::MultiLine
52        } else {
53            TextBufferKind::SingleLine
54        };
55        let mut buf = TextBuffer {
56            rope: ropey::Rope::from_str(&tc.0),
57            version: 0,
58            kind,
59        };
60        let _ = &mut buf; // silence unused-mut on minimal builds
61        let cursor = {
62            let head = TextPos::from_byte(&tc.0, ti.cursor);
63            let anchor = match ti.selection_anchor {
64                Some(a) => TextPos::from_byte(&tc.0, a),
65                None => head,
66            };
67            TextCursor {
68                head,
69                anchor,
70                affinity: Affinity::default(),
71                goal_x: None,
72            }
73        };
74        commands
75            .entity(e)
76            .insert((buf, cursor, UndoStack::default(), TextEditable));
77    }
78}
79
80/// The single text mutator (W3.2). Drains
81/// [`lumen_core::text_events::TextEditRequest`] and mutates
82/// [`TextBuffer`] / [`TextCursor`] / [`UndoStack`] / [`ImePreedit`] in
83/// place. Emits [`TextEditApplied`] after each accepted edit and
84/// [`TextEditRejected`] for validator / single-line newline / undo-empty
85/// rejections.
86///
87/// Execution-order contract:
88/// - Runs AFTER every producer (route_ime_events, type_into_focused,
89///   pointer drag, script set_text, paste).
90/// - Runs BEFORE `text_mirror_buffer_to_content` so the mirror sees the
91///   post-mutation rope.
92/// - Runs BEFORE bind-text push systems so they see only post-mutation
93///   text.
94#[allow(clippy::too_many_arguments)]
95pub fn text_apply_edits(
96    mut requests: MessageReader<TextEditRequest>,
97    mut applied: MessageWriter<TextEditApplied>,
98    mut rejected: MessageWriter<TextEditRejected>,
99    mut q: Query<(
100        &mut TextBuffer,
101        &mut TextCursor,
102        &mut UndoStack,
103        Option<&mut ImePreedit>,
104    )>,
105    mut commands: Commands,
106) {
107    for req in requests.read() {
108        let entity = req.entity();
109        let Ok((mut buf, mut cur, mut undo, mut preedit)) = q.get_mut(entity) else {
110            rejected.write(TextEditRejected {
111                entity,
112                reason: RejectReason::BadTarget,
113            });
114            continue;
115        };
116        let before_byte = cur.head.byte;
117        let outcome = apply_one(
118            req,
119            entity,
120            &mut buf,
121            &mut cur,
122            &mut undo,
123            preedit.as_deref_mut(),
124            &mut commands,
125        );
126        match outcome {
127            ApplyOutcome::Applied(kind) => {
128                applied.write(TextEditApplied {
129                    entity,
130                    version: buf.version,
131                    kind,
132                    before_byte,
133                    after_byte: cur.head.byte,
134                });
135            }
136            ApplyOutcome::Rejected(reason) => {
137                rejected.write(TextEditRejected { entity, reason });
138            }
139        }
140    }
141}
142
143enum ApplyOutcome {
144    Applied(AppliedKind),
145    Rejected(RejectReason),
146}
147
148#[allow(clippy::too_many_arguments)]
149fn apply_one(
150    req: &TextEditRequest,
151    entity: Entity,
152    buf: &mut TextBuffer,
153    cur: &mut TextCursor,
154    undo: &mut UndoStack,
155    preedit: Option<&mut ImePreedit>,
156    commands: &mut Commands,
157) -> ApplyOutcome {
158    use TextEditRequest::*;
159    match req {
160        Insert { at, text, .. } => {
161            let sanitized = sanitize_for_buffer(buf, text);
162            if let Some(reason) = sanitized.reject {
163                return ApplyOutcome::Rejected(reason);
164            }
165            let pos = resolve_pos(buf, Anchor::Position(*at));
166            insert_text(buf, cur, undo, pos.byte, &sanitized.text);
167            ApplyOutcome::Applied(AppliedKind::Insert)
168        }
169        Delete { range, .. } => {
170            let r = clamp_range(buf, range.clone());
171            if r.is_empty() {
172                return ApplyOutcome::Rejected(RejectReason::BadTarget);
173            }
174            delete_range(buf, cur, undo, r);
175            ApplyOutcome::Applied(AppliedKind::Delete)
176        }
177        Replace { range, text, .. } => {
178            let sanitized = sanitize_for_buffer(buf, text);
179            if let Some(reason) = sanitized.reject {
180                return ApplyOutcome::Rejected(reason);
181            }
182            let r = clamp_range(buf, range.clone());
183            replace_range(buf, cur, undo, r, &sanitized.text);
184            ApplyOutcome::Applied(AppliedKind::Replace)
185        }
186        MoveCursor { motion, mode, .. } => {
187            let new_head = move_cursor(buf, cur.head, *motion);
188            let keep_anchor = matches!(mode, MoveMode::KeepAnchor);
189            cur.move_head(new_head, keep_anchor);
190            ApplyOutcome::Applied(AppliedKind::CursorMove)
191        }
192        Select { range, .. } => {
193            let r = clamp_range(buf, range.clone());
194            cur.anchor = TextPos::from_buffer_byte(buf, r.start);
195            cur.head = TextPos::from_buffer_byte(buf, r.end);
196            ApplyOutcome::Applied(AppliedKind::CursorMove)
197        }
198        SetCursor { pos, .. } => {
199            let p = resolve_pos(buf, Anchor::Position(*pos));
200            cur.head = p;
201            cur.anchor = p;
202            ApplyOutcome::Applied(AppliedKind::CursorMove)
203        }
204        ExtendSelection { pos, .. } => {
205            let p = resolve_pos(buf, Anchor::Position(*pos));
206            cur.move_head(p, true);
207            ApplyOutcome::Applied(AppliedKind::CursorMove)
208        }
209        SelectAll { .. } => {
210            cur.anchor = TextPos::ZERO;
211            cur.head = TextPos::from_buffer_byte(buf, buf.len_bytes());
212            ApplyOutcome::Applied(AppliedKind::CursorMove)
213        }
214        Undo { .. } => {
215            if !undo.undo(buf, cur) {
216                return ApplyOutcome::Rejected(RejectReason::NothingToUndo);
217            }
218            ApplyOutcome::Applied(AppliedKind::UndoRedo)
219        }
220        Redo { .. } => {
221            if !undo.redo(buf, cur) {
222                return ApplyOutcome::Rejected(RejectReason::NothingToRedo);
223            }
224            ApplyOutcome::Applied(AppliedKind::UndoRedo)
225        }
226        ImeBegin { .. } => {
227            if preedit.is_none() {
228                commands.entity(entity).insert(ImePreedit::default());
229            }
230            ApplyOutcome::Applied(AppliedKind::CursorMove)
231        }
232        ImeUpdate {
233            text,
234            caret_in_preedit,
235            ..
236        } => {
237            let caret = (*caret_in_preedit).min(text.len());
238            match preedit {
239                Some(p) => {
240                    p.text = text.to_string();
241                    p.caret_in_preedit = caret;
242                }
243                None => {
244                    commands.entity(entity).insert(ImePreedit {
245                        text: text.to_string(),
246                        caret_in_preedit: caret,
247                    });
248                }
249            }
250            ApplyOutcome::Applied(AppliedKind::CursorMove)
251        }
252        ImeCommit {
253            text,
254            replace_range,
255            ..
256        } => {
257            let sanitized = sanitize_for_buffer(buf, text);
258            if let Some(reason) = sanitized.reject {
259                // Even a rejected commit clears preedit.
260                commands.entity(entity).remove::<ImePreedit>();
261                return ApplyOutcome::Rejected(reason);
262            }
263            // Drop selection / replace_range / cursor insertion in priority.
264            let kind = if let Some(rr) = replace_range {
265                let r = clamp_range(buf, rr.clone());
266                replace_range_op(buf, cur, undo, r, &sanitized.text);
267                AppliedKind::Replace
268            } else if let Some(sel) = cur.selection_range() {
269                replace_range_op(buf, cur, undo, sel, &sanitized.text);
270                AppliedKind::Replace
271            } else {
272                insert_text(buf, cur, undo, cur.head.byte, &sanitized.text);
273                AppliedKind::Insert
274            };
275            commands.entity(entity).remove::<ImePreedit>();
276            ApplyOutcome::Applied(kind)
277        }
278        ImeCancel { .. } => {
279            commands.entity(entity).remove::<ImePreedit>();
280            ApplyOutcome::Applied(AppliedKind::CursorMove)
281        }
282    }
283}
284
285struct Sanitized {
286    text: String,
287    reject: Option<RejectReason>,
288}
289
290fn sanitize_for_buffer(buf: &TextBuffer, text: &str) -> Sanitized {
291    if buf.is_single_line() && text.contains('\n') {
292        // Per Qt's QLineEdit::setText: strip newlines silently for
293        // single-line; we follow that convention rather than rejecting
294        // the entire edit so paste-with-newlines still inserts visible
295        // content.
296        let stripped: String = text.replace('\n', " ");
297        return Sanitized {
298            text: stripped,
299            reject: None,
300        };
301    }
302    Sanitized {
303        text: text.to_string(),
304        reject: None,
305    }
306}
307
308fn resolve_pos(buf: &TextBuffer, anchor: Anchor) -> TextPos {
309    match anchor {
310        Anchor::Cursor => TextPos::from_buffer_byte(buf, buf.len_bytes()),
311        Anchor::Position(p) => {
312            // Snap to char boundary.
313            TextPos::from_buffer_byte(buf, p.byte)
314        }
315        Anchor::SelectionStart | Anchor::DocumentStart => TextPos::ZERO,
316        Anchor::SelectionEnd | Anchor::DocumentEnd => {
317            TextPos::from_buffer_byte(buf, buf.len_bytes())
318        }
319    }
320}
321
322fn clamp_range(buf: &TextBuffer, mut r: Range<usize>) -> Range<usize> {
323    let len = buf.len_bytes();
324    if r.start > len {
325        r.start = len;
326    }
327    if r.end > len {
328        r.end = len;
329    }
330    if r.start > r.end {
331        std::mem::swap(&mut r.start, &mut r.end);
332    }
333    // Snap both ends to char boundaries.
334    let s = buf.rope.to_string();
335    let mut start = r.start;
336    while start > 0 && !s.is_char_boundary(start) {
337        start -= 1;
338    }
339    let mut end = r.end;
340    while end < s.len() && !s.is_char_boundary(end) {
341        end += 1;
342    }
343    start..end
344}
345
346/// Insert `text` at `at_byte` (must be a char boundary; producers clamp
347/// via [`TextPos::from_byte`]), moving caret + anchor past the insertion
348/// and recording an undo entry. Public so `lumen-input`'s keystroke
349/// router can mutate the buffer through the same single implementation
350/// the request mutator uses.
351pub fn insert_text(
352    buf: &mut TextBuffer,
353    cur: &mut TextCursor,
354    undo: &mut UndoStack,
355    at_byte: usize,
356    text: &str,
357) {
358    if text.is_empty() {
359        return;
360    }
361    let char_idx = buf.rope.byte_to_char(at_byte);
362    let before_pos = cur.head;
363    buf.rope.insert(char_idx, text);
364    buf.version = buf.version.wrapping_add(1);
365    let new_byte = at_byte + text.len();
366    let new_head = TextPos::from_buffer_byte(buf, new_byte);
367    cur.head = new_head;
368    cur.anchor = new_head;
369    undo.record_insert(at_byte, text, before_pos);
370}
371
372/// Delete the byte `range` (char-boundary clamped by callers), collapse
373/// the caret to `range.start`, and record an undo entry. See
374/// [`insert_text`] for the visibility rationale.
375pub fn delete_range(
376    buf: &mut TextBuffer,
377    cur: &mut TextCursor,
378    undo: &mut UndoStack,
379    range: Range<usize>,
380) {
381    if range.is_empty() {
382        return;
383    }
384    let removed = {
385        let s_char = buf.rope.byte_to_char(range.start);
386        let e_char = buf.rope.byte_to_char(range.end);
387        let removed = buf.rope.slice(s_char..e_char).to_string();
388        buf.rope.remove(s_char..e_char);
389        removed
390    };
391    buf.version = buf.version.wrapping_add(1);
392    let new_head = TextPos::from_buffer_byte(buf, range.start);
393    let before_pos = cur.head;
394    cur.head = new_head;
395    cur.anchor = new_head;
396    undo.record_delete(range.start, &removed, before_pos);
397}
398
399fn replace_range_op(
400    buf: &mut TextBuffer,
401    cur: &mut TextCursor,
402    undo: &mut UndoStack,
403    range: Range<usize>,
404    text: &str,
405) {
406    let removed = {
407        let s_char = buf.rope.byte_to_char(range.start);
408        let e_char = buf.rope.byte_to_char(range.end);
409        let removed = buf.rope.slice(s_char..e_char).to_string();
410        if !removed.is_empty() {
411            buf.rope.remove(s_char..e_char);
412        }
413        removed
414    };
415    let s_char = buf.rope.byte_to_char(range.start);
416    if !text.is_empty() {
417        buf.rope.insert(s_char, text);
418    }
419    buf.version = buf.version.wrapping_add(1);
420    let new_byte = range.start + text.len();
421    let before_pos = cur.head;
422    let new_head = TextPos::from_buffer_byte(buf, new_byte);
423    cur.head = new_head;
424    cur.anchor = new_head;
425    undo.record_replace(range.start, &removed, text, before_pos);
426}
427
428/// Replace the byte `range` with `text`, landing the caret after the
429/// replacement and recording a single undo entry. See [`insert_text`]
430/// for the visibility rationale.
431pub fn replace_range(
432    buf: &mut TextBuffer,
433    cur: &mut TextCursor,
434    undo: &mut UndoStack,
435    range: Range<usize>,
436    text: &str,
437) {
438    replace_range_op(buf, cur, undo, range, text);
439}
440
441/// Resolve a [`CursorMotion`] from `from` against the buffer contents.
442/// Line motions step logical (`\n`-delimited) lines with byte-column
443/// preservation. Public so the keystroke router shares one motion
444/// implementation with the request mutator.
445pub fn move_cursor(buf: &TextBuffer, from: TextPos, motion: CursorMotion) -> TextPos {
446    let text = buf.rope.to_string();
447    let byte = from.byte.min(text.len());
448    let new_byte = match motion {
449        CursorMotion::CharLeft => prev_grapheme_boundary(&text, byte),
450        CursorMotion::CharRight => next_grapheme_boundary(&text, byte),
451        CursorMotion::WordLeft => prev_word_boundary(&text, byte),
452        CursorMotion::WordRight => next_word_boundary(&text, byte),
453        CursorMotion::LineStart => line_start(&text, byte),
454        CursorMotion::LineEnd => line_end(&text, byte),
455        CursorMotion::LineUp => {
456            // Lacking shaped-line geometry here, we step one logical line.
457            let ls = line_start(&text, byte);
458            if ls == 0 {
459                0
460            } else {
461                let prev_end = ls - 1;
462                let prev_start = line_start(&text, prev_end);
463                let col = byte - ls;
464                (prev_start + col).min(prev_end)
465            }
466        }
467        CursorMotion::LineDown => {
468            let le = line_end(&text, byte);
469            if le == text.len() {
470                text.len()
471            } else {
472                let next_start = le + 1;
473                let next_end = line_end(&text, next_start);
474                let col = byte - line_start(&text, byte);
475                (next_start + col).min(next_end)
476            }
477        }
478        CursorMotion::DocStart => 0,
479        CursorMotion::DocEnd => text.len(),
480    };
481    TextPos::from_byte(&text, new_byte)
482}
483
484/// D6: resolve a soft-wrap-aware vertical / line-bound motion against shaped
485/// [`TextGeometry`]. Returns `(new head, new goal_x)`: the goal x to store
486/// back on the cursor (Qt `verticalMovementX`, D5).
487///
488/// - `LineUp` / `LineDown`: step one visual line, landing on the byte whose
489///   caret x is nearest the sticky `goal_x` (seeded from the caret's current
490///   x when `goal_x` is `None`); the goal is preserved across the move so a
491///   run of Up/Down tracks the original column. At the top / bottom visual
492///   line the motion delegates to the byte-only [`move_cursor`] (doc start /
493///   end), matching Qt.
494/// - `LineStart` / `LineEnd`: snap to the VISUAL line's byte bounds (soft-wrap
495///   aware), clearing the goal like any horizontal motion.
496/// - Any other motion delegates to [`move_cursor`] and clears the goal.
497///
498/// No shaper dependency: the caller passes the geometry the producer already
499/// shaped. Falls back naturally when the geometry is empty (single logical
500/// line via [`move_cursor`]).
501pub fn move_cursor_visual(
502    geom: &TextGeometry,
503    buf: &TextBuffer,
504    cur: TextCursor,
505    motion: CursorMotion,
506) -> (TextPos, Option<f32>) {
507    let text = buf.rope.to_string();
508    let head = cur.head.byte.min(text.len());
509    match motion {
510        CursorMotion::LineUp | CursorMotion::LineDown => {
511            let count = geom.line_count();
512            if count == 0 {
513                return (move_cursor(buf, cur.head, motion), None);
514            }
515            let line = geom.visual_line_of_byte(head);
516            let target = match motion {
517                CursorMotion::LineUp => line.saturating_sub(1),
518                _ => (line + 1).min(count - 1),
519            };
520            if target == line {
521                // Already on the top / bottom visual line: fall through to
522                // the byte motion (doc start on Up, doc end on Down) but keep
523                // the goal so a subsequent reversal still tracks the column.
524                return (move_cursor(buf, cur.head, motion), cur.goal_x);
525            }
526            let goal = cur.goal_x.unwrap_or_else(|| geom.byte_to_caret(head).x);
527            let landed = geom.byte_at_line_x(target, goal);
528            (TextPos::from_byte(&text, landed), Some(goal))
529        }
530        CursorMotion::LineStart | CursorMotion::LineEnd => {
531            let line = geom.visual_line_of_byte(head);
532            let (s, e) = geom.visual_line_bounds(line);
533            let b = if matches!(motion, CursorMotion::LineStart) {
534                s
535            } else {
536                e
537            };
538            (TextPos::from_byte(&text, b), None)
539        }
540        _ => (move_cursor(buf, cur.head, motion), None),
541    }
542}
543
544/// Snap `from` down to the nearest UTF-8 code point boundary.
545fn snap_char_boundary(s: &str, from: usize) -> usize {
546    let mut i = from.min(s.len());
547    while i > 0 && !s.is_char_boundary(i) {
548        i -= 1;
549    }
550    i
551}
552
553/// Previous extended grapheme cluster boundary before `from`.
554///
555/// Drives [`CursorMotion::CharLeft`], so Left / Shift+Left / forward-word
556/// fallbacks all step whole clusters (Qt `previousCursorPosition`, Slint
557/// `GraphemeCursor::prev_boundary`). Backspace does NOT use this; see
558/// [`prev_code_point_boundary`].
559pub fn prev_grapheme_boundary(s: &str, from: usize) -> usize {
560    let from = snap_char_boundary(s, from);
561    let mut cursor = GraphemeCursor::new(from, s.len(), true);
562    cursor.prev_boundary(s, 0).ok().flatten().unwrap_or(0)
563}
564
565/// Next extended grapheme cluster boundary after `from`.
566///
567/// Drives [`CursorMotion::CharRight`], which forward Delete also resolves
568/// through, so Delete removes a whole cluster rather than one scalar.
569pub fn next_grapheme_boundary(s: &str, from: usize) -> usize {
570    let from = snap_char_boundary(s, from);
571    let mut cursor = GraphemeCursor::new(from, s.len(), true);
572    cursor.next_boundary(s, 0).ok().flatten().unwrap_or(s.len())
573}
574
575/// Previous code point boundary before `from`.
576///
577/// Backspace only. Qt's `backspace()` steps one code unit and Slint keeps a
578/// dedicated `PreviousCharacter` direction for the same reason: peeling a
579/// combining mark off the base character backwards is the expected editing
580/// behavior. Keep this asymmetry with [`prev_grapheme_boundary`].
581pub fn prev_code_point_boundary(s: &str, from: usize) -> usize {
582    let from = snap_char_boundary(s, from);
583    if from == 0 {
584        return 0;
585    }
586    snap_char_boundary(s, from - 1)
587}
588
589fn prev_word_boundary(s: &str, from: usize) -> usize {
590    let mut prev = 0usize;
591    for (off, _) in s.split_word_bound_indices() {
592        if off >= from {
593            return prev;
594        }
595        prev = off;
596    }
597    prev
598}
599
600fn next_word_boundary(s: &str, from: usize) -> usize {
601    for (off, w) in s.split_word_bound_indices() {
602        let end = off + w.len();
603        if end > from {
604            return end;
605        }
606    }
607    s.len()
608}
609
610fn line_start(s: &str, from: usize) -> usize {
611    s[..from].rfind('\n').map(|i| i + 1).unwrap_or(0)
612}
613
614fn line_end(s: &str, from: usize) -> usize {
615    s[from..].find('\n').map(|i| from + i).unwrap_or(s.len())
616}
617
618/// Mirror system. After every successful edit, write the rope back into
619/// [`TextContent`] so the existing renderer / `apply_text_bindings` push
620/// path keeps reading the same shape. Also writes the byte cursor back
621/// into [`TextInput.cursor`] for legacy keystroke routes.
622///
623/// Gated on [`TextEditApplied`] (W3.2) - no per-tick sweep.
624#[allow(clippy::type_complexity)]
625pub fn text_mirror_buffer_to_content(
626    mut applied: MessageReader<TextEditApplied>,
627    mut q: Query<(
628        &TextBuffer,
629        &TextCursor,
630        &mut TextContent,
631        Option<&mut TextInput>,
632    )>,
633) {
634    for ev in applied.read() {
635        let Ok((buf, cur, mut tc, ti)) = q.get_mut(ev.entity) else {
636            continue;
637        };
638        let s = buf.rope.to_string();
639        if tc.0 != s {
640            tc.0 = s;
641        }
642        if let Some(mut ti) = ti {
643            ti.cursor = cur.head.byte;
644            ti.selection_anchor = if cur.is_empty() {
645                None
646            } else {
647                Some(cur.anchor.byte)
648            };
649        }
650    }
651}
652
653/// Pre-keystroke gate. When the existing `route_ime_events` /
654/// `type_into_focused` path mutates [`TextContent`] directly (the
655/// bd23f51 surgical fix), reflect those changes back into [`TextBuffer`]
656/// so the W3 model stays in sync. Runs AFTER the legacy keystroke path
657/// and BEFORE shaping/render.
658///
659/// Triggered by `TextContent`'s Changed<> bit, but the body is a no-op
660/// when the rope already matches (i.e. the change came from
661/// `text_mirror_buffer_to_content`).
662#[allow(clippy::type_complexity)]
663pub fn text_reflect_content_to_buffer(
664    mut q: Query<
665        (
666            &TextContent,
667            &mut TextBuffer,
668            Option<&TextInput>,
669            &mut TextCursor,
670            Option<&mut UndoStack>,
671        ),
672        Changed<TextContent>,
673    >,
674) {
675    for (tc, mut buf, ti, mut cur, undo) in &mut q {
676        let rope_str = buf.rope.to_string();
677        if rope_str == tc.0 {
678            continue;
679        }
680        // External (signal binding, legacy keystroke fallback) wrote
681        // into TextContent. Pull it into the rope, bump version, and
682        // re-seed the cursor.
683        buf.rope = ropey::Rope::from_str(&tc.0);
684        buf.version = buf.version.wrapping_add(1);
685        let byte = match ti {
686            Some(ti) => ti.cursor.min(tc.0.len()),
687            None => tc.0.len(),
688        };
689        let p = TextPos::from_byte(&tc.0, byte);
690        cur.head = p;
691        cur.anchor = p;
692        // The recorded undo deltas are positioned against the previous
693        // rope; replaying them against externally-replaced content
694        // would splice at stale offsets. Clear, matching Qt's
695        // `QLineEdit::setText` behavior.
696        if let Some(mut undo) = undo {
697            undo.clear();
698        }
699    }
700}
701
702/// Caret blink driver (W2 Qt-polish: text-editing core).
703///
704/// While a [`TextInput`] entity holds keyboard focus:
705/// - toggles [`CaretBlink::visible`] every [`CaretBlink::period`]
706///   (~530 ms, Qt's cadence),
707/// - resets the phase to *visible* whenever the focused input's
708///   `TextInput` state changes (any edit or caret/selection move - the
709///   keystroke router and the mirror both write those fields in
710///   lockstep with the buffer) or focus moves to a different entity,
711/// - marks [`FrameDirty`] on each toggle so the backend re-encodes, and
712/// - raises [`AnimationsActive`] so the redraw scheduler keeps ticking
713///   between OS events (same self-scheduling contract the hover/press
714///   tweens use).
715///
716/// When no text input is focused the system requests nothing: the flag
717/// stays untouched at `visible`, `AnimationsActive` is not raised, and
718/// the app reaches full idle quiescence (zero blink wakeups).
719pub fn caret_blink(
720    tracker: Res<lumen_core::input::FocusTracker>,
721    inputs: Query<(), With<TextInput>>,
722    changed_inputs: Query<(), Changed<TextInput>>,
723    mut blink: ResMut<lumen_core::components::CaretBlink>,
724    animations: Option<Res<lumen_core::render_world::AnimationsActive>>,
725    frame_dirty: Option<ResMut<lumen_core::render_world::FrameDirty>>,
726    mut last_focused: Local<Option<Entity>>,
727) {
728    let focused_input = tracker.0.filter(|e| inputs.contains(*e));
729    let Some(entity) = focused_input else {
730        // Unfocused: park fully. Leave the flag visible so the caret
731        // shows immediately on the next focus.
732        if !blink.visible {
733            blink.reset();
734        }
735        *last_focused = None;
736        return;
737    };
738    let was_visible = blink.visible;
739    let fresh_focus = *last_focused != Some(entity);
740    if fresh_focus || changed_inputs.contains(entity) {
741        // Focus landed here, or the caret moved / text changed: restart
742        // the phase at "visible" so the bar never blinks mid-keystroke.
743        blink.reset();
744        *last_focused = Some(entity);
745    } else {
746        let periods = blink
747            .phase
748            .elapsed()
749            .as_millis()
750            .checked_div(blink.period.as_millis())
751            .unwrap_or(0);
752        blink.visible = periods.is_multiple_of(2);
753    }
754    // Repaint on a visibility flip, and on fresh focus (focus markers
755    // aren't in `roll_up_frame_dirty`'s watch list, so nothing else
756    // guarantees the caret's first frame).
757    if (blink.visible != was_visible || fresh_focus)
758        && let Some(mut fd) = frame_dirty
759    {
760        fd.dirty = true;
761    }
762    // Keep the event loop ticking while an input is focused so the next
763    // half-phase actually gets evaluated (there is no OS event to wake
764    // us otherwise). Cleared at tick start; not raised when unfocused.
765    if let Some(anims) = animations {
766        anims.request();
767    }
768}
769
770/// Helper used by producers: build a `TextEditRequest::Insert` from a
771/// string. Inserts at the cursor head by resolving inside the mutator.
772pub fn make_insert(entity: Entity, at: TextPos, text: impl Into<Arc<str>>) -> TextEditRequest {
773    TextEditRequest::Insert {
774        entity,
775        at,
776        text: text.into(),
777    }
778}
779
780/// Plugin that wires the text-edit systems.
781///
782/// Ordering inside `TickStage::Systems`:
783/// - `text_attach_buffer` early (so freshly-spawned `<input>` has a buffer).
784/// - `text_apply_edits` in [`TextEditSet::Apply`], after
785///   [`TextEditSet::Producers`] - `lumen-input` tags its request
786///   producers (`type_into_focused`, `text_pointer_to_caret`,
787///   `text_pointer_drag_select`, `route_ime_events`,
788///   `cycle_focus_on_tab`) with that shared label, so pointer edits
789///   apply on the tick they were produced. The set edge is inert when
790///   no producer is installed.
791/// - `text_mirror_buffer_to_content` ([`TextEditSet::Mirror`]) after
792///   `text_apply_edits`.
793/// - `text_reflect_content_to_buffer` after the mirror (catches signal
794///   bindings and any legacy direct `TextContent` writes).
795/// - `caret_blink` after the mirror so a same-tick edit resets the
796///   phase before extract reads it.
797///
798/// Bind-text mirroring runs in its own stage; the mirror system writes
799/// `TextContent` so `push_textinput_to_signal` (which filters on
800/// `Changed<TextContent>`) keeps picking up edits.
801pub struct TextEditPlugin;
802
803impl Plugin for TextEditPlugin {
804    fn build(self, app: &mut lumen_core::app::App) {
805        app.world
806            .init_resource::<bevy_ecs::message::Messages<TextEditRequest>>();
807        app.world
808            .init_resource::<bevy_ecs::message::Messages<TextEditApplied>>();
809        app.world
810            .init_resource::<bevy_ecs::message::Messages<TextEditRejected>>();
811        app.world
812            .init_resource::<bevy_ecs::message::Messages<ImeSurroundingRequested>>();
813        app.world
814            .init_resource::<bevy_ecs::message::Messages<ImeSurroundingResponse>>();
815        app.world
816            .init_resource::<lumen_core::components::CaretBlink>();
817        // Bootstrap runs every tick - idempotent and cheap.
818        app.add_systems(TickStage::Systems, text_attach_buffer);
819        app.add_systems(
820            TickStage::Systems,
821            text_apply_edits
822                .in_set(lumen_core::text_events::TextEditSet::Apply)
823                .after(text_attach_buffer)
824                .after(lumen_core::text_events::TextEditSet::Producers),
825        );
826        app.add_systems(
827            TickStage::Systems,
828            text_mirror_buffer_to_content
829                .in_set(lumen_core::text_events::TextEditSet::Mirror)
830                .after(text_apply_edits),
831        );
832        app.add_systems(
833            TickStage::Systems,
834            text_reflect_content_to_buffer.after(text_mirror_buffer_to_content),
835        );
836        app.add_systems(
837            TickStage::Systems,
838            caret_blink.after(text_mirror_buffer_to_content),
839        );
840    }
841}
842
843/// Default plugin metadata for [`TextEditPlugin`].
844impl Default for TextEditPlugin {
845    fn default() -> Self {
846        Self
847    }
848}
849
850// Suppress unused-import warnings on minimal builds (some helpers
851// referenced only inside conditionally-compiled tests below).
852#[allow(dead_code)]
853fn _suppress_unused_warnings() {
854    let _ = Instant::now;
855    let _: &dyn Fn(&str) -> Vec<&str> = &|s| s.unicode_words().collect();
856}
857
858#[cfg(test)]
859mod tests {
860    use super::*;
861    use bevy_ecs::message::Messages;
862    use lumen_core::app::App;
863
864    fn build_app() -> App {
865        let mut app = App::new();
866        app.world.init_resource::<Messages<TextEditRequest>>();
867        app.world.init_resource::<Messages<TextEditApplied>>();
868        app.world.init_resource::<Messages<TextEditRejected>>();
869        app
870    }
871
872    fn spawn_editable(app: &mut App, text: &str) -> Entity {
873        let buf = TextBuffer::multi_line(text);
874        let cursor = {
875            let p = TextPos::from_byte(text, text.len());
876            TextCursor {
877                head: p,
878                anchor: p,
879                affinity: Affinity::default(),
880                goal_x: None,
881            }
882        };
883        app.world
884            .spawn((
885                TextContent(text.to_string()),
886                TextInput {
887                    placeholder: String::new(),
888                    cursor: text.len(),
889                    selection_anchor: None,
890                    multiline: true,
891                },
892                buf,
893                cursor,
894                UndoStack::default(),
895                TextEditable,
896            ))
897            .id()
898    }
899
900    #[test]
901    fn insert_appends_text_and_bumps_version() {
902        let mut app = build_app();
903        let e = spawn_editable(&mut app, "hello");
904        app.world
905            .resource_mut::<Messages<TextEditRequest>>()
906            .write(TextEditRequest::Insert {
907                entity: e,
908                at: TextPos {
909                    byte: 5,
910                    grapheme: 5,
911                },
912                text: Arc::from(" world"),
913            });
914        let mut sched = bevy_ecs::schedule::Schedule::default();
915        sched.add_systems(text_apply_edits);
916        sched.run(&mut app.world);
917        let buf = app.world.entity(e).get::<TextBuffer>().unwrap();
918        assert_eq!(buf.to_string(), "hello world");
919        assert_eq!(buf.version, 1);
920    }
921
922    #[test]
923    fn delete_range_clamps_to_boundary() {
924        let mut app = build_app();
925        let e = spawn_editable(&mut app, "h\u{e9}llo"); // '\u{e9}' = 2 bytes
926        app.world
927            .resource_mut::<Messages<TextEditRequest>>()
928            .write(TextEditRequest::Delete {
929                entity: e,
930                range: 1..2, // mid '\u{e9}' -> clamps to 1..3
931            });
932        let mut sched = bevy_ecs::schedule::Schedule::default();
933        sched.add_systems(text_apply_edits);
934        sched.run(&mut app.world);
935        let buf = app.world.entity(e).get::<TextBuffer>().unwrap();
936        assert_eq!(buf.to_string(), "hllo");
937    }
938
939    #[test]
940    fn replace_with_selection_uses_replace_kind() {
941        let mut app = build_app();
942        let e = spawn_editable(&mut app, "hello");
943        app.world
944            .resource_mut::<Messages<TextEditRequest>>()
945            .write(TextEditRequest::Replace {
946                entity: e,
947                range: 1..4,
948                text: Arc::from("XYZ"),
949            });
950        let mut sched = bevy_ecs::schedule::Schedule::default();
951        sched.add_systems(text_apply_edits);
952        sched.run(&mut app.world);
953        let buf = app.world.entity(e).get::<TextBuffer>().unwrap();
954        assert_eq!(buf.to_string(), "hXYZo");
955    }
956
957    #[test]
958    fn cursor_motion_word_right() {
959        let mut app = build_app();
960        let e = spawn_editable(&mut app, "hello world");
961        // Reset cursor to start.
962        {
963            let mut em = app.world.entity_mut(e);
964            let mut c = em.get_mut::<TextCursor>().unwrap();
965            c.head = TextPos::ZERO;
966            c.anchor = TextPos::ZERO;
967        }
968        app.world
969            .resource_mut::<Messages<TextEditRequest>>()
970            .write(TextEditRequest::MoveCursor {
971                entity: e,
972                motion: CursorMotion::WordRight,
973                mode: MoveMode::MoveAnchor,
974            });
975        let mut sched = bevy_ecs::schedule::Schedule::default();
976        sched.add_systems(text_apply_edits);
977        sched.run(&mut app.world);
978        let c = app.world.entity(e).get::<TextCursor>().unwrap();
979        assert_eq!(c.head.byte, 5);
980    }
981
982    #[test]
983    fn ime_commit_with_replace_range_replaces() {
984        let mut app = build_app();
985        let e = spawn_editable(&mut app, "abcdef");
986        app.world
987            .resource_mut::<Messages<TextEditRequest>>()
988            .write(TextEditRequest::ImeCommit {
989                entity: e,
990                text: Arc::from("XX"),
991                replace_range: Some(2..4),
992            });
993        let mut sched = bevy_ecs::schedule::Schedule::default();
994        sched.add_systems(text_apply_edits);
995        sched.run(&mut app.world);
996        let buf = app.world.entity(e).get::<TextBuffer>().unwrap();
997        assert_eq!(buf.to_string(), "abXXef");
998    }
999
1000    #[test]
1001    fn single_line_strips_newlines_on_insert() {
1002        let mut app = build_app();
1003        let buf = TextBuffer::single_line("hi");
1004        let p = TextPos::from_byte("hi", 2);
1005        let e = app
1006            .world
1007            .spawn((
1008                TextContent(String::from("hi")),
1009                TextInput {
1010                    placeholder: String::new(),
1011                    cursor: 2,
1012                    selection_anchor: None,
1013                    multiline: false,
1014                },
1015                buf,
1016                TextCursor {
1017                    head: p,
1018                    anchor: p,
1019                    affinity: Affinity::default(),
1020                    goal_x: None,
1021                },
1022                UndoStack::default(),
1023            ))
1024            .id();
1025        app.world
1026            .resource_mut::<Messages<TextEditRequest>>()
1027            .write(TextEditRequest::Insert {
1028                entity: e,
1029                at: p,
1030                text: Arc::from("a\nb"),
1031            });
1032        let mut sched = bevy_ecs::schedule::Schedule::default();
1033        sched.add_systems(text_apply_edits);
1034        sched.run(&mut app.world);
1035        let buf = app.world.entity(e).get::<TextBuffer>().unwrap();
1036        assert_eq!(buf.to_string(), "hia b");
1037    }
1038
1039    #[test]
1040    fn extend_selection_keeps_anchor() {
1041        let mut app = build_app();
1042        let e = spawn_editable(&mut app, "hello world");
1043        let mut sched = bevy_ecs::schedule::Schedule::default();
1044        sched.add_systems(text_apply_edits);
1045        // Set the caret to byte 7, then extend to byte 2: the anchor
1046        // must stay at 7 (Select would have sorted it to the low end).
1047        app.world
1048            .resource_mut::<Messages<TextEditRequest>>()
1049            .write(TextEditRequest::SetCursor {
1050                entity: e,
1051                pos: TextPos::from_byte("hello world", 7),
1052            });
1053        sched.run(&mut app.world);
1054        app.world.resource_mut::<Messages<TextEditRequest>>().write(
1055            TextEditRequest::ExtendSelection {
1056                entity: e,
1057                pos: TextPos::from_byte("hello world", 2),
1058            },
1059        );
1060        sched.run(&mut app.world);
1061        let c = app.world.entity(e).get::<TextCursor>().unwrap();
1062        assert_eq!(c.anchor.byte, 7);
1063        assert_eq!(c.head.byte, 2);
1064        assert_eq!(c.selection_range(), Some(2..7));
1065    }
1066
1067    #[test]
1068    fn undo_then_redo_restores_state() {
1069        let mut app = build_app();
1070        let e = spawn_editable(&mut app, "hello");
1071        // Insert
1072        app.world
1073            .resource_mut::<Messages<TextEditRequest>>()
1074            .write(TextEditRequest::Insert {
1075                entity: e,
1076                at: TextPos {
1077                    byte: 5,
1078                    grapheme: 5,
1079                },
1080                text: Arc::from("!"),
1081            });
1082        let mut sched = bevy_ecs::schedule::Schedule::default();
1083        sched.add_systems(text_apply_edits);
1084        sched.run(&mut app.world);
1085        assert_eq!(
1086            app.world.entity(e).get::<TextBuffer>().unwrap().to_string(),
1087            "hello!"
1088        );
1089        // Undo
1090        app.world
1091            .resource_mut::<Messages<TextEditRequest>>()
1092            .write(TextEditRequest::Undo { entity: e });
1093        sched.run(&mut app.world);
1094        assert_eq!(
1095            app.world.entity(e).get::<TextBuffer>().unwrap().to_string(),
1096            "hello"
1097        );
1098        // Redo
1099        app.world
1100            .resource_mut::<Messages<TextEditRequest>>()
1101            .write(TextEditRequest::Redo { entity: e });
1102        sched.run(&mut app.world);
1103        assert_eq!(
1104            app.world.entity(e).get::<TextBuffer>().unwrap().to_string(),
1105            "hello!"
1106        );
1107    }
1108
1109    // --- D5 / D6: goal-column + visual-line motion -------------------------
1110
1111    use crate::{GlyphPosition, ShapedRun, ShapedSegment, TextGeometry};
1112
1113    fn vglyph(bs: u32, be: u32, x: f32, y: f32) -> GlyphPosition {
1114        GlyphPosition {
1115            id: 0,
1116            x,
1117            y,
1118            advance: 10.0,
1119            byte_start: bs,
1120            byte_end: be,
1121        }
1122    }
1123
1124    fn geom_of(glyphs: Vec<GlyphPosition>, width: f32) -> TextGeometry {
1125        let seg = ShapedSegment {
1126            font_id: 1,
1127            font_data: std::sync::Arc::new(Vec::new()),
1128            font_index: 0,
1129            normalized_coords: Vec::new(),
1130            level: 0,
1131            glyphs: glyphs.clone(),
1132            width,
1133        };
1134        let run = ShapedRun {
1135            font_data: seg.font_data.clone(),
1136            font_index: 0,
1137            glyphs,
1138            segments: vec![seg],
1139            width,
1140        };
1141        TextGeometry::from(&run)
1142    }
1143
1144    /// Three visual lines: long (0..5), medium (5..8), long (8..13).
1145    fn three_line_geom() -> TextGeometry {
1146        geom_of(
1147            vec![
1148                vglyph(0, 1, 0.0, 0.0),
1149                vglyph(1, 2, 10.0, 0.0),
1150                vglyph(2, 3, 20.0, 0.0),
1151                vglyph(3, 4, 30.0, 0.0),
1152                vglyph(4, 5, 40.0, 0.0),
1153                vglyph(5, 6, 0.0, 19.2),
1154                vglyph(6, 7, 10.0, 19.2),
1155                vglyph(7, 8, 20.0, 19.2),
1156                vglyph(8, 9, 0.0, 38.4),
1157                vglyph(9, 10, 10.0, 38.4),
1158                vglyph(10, 11, 20.0, 38.4),
1159                vglyph(11, 12, 30.0, 38.4),
1160                vglyph(12, 13, 40.0, 38.4),
1161            ],
1162            50.0,
1163        )
1164    }
1165
1166    /// D5: the sticky goal-x is kept across consecutive vertical moves, so a
1167    /// long -> medium -> long descent lands back on the original column.
1168    #[test]
1169    fn goal_x_preserved_across_vertical_moves() {
1170        let g = three_line_geom();
1171        let buf = TextBuffer::multi_line("aaaaabbbccccc"); // 13 bytes
1172        // Head at byte 4 (x = 40, end of line 0), no goal yet.
1173        let cur = TextCursor {
1174            head: TextPos::from_byte("aaaaabbbccccc", 4),
1175            ..Default::default()
1176        };
1177        let (p1, goal1) = move_cursor_visual(&g, &buf, cur, CursorMotion::LineDown);
1178        // Line 1 is short (max x 30): the column clamps, but the goal keeps 40.
1179        assert_eq!(goal1, Some(40.0));
1180        // Descend again with the preserved goal: land back at x = 40 (byte 12).
1181        let cur2 = TextCursor {
1182            head: p1,
1183            goal_x: goal1,
1184            ..Default::default()
1185        };
1186        let (p2, goal2) = move_cursor_visual(&g, &buf, cur2, CursorMotion::LineDown);
1187        assert_eq!(goal2, Some(40.0));
1188        assert_eq!(p2.byte, 12);
1189        assert_eq!(g.caret_xy(p2.byte).0, 40.0);
1190    }
1191
1192    /// D5: with the goal cleared (as a horizontal motion / edit does in the
1193    /// router) the next vertical move re-seeds from the caret's CURRENT x, so
1194    /// it lands on a different byte than a stale goal would.
1195    #[test]
1196    fn goal_x_reset_reseeds_from_current_column() {
1197        let g = three_line_geom();
1198        let buf = TextBuffer::multi_line("aaaaabbbccccc");
1199        let at2 = TextPos::from_byte("aaaaabbbccccc", 2); // x = 20 on line 0
1200        // Cleared goal -> re-seed from x = 20 -> lands on line 1 byte 7.
1201        let cleared = TextCursor {
1202            head: at2,
1203            goal_x: None,
1204            ..Default::default()
1205        };
1206        let (p_reset, _) = move_cursor_visual(&g, &buf, cleared, CursorMotion::LineDown);
1207        // Stale goal of 40 -> lands on line 1 byte 8 instead.
1208        let stale = TextCursor {
1209            head: at2,
1210            goal_x: Some(40.0),
1211            ..Default::default()
1212        };
1213        let (p_stale, _) = move_cursor_visual(&g, &buf, stale, CursorMotion::LineDown);
1214        assert_eq!(p_reset.byte, 7);
1215        assert_eq!(p_stale.byte, 8);
1216        assert_ne!(p_reset.byte, p_stale.byte);
1217    }
1218
1219    /// Two visual lines from ONE logical line (soft wrap, no '\n').
1220    fn wrapped_geom() -> TextGeometry {
1221        geom_of(
1222            vec![
1223                vglyph(0, 1, 0.0, 0.0),
1224                vglyph(1, 2, 10.0, 0.0),
1225                vglyph(2, 3, 20.0, 0.0),
1226                vglyph(3, 4, 30.0, 0.0),
1227                vglyph(4, 5, 40.0, 0.0),
1228                vglyph(5, 6, 0.0, 19.2),
1229                vglyph(6, 7, 10.0, 19.2),
1230                vglyph(7, 8, 20.0, 19.2),
1231                vglyph(8, 9, 30.0, 19.2),
1232                vglyph(9, 10, 40.0, 19.2),
1233            ],
1234            50.0,
1235        )
1236    }
1237
1238    /// D6: End/Home snap to the VISUAL line bounds (soft-wrap aware), not the
1239    /// logical `\n` line, and Up/Down cross the soft-wrap boundary.
1240    #[test]
1241    fn visual_line_bounds_and_cross_wrap() {
1242        let g = wrapped_geom();
1243        let buf = TextBuffer::multi_line("aaaaabbbbb"); // 10 bytes, one logical line
1244        let text = "aaaaabbbbb";
1245        // End from byte 2 (visual line 0) -> byte 5, the WRAP point, not the
1246        // logical end (10).
1247        let (end0, _) = move_cursor_visual(
1248            &g,
1249            &buf,
1250            TextCursor {
1251                head: TextPos::from_byte(text, 2),
1252                ..Default::default()
1253            },
1254            CursorMotion::LineEnd,
1255        );
1256        assert_eq!(end0.byte, 5);
1257        // Home from byte 7 (visual line 1) -> byte 5, the visual line start.
1258        let (home1, _) = move_cursor_visual(
1259            &g,
1260            &buf,
1261            TextCursor {
1262                head: TextPos::from_byte(text, 7),
1263                ..Default::default()
1264            },
1265            CursorMotion::LineStart,
1266        );
1267        assert_eq!(home1.byte, 5);
1268        // Up from byte 7 (line 1, x = 20) crosses onto line 0 at the same
1269        // column (byte 2).
1270        let (up, _) = move_cursor_visual(
1271            &g,
1272            &buf,
1273            TextCursor {
1274                head: TextPos::from_byte(text, 7),
1275                ..Default::default()
1276            },
1277            CursorMotion::LineUp,
1278        );
1279        assert_eq!(g.visual_line_of_byte(up.byte), 0);
1280        assert_eq!(up.byte, 2);
1281    }
1282}
1283
1284#[cfg(test)]
1285mod grapheme_motion_tests {
1286    //! Arrow motion, selection extension, and forward Delete step one
1287    //! extended grapheme cluster (Qt `previousCursorPosition` /
1288    //! `nextCursorPosition`, Slint `GraphemeCursor`). Backspace keeps
1289    //! stepping one code point, which both references also do; the
1290    //! asymmetry is deliberate and pinned here.
1291    use super::*;
1292    use lumen_core::text_model::{TextBuffer, TextCursor, TextPos};
1293
1294    /// "e" + U+0301 COMBINING ACUTE ACCENT: 1 + 2 bytes, one cluster.
1295    const COMBINING: &str = "e\u{0301}";
1296    /// U+1F1E6 U+1F1E7 regional indicators: 4 + 4 bytes, one flag cluster.
1297    const FLAG: &str = "\u{1F1E6}\u{1F1E7}";
1298    /// Family emoji: five scalars joined by ZWJ, one cluster.
1299    const ZWJ: &str = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}";
1300    /// Devanagari "ki": consonant + vowel sign, one cluster.
1301    const DEVANAGARI: &str = "\u{0915}\u{093F}";
1302
1303    fn buf_of(s: &str) -> TextBuffer {
1304        TextBuffer::from(s)
1305    }
1306
1307    fn right(text: &str, from: usize) -> usize {
1308        move_cursor(
1309            &buf_of(text),
1310            TextPos::from_byte(text, from),
1311            CursorMotion::CharRight,
1312        )
1313        .byte
1314    }
1315
1316    fn left(text: &str, from: usize) -> usize {
1317        move_cursor(
1318            &buf_of(text),
1319            TextPos::from_byte(text, from),
1320            CursorMotion::CharLeft,
1321        )
1322        .byte
1323    }
1324
1325    #[test]
1326    fn arrow_right_clears_a_whole_cluster() {
1327        for s in [COMBINING, FLAG, ZWJ, DEVANAGARI] {
1328            assert_eq!(right(s, 0), s.len(), "Right must cross all of {s:?}");
1329        }
1330    }
1331
1332    #[test]
1333    fn arrow_left_clears_a_whole_cluster() {
1334        for s in [COMBINING, FLAG, ZWJ, DEVANAGARI] {
1335            assert_eq!(left(s, s.len()), 0, "Left must cross all of {s:?}");
1336        }
1337    }
1338
1339    #[test]
1340    fn arrow_round_trip_returns_to_the_start_byte() {
1341        for s in [COMBINING, FLAG, ZWJ, DEVANAGARI] {
1342            let text = format!("a{s}b");
1343            let after_a = 1;
1344            let crossed = right(&text, after_a);
1345            assert_eq!(crossed, 1 + s.len());
1346            assert_eq!(left(&text, crossed), after_a);
1347        }
1348    }
1349
1350    #[test]
1351    fn arrow_never_parks_inside_a_cluster() {
1352        let text = format!("{ZWJ}x");
1353        assert_eq!(right(&text, 0), ZWJ.len());
1354        assert_eq!(right(&text, ZWJ.len()), text.len());
1355    }
1356
1357    #[test]
1358    fn forward_delete_removes_the_whole_cluster() {
1359        for s in [COMBINING, FLAG, ZWJ, DEVANAGARI] {
1360            let text = format!("{s}z");
1361            let mut buf = buf_of(&text);
1362            let mut cur = TextCursor::default();
1363            let mut undo = UndoStack::default();
1364            // Forward Delete resolves its range through CharRight.
1365            let end = move_cursor(&buf, cur.head, CursorMotion::CharRight).byte;
1366            delete_range(&mut buf, &mut cur, &mut undo, 0..end);
1367            assert_eq!(buf.to_string(), "z", "Delete left a remnant of {s:?}");
1368        }
1369    }
1370
1371    #[test]
1372    fn backspace_still_peels_one_code_point() {
1373        // Deliberate asymmetry: Qt's `backspace()` and Slint's
1374        // `PreviousCharacter` both step one code point so a combining
1375        // mark can be removed from its base character.
1376        assert_eq!(prev_code_point_boundary(COMBINING, COMBINING.len()), 1);
1377        assert_eq!(prev_code_point_boundary(ZWJ, ZWJ.len()), ZWJ.len() - 4);
1378        assert_eq!(prev_code_point_boundary(FLAG, FLAG.len()), 4);
1379    }
1380
1381    #[test]
1382    fn shift_arrow_extends_by_the_same_cluster_boundaries() {
1383        let text = format!("{FLAG}{COMBINING}");
1384        let buf = buf_of(&text);
1385        let mut cur = TextCursor::default();
1386        // Shift+Right twice: the head crosses the flag, then the
1387        // combining pair, while the anchor stays at 0.
1388        for expected in [FLAG.len(), text.len()] {
1389            let p = move_cursor(&buf, cur.head, CursorMotion::CharRight);
1390            cur.move_head(p, true);
1391            assert_eq!(cur.head.byte, expected);
1392            assert_eq!(cur.anchor.byte, 0);
1393        }
1394        assert_eq!(cur.selection_range(), Some(0..text.len()));
1395        // Shift+Left walks the same boundaries back.
1396        for expected in [FLAG.len(), 0] {
1397            let p = move_cursor(&buf, cur.head, CursorMotion::CharLeft);
1398            cur.move_head(p, true);
1399            assert_eq!(cur.head.byte, expected);
1400        }
1401        assert_eq!(cur.selection_range(), None);
1402    }
1403
1404    /// Plain ASCII must still step exactly one byte at a time. Cluster
1405    /// segmentation is only supposed to change the answer where a cluster
1406    /// spans more than one scalar; if it ever skipped ahead on ordinary
1407    /// text the caret would jump over characters.
1408    #[test]
1409    fn ascii_steps_one_byte_at_a_time() {
1410        let text = "hello world";
1411        let mut at = 0usize;
1412        for expected in 1..=text.len() {
1413            at = right(text, at);
1414            assert_eq!(at, expected, "Right over ASCII skipped a character");
1415        }
1416        for expected in (0..text.len()).rev() {
1417            at = left(text, at);
1418            assert_eq!(at, expected, "Left over ASCII skipped a character");
1419        }
1420    }
1421
1422    /// A newline is its own cluster, so crossing a line break is one step
1423    /// and never swallows the first character of the next line.
1424    #[test]
1425    fn a_newline_is_one_step() {
1426        let text = "ab\ncd";
1427        assert_eq!(right(text, 2), 3, "Right at the line end skipped the \\n");
1428        assert_eq!(right(text, 3), 4);
1429        assert_eq!(left(text, 3), 2);
1430    }
1431
1432    #[test]
1433    fn motion_snaps_a_mid_cluster_start_byte() {
1434        // A byte parked inside the ZWJ sequence still lands on a real
1435        // cluster edge rather than splitting a scalar.
1436        let b = next_grapheme_boundary(ZWJ, 4);
1437        assert_eq!(b, ZWJ.len());
1438        assert_eq!(prev_grapheme_boundary(ZWJ, 4), 0);
1439    }
1440}