Skip to main content

azul_layout/text3/
edit.rs

1//! Pure functions for editing a `Vec<InlineContent>` based on selections.
2//!
3//! Entry points: [`edit_text`] (single edit, multiple cursors),
4//! [`edit_text_multi`] (per-cursor text), and [`inspect_delete`]
5//! (preview what a delete would remove).
6
7use azul_core::selection::{
8    CursorAffinity, GraphemeClusterId, Selection, SelectionRange, TextCursor,
9};
10
11use crate::text3::cache::{InlineContent, StyledRun};
12
13/// An enum representing a single text editing action.
14#[derive(Debug, Clone)]
15pub enum TextEdit {
16    /// Insert the given string at the cursor position.
17    Insert(String),
18    /// Delete one grapheme cluster before the cursor (Backspace).
19    DeleteBackward,
20    /// Delete one grapheme cluster after the cursor (Delete key).
21    DeleteForward,
22}
23
24const fn selection_start_run(selection: &Selection) -> u32 {
25    match selection {
26        Selection::Cursor(c) => c.cluster_id.source_run,
27        Selection::Range(r) => r.start.cluster_id.source_run,
28    }
29}
30
31const fn selection_start_byte(selection: &Selection) -> u32 {
32    match selection {
33        Selection::Cursor(c) => c.cluster_id.start_byte_in_run,
34        Selection::Range(r) => r.start.cluster_id.start_byte_in_run,
35    }
36}
37
38/// Sorts selections from the end of the document to the beginning so that
39/// applying an edit at one selection does not invalidate the byte offsets of
40/// selections still to be processed.
41fn sort_selections_back_to_front(selections: &[Selection]) -> Vec<Selection> {
42    let mut sorted = selections.to_vec();
43    sorted.sort_by(|a, b| {
44        let cursor_a = match a {
45            Selection::Cursor(c) => c,
46            Selection::Range(r) => &r.start,
47        };
48        let cursor_b = match b {
49            Selection::Cursor(c) => c,
50            Selection::Range(r) => &r.start,
51        };
52        cursor_b.cluster_id.cmp(&cursor_a.cluster_id) // Reverse sort
53    });
54    sorted
55}
56
57/// Shifts every already-processed cursor sitting at or after `edit_byte` in
58/// `edit_run` by `byte_offset_change`, clamping to zero.
59#[allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)] // bounded layout/render numeric cast
60fn adjust_cursors(
61    selections: &mut [Selection],
62    edit_run: u32,
63    edit_byte: u32,
64    byte_offset_change: i32,
65) {
66    for sel in selections.iter_mut() {
67        if let Selection::Cursor(cursor) = sel {
68            if cursor.cluster_id.source_run == edit_run
69                && cursor.cluster_id.start_byte_in_run >= edit_byte
70            {
71                cursor.cluster_id.start_byte_in_run =
72                    (cursor.cluster_id.start_byte_in_run as i32 + byte_offset_change).max(0) as u32;
73            }
74        }
75    }
76}
77
78/// Shifts the `source_run` index of every already-processed cursor that sits in a
79/// run AFTER `boundary_run`, by `run_count_change` (negative when runs were removed
80/// or merged), clamping so it never drops to or below the surviving boundary run.
81///
82/// Needed because edits do not only change byte offsets within a run — a multi-run
83/// delete (or a cross-run backspace/forward-delete, or removing an inline image)
84/// changes the NUMBER of runs. Since cursors are processed back-to-front, a
85/// previously-processed (later-in-document) cursor whose run comes after the edit
86/// would otherwise keep a stale `source_run` pointing one-or-more runs too high —
87/// landing on the wrong run or going out of bounds entirely.
88#[allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)] // bounded layout/render numeric cast
89fn adjust_cursor_runs(selections: &mut [Selection], boundary_run: u32, run_count_change: i32) {
90    if run_count_change == 0 {
91        return;
92    }
93    for sel in selections.iter_mut() {
94        if let Selection::Cursor(cursor) = sel {
95            if cursor.cluster_id.source_run > boundary_run {
96                let shifted = (cursor.cluster_id.source_run as i32 + run_count_change)
97                    .max(boundary_run as i32);
98                cursor.cluster_id.source_run = shifted as u32;
99            }
100        }
101    }
102}
103
104/// Byte length of the text in the run at `run_idx`, or 0 for non-text / missing runs.
105fn run_text_len(content: &[InlineContent], run_idx: u32) -> usize {
106    match content.get(run_idx as usize) {
107        Some(InlineContent::Text(run)) => run.text.len(),
108        _ => 0,
109    }
110}
111
112/// The primary entry point for text modification. Takes the current content and selections,
113/// applies an edit, and returns the new content and the resulting cursor positions.
114#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] // bounded layout/render numeric cast
115#[must_use] pub fn edit_text(
116    content: &[InlineContent],
117    selections: &[Selection],
118    edit: &TextEdit,
119) -> (Vec<InlineContent>, Vec<Selection>) {
120    if selections.is_empty() {
121        return (content.to_vec(), Vec::new());
122    }
123
124    let mut new_content = content.to_vec();
125    let mut new_selections = Vec::new();
126
127    // To handle multiple cursors correctly, we must process edits
128    // from the end of the document to the beginning. This ensures that
129    // earlier edits do not invalidate the indices of later edits.
130    let sorted_selections = sort_selections_back_to_front(selections);
131
132    for selection in sorted_selections {
133        let edit_run = selection_start_run(&selection);
134        let edit_byte = selection_start_byte(&selection);
135
136        // Measure the affected run before and after the edit so we can shift
137        // previously-processed cursors by the ACTUAL byte delta. The old code
138        // hardcoded -1 for any delete, which mis-tracked multi-byte graphemes.
139        let old_run_len = run_text_len(&new_content, edit_run);
140        let old_run_count = new_content.len();
141        let (temp_content, new_cursor) =
142            apply_edit_to_selection(&new_content, &selection, edit);
143        let new_run_len = run_text_len(&temp_content, edit_run);
144        let byte_offset_change = new_run_len as i32 - old_run_len as i32;
145        let run_count_change = temp_content.len() as i32 - old_run_count as i32;
146
147        // Adjust all previously-processed cursors in the same run that come after this position
148        adjust_cursors(&mut new_selections, edit_run, edit_byte, byte_offset_change);
149        // If the edit changed the run COUNT (multi-run delete / cross-run delete /
150        // image removal), reindex later cursors whose run sits after this edit.
151        adjust_cursor_runs(&mut new_selections, edit_run, run_count_change);
152
153        new_content = temp_content;
154        new_selections.push(Selection::Cursor(new_cursor));
155    }
156
157    // The new selections were added in reverse order, so we reverse them back.
158    new_selections.reverse();
159
160    (new_content, new_selections)
161}
162
163/// Applies a single edit to a single selection.
164///
165/// When the selection is a Range:
166/// - `Insert`: deletes the range, then inserts text at the collapsed cursor
167/// - `DeleteBackward`/`DeleteForward`: deletes the range ONLY (the range
168///   deletion replaces the character-level delete — pressing Backspace with
169///   a selection should remove the selection, not the selection + 1 char)
170#[must_use] pub fn apply_edit_to_selection(
171    content: &[InlineContent],
172    selection: &Selection,
173    edit: &TextEdit,
174) -> (Vec<InlineContent>, TextCursor) {
175    let mut new_content = content.to_vec();
176
177    match selection {
178        Selection::Range(range) => {
179            // Delete the range first
180            let (content_after_delete, cursor_pos) = delete_range(&new_content, range);
181            match edit {
182                // Insert: replace the deleted range with new text
183                TextEdit::Insert(text_to_insert) => {
184                    let mut c = content_after_delete;
185                    insert_text(&c, &cursor_pos, text_to_insert)
186                }
187                // Delete: range deletion is sufficient — don't delete again
188                TextEdit::DeleteBackward | TextEdit::DeleteForward => {
189                    (content_after_delete, cursor_pos)
190                }
191            }
192        }
193        Selection::Cursor(cursor) => {
194            match edit {
195                TextEdit::Insert(text_to_insert) => {
196                    insert_text(&new_content, cursor, text_to_insert)
197                }
198                TextEdit::DeleteBackward => delete_backward(&new_content, cursor),
199                TextEdit::DeleteForward => delete_forward(&new_content, cursor),
200            }
201        }
202    }
203}
204
205/// Absolute byte offset of a cursor within its run's text, honoring affinity.
206///
207/// `Leading` = at the start of the referenced grapheme cluster; `Trailing` =
208/// after it. This mirrors the affinity handling in `insert_text` /
209/// `delete_backward` / `delete_forward`, and is what lets a select-all range
210/// (whose end cursor is `Trailing` on the last cluster) cover the whole text.
211pub(crate) fn cursor_byte_offset_in_run(text: &str, cursor: &TextCursor) -> usize {
212    use unicode_segmentation::UnicodeSegmentation;
213    let csb = cursor.cluster_id.start_byte_in_run as usize;
214    match cursor.affinity {
215        CursorAffinity::Leading => csb.min(text.len()),
216        CursorAffinity::Trailing => {
217            if csb >= text.len() {
218                text.len()
219            } else {
220                text[csb..]
221                    .grapheme_indices(true)
222                    .next()
223                    .map_or(text.len(), |(_, g)| csb + g.len())
224            }
225        }
226    }
227}
228
229/// Deletes the content within a given range.
230///
231/// Handles:
232/// - Deletions within a single text run.
233/// - Deletions spanning multiple runs: the start/end runs are truncated, the
234///   runs strictly between them are dropped, and the two truncated runs are
235///   merged when they share the same style.
236///
237/// Non-text items (images, etc.) at the boundaries are left intact (their text
238/// offset resolves to 0), while intermediate non-text items are dropped along
239/// with the rest of the spanned content.
240#[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
241#[must_use] pub fn delete_range(
242    content: &[InlineContent],
243    range: &SelectionRange,
244) -> (Vec<InlineContent>, TextCursor) {
245    let mut new_content = content.to_vec();
246    let start_run_idx = range.start.cluster_id.source_run as usize;
247    let end_run_idx = range.end.cluster_id.source_run as usize;
248
249    // The range may be "backward" (start after end) when the user selected
250    // right-to-left, e.g. Shift+Home or Shift+Left. Normalize to [lo, hi] so the
251    // deletion is direction-agnostic. The old `start_byte <= end_byte` guard
252    // skipped the drain for backward ranges, so Delete/Backspace (and type-to-
253    // replace) silently did nothing on such selections.
254    let mut cursor_after = range.start;
255    if start_run_idx == end_run_idx {
256        if let Some(InlineContent::Text(run)) = new_content.get_mut(start_run_idx) {
257            let a = cursor_byte_offset_in_run(&run.text, &range.start);
258            let b = cursor_byte_offset_in_run(&run.text, &range.end);
259            let lo = a.min(b);
260            let hi = a.max(b);
261            if hi <= run.text.len() && lo < hi {
262                run.text.drain(lo..hi);
263                // Collapse the caret to the start of the deleted region (the low
264                // end), regardless of the original selection direction.
265                cursor_after = TextCursor {
266                    cluster_id: GraphemeClusterId {
267                        source_run: start_run_idx as u32,
268                        start_byte_in_run: lo as u32,
269                    },
270                    affinity: CursorAffinity::Leading,
271                };
272            }
273        } else if start_run_idx < new_content.len() && range.start != range.end {
274            // The selection covers a single NON-text run (inline image / object /
275            // shape). A byte-offset drain can't remove it; delete the whole item and
276            // collapse the caret to its former index. `range.start != range.end`
277            // guards against a zero-width (collapsed) selection deleting the item.
278            new_content.remove(start_run_idx);
279            cursor_after = TextCursor {
280                cluster_id: GraphemeClusterId {
281                    source_run: start_run_idx as u32,
282                    start_byte_in_run: 0,
283                },
284                affinity: CursorAffinity::Leading,
285            };
286        }
287    } else {
288        // Multi-run deletion.
289        //
290        // Normalize direction so `lo` precedes `hi` in document order (the range
291        // may be backward if the user selected right-to-left across runs). Then:
292        //   1. truncate the start (lo) run to the text BEFORE the selection,
293        //   2. truncate the end (hi) run to the text AFTER the selection,
294        //   3. drop every run strictly between them,
295        //   4. merge the two truncated runs when they share the same style.
296        let (lo_run, lo_cursor, hi_run, hi_cursor) = if start_run_idx <= end_run_idx {
297            (start_run_idx, range.start, end_run_idx, range.end)
298        } else {
299            (end_run_idx, range.end, start_run_idx, range.start)
300        };
301
302        // Affinity-aware byte offsets within the two boundary runs. Non-text
303        // boundary runs resolve to 0 (nothing to truncate there).
304        let lo_byte = match new_content.get(lo_run) {
305            Some(InlineContent::Text(run)) => cursor_byte_offset_in_run(&run.text, &lo_cursor),
306            _ => 0,
307        };
308        let hi_byte = match new_content.get(hi_run) {
309            Some(InlineContent::Text(run)) => cursor_byte_offset_in_run(&run.text, &hi_cursor),
310            _ => 0,
311        };
312
313        // 1. Keep only text[..lo_byte] in the start run; remember the head length
314        //    (the collapse point for the caret).
315        let head_len = if let Some(InlineContent::Text(run)) = new_content.get_mut(lo_run) {
316            let cut = lo_byte.min(run.text.len());
317            run.text.truncate(cut);
318            cut
319        } else {
320            0
321        };
322
323        // 2. Keep only text[hi_byte..] in the end run.
324        if let Some(InlineContent::Text(run)) = new_content.get_mut(hi_run) {
325            let cut = hi_byte.min(run.text.len());
326            run.text.drain(..cut);
327        }
328
329        // 3. Drop the intermediate runs. After draining, the end run sits at
330        //    `lo_run + 1`. Clamp the end so a bogus out-of-range `hi_run` can
331        //    never panic the drain.
332        let drain_end = hi_run.min(new_content.len());
333        if drain_end > lo_run + 1 {
334            new_content.drain((lo_run + 1)..drain_end);
335        }
336        let tail_idx = lo_run + 1;
337
338        // 4. Merge head and tail when both are text with matching style. Compared
339        //    by value (`StyleProperties: PartialEq`) so runs that were split from
340        //    one DOM element — or otherwise carry identical styling — re-join into
341        //    a single run, while genuinely different styles stay separate.
342        let mergeable = matches!(
343            (new_content.get(lo_run), new_content.get(tail_idx)),
344            (Some(InlineContent::Text(a)), Some(InlineContent::Text(b)))
345                if a.style == b.style
346        );
347        if mergeable {
348            if let InlineContent::Text(tail) = new_content.remove(tail_idx) {
349                if let Some(InlineContent::Text(head)) = new_content.get_mut(lo_run) {
350                    head.text.push_str(&tail.text);
351                }
352            }
353        }
354
355        // Collapse the caret to the join point (start of the deleted region).
356        cursor_after = TextCursor {
357            cluster_id: GraphemeClusterId {
358                source_run: lo_run as u32,
359                start_byte_in_run: head_len as u32,
360            },
361            affinity: CursorAffinity::Leading,
362        };
363    }
364
365    (new_content, cursor_after) // caret at the start of the deleted range
366}
367
368/// Inserts text at a cursor position.
369/// 
370/// The cursor's affinity determines the exact insertion point:
371/// - `Leading`: Insert at the start of the referenced cluster (`start_byte_in_run`)
372/// - `Trailing`: Insert at the end of the referenced cluster (after the grapheme)
373#[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
374#[must_use]
375pub fn insert_text(
376    content: &[InlineContent],
377    cursor: &TextCursor,
378    text_to_insert: &str,
379) -> (Vec<InlineContent>, TextCursor) {
380    use unicode_segmentation::UnicodeSegmentation;
381    
382    let mut new_content = content.to_vec();
383    let run_idx = cursor.cluster_id.source_run as usize;
384    let cluster_start_byte = cursor.cluster_id.start_byte_in_run as usize;
385
386    if let Some(InlineContent::Text(run)) = new_content.get_mut(run_idx) {
387        // Calculate the actual insertion byte offset based on affinity
388        let byte_offset = match cursor.affinity {
389            CursorAffinity::Leading => {
390                // Insert at the start of the cluster
391                cluster_start_byte
392            },
393            CursorAffinity::Trailing => {
394                // Insert at the end of the cluster - find the next grapheme boundary
395                // We need to find where this grapheme cluster ends
396                if cluster_start_byte >= run.text.len() {
397                    // Cursor is at/past end of run - insert at end
398                    run.text.len()
399                } else {
400                    // Find the grapheme that starts at cluster_start_byte and get its end
401                    run.text[cluster_start_byte..]
402                        .grapheme_indices(true)
403                        .next()
404                        .map_or(run.text.len(), |(_, grapheme)| cluster_start_byte + grapheme.len())
405                }
406            },
407        };
408        
409        if byte_offset <= run.text.len() {
410            run.text.insert_str(byte_offset, text_to_insert);
411
412            let new_cursor = TextCursor {
413                cluster_id: GraphemeClusterId {
414                    source_run: run_idx as u32,
415                    start_byte_in_run: (byte_offset + text_to_insert.len()) as u32,
416                },
417                affinity: CursorAffinity::Leading,
418            };
419            return (new_content, new_cursor);
420        }
421    }
422
423    // If insertion failed, return original state
424    (content.to_vec(), *cursor)
425}
426
427/// Deletes one grapheme cluster backward from the cursor.
428/// 
429/// The cursor's affinity determines the actual cursor position:
430/// - `Leading`: Cursor is at start of cluster, delete the previous grapheme
431/// - `Trailing`: Cursor is at end of cluster, delete the current grapheme
432#[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
433#[allow(clippy::too_many_lines)] // cohesive grapheme-deletion routine: one branch per cursor affinity
434#[must_use]
435pub fn delete_backward(
436    content: &[InlineContent],
437    cursor: &TextCursor,
438) -> (Vec<InlineContent>, TextCursor) {
439    use unicode_segmentation::UnicodeSegmentation;
440    let mut new_content = content.to_vec();
441    let run_idx = cursor.cluster_id.source_run as usize;
442    let cluster_start_byte = cursor.cluster_id.start_byte_in_run as usize;
443
444    // Non-text run (inline image / object / shape) under the cursor. A grapheme
445    // drain can't act on it, so handle it explicitly instead of silently no-op'ing.
446    if new_content.get(run_idx).is_some()
447        && !matches!(new_content.get(run_idx), Some(InlineContent::Text(_)))
448    {
449        return match cursor.affinity {
450            // Caret sits AFTER the item — Backspace removes the item itself.
451            CursorAffinity::Trailing => {
452                new_content.remove(run_idx);
453                (
454                    new_content,
455                    TextCursor {
456                        cluster_id: GraphemeClusterId {
457                            source_run: run_idx as u32,
458                            start_byte_in_run: 0,
459                        },
460                        affinity: CursorAffinity::Leading,
461                    },
462                )
463            }
464            // Caret sits BEFORE the item — Backspace acts on the previous run.
465            CursorAffinity::Leading if run_idx > 0 => {
466                let prev_byte = match content.get(run_idx - 1) {
467                    Some(InlineContent::Text(r)) => r.text.len() as u32,
468                    _ => 0,
469                };
470                delete_backward(
471                    content,
472                    &TextCursor {
473                        cluster_id: GraphemeClusterId {
474                            source_run: (run_idx - 1) as u32,
475                            start_byte_in_run: prev_byte,
476                        },
477                        affinity: CursorAffinity::Trailing,
478                    },
479                )
480            }
481            CursorAffinity::Leading => (content.to_vec(), *cursor),
482        };
483    }
484
485    if let Some(InlineContent::Text(run)) = new_content.get_mut(run_idx) {
486        // Calculate the actual cursor byte offset based on affinity
487        let byte_offset = match cursor.affinity {
488            CursorAffinity::Leading => cluster_start_byte,
489            CursorAffinity::Trailing => {
490                // Cursor is at end of cluster - find the next grapheme boundary
491                if cluster_start_byte >= run.text.len() {
492                    run.text.len()
493                } else {
494                    run.text[cluster_start_byte..]
495                        .grapheme_indices(true)
496                        .next()
497                        .map_or(run.text.len(), |(_, grapheme)| cluster_start_byte + grapheme.len())
498                }
499            },
500        };
501
502        if byte_offset > 0 {
503            let prev_grapheme_start = run.text[..byte_offset]
504                .grapheme_indices(true)
505                .next_back()
506                .map_or(0, |(i, _)| i);
507            run.text.drain(prev_grapheme_start..byte_offset);
508
509            let new_cursor = TextCursor {
510                cluster_id: GraphemeClusterId {
511                    source_run: run_idx as u32,
512                    start_byte_in_run: prev_grapheme_start as u32,
513                },
514                affinity: CursorAffinity::Leading,
515            };
516            return (new_content, new_cursor);
517        } else if run_idx > 0 {
518            // Handle deleting across run boundaries.
519            match content.get(run_idx - 1).cloned() {
520                // Previous run is text — merge the two runs.
521                Some(InlineContent::Text(prev_run)) => {
522                    let mut merged_text = prev_run.text;
523                    let new_cursor_byte_offset = merged_text.len();
524                    merged_text.push_str(&run.text);
525
526                    new_content[run_idx - 1] = InlineContent::Text(StyledRun {
527                        text: merged_text,
528                        style: prev_run.style,
529                        logical_start_byte: prev_run.logical_start_byte,
530                        source_node_id: prev_run.source_node_id,
531                    });
532                    new_content.remove(run_idx);
533
534                    let new_cursor = TextCursor {
535                        cluster_id: GraphemeClusterId {
536                            source_run: (run_idx - 1) as u32,
537                            start_byte_in_run: new_cursor_byte_offset as u32,
538                        },
539                        affinity: CursorAffinity::Leading,
540                    };
541                    return (new_content, new_cursor);
542                }
543                // Previous run is a non-text item — Backspace removes it.
544                Some(_) => {
545                    new_content.remove(run_idx - 1);
546                    let new_cursor = TextCursor {
547                        cluster_id: GraphemeClusterId {
548                            source_run: (run_idx - 1) as u32,
549                            start_byte_in_run: 0,
550                        },
551                        affinity: CursorAffinity::Leading,
552                    };
553                    return (new_content, new_cursor);
554                }
555                None => {}
556            }
557        }
558    }
559
560    (content.to_vec(), *cursor)
561}
562
563/// Deletes one grapheme cluster forward from the cursor.
564/// 
565/// The cursor's affinity determines the actual cursor position:
566/// - `Leading`: Cursor is at start of cluster, delete the current grapheme
567/// - `Trailing`: Cursor is at end of cluster, delete the next grapheme
568#[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
569#[must_use]
570pub fn delete_forward(
571    content: &[InlineContent],
572    cursor: &TextCursor,
573) -> (Vec<InlineContent>, TextCursor) {
574    use unicode_segmentation::UnicodeSegmentation;
575    let mut new_content = content.to_vec();
576    let run_idx = cursor.cluster_id.source_run as usize;
577    let cluster_start_byte = cursor.cluster_id.start_byte_in_run as usize;
578
579    // Non-text run (inline image / object / shape) under the cursor.
580    if new_content.get(run_idx).is_some()
581        && !matches!(new_content.get(run_idx), Some(InlineContent::Text(_)))
582    {
583        return match cursor.affinity {
584            // Caret sits BEFORE the item — Delete removes the item itself.
585            CursorAffinity::Leading => {
586                new_content.remove(run_idx);
587                (
588                    new_content,
589                    TextCursor {
590                        cluster_id: GraphemeClusterId {
591                            source_run: run_idx as u32,
592                            start_byte_in_run: 0,
593                        },
594                        affinity: CursorAffinity::Leading,
595                    },
596                )
597            }
598            // Caret sits AFTER the item — Delete acts on the next run.
599            CursorAffinity::Trailing if run_idx + 1 < content.len() => delete_forward(
600                content,
601                &TextCursor {
602                    cluster_id: GraphemeClusterId {
603                        source_run: (run_idx + 1) as u32,
604                        start_byte_in_run: 0,
605                    },
606                    affinity: CursorAffinity::Leading,
607                },
608            ),
609            CursorAffinity::Trailing => (content.to_vec(), *cursor),
610        };
611    }
612
613    if let Some(InlineContent::Text(run)) = new_content.get_mut(run_idx) {
614        // Calculate the actual cursor byte offset based on affinity
615        let byte_offset = match cursor.affinity {
616            CursorAffinity::Leading => cluster_start_byte,
617            CursorAffinity::Trailing => {
618                // Cursor is at end of cluster - find the next grapheme boundary
619                if cluster_start_byte >= run.text.len() {
620                    run.text.len()
621                } else {
622                    run.text[cluster_start_byte..]
623                        .grapheme_indices(true)
624                        .next()
625                        .map_or(run.text.len(), |(_, grapheme)| cluster_start_byte + grapheme.len())
626                }
627            },
628        };
629
630        if byte_offset < run.text.len() {
631            let next_grapheme_end = run.text[byte_offset..]
632                .grapheme_indices(true)
633                .nth(1)
634                .map_or(run.text.len(), |(i, _)| byte_offset + i);
635            run.text.drain(byte_offset..next_grapheme_end);
636
637            // Cursor position stays at the same byte offset but with Leading affinity
638            let new_cursor = TextCursor {
639                cluster_id: GraphemeClusterId {
640                    source_run: run_idx as u32,
641                    start_byte_in_run: byte_offset as u32,
642                },
643                affinity: CursorAffinity::Leading,
644            };
645            return (new_content, new_cursor);
646        } else if run_idx < content.len() - 1 {
647            // Handle deleting across run boundaries.
648            match content.get(run_idx + 1).cloned() {
649                // Next run is text — merge the two runs.
650                Some(InlineContent::Text(next_run)) => {
651                    let mut merged_text = run.text.clone();
652                    merged_text.push_str(&next_run.text);
653
654                    new_content[run_idx] = InlineContent::Text(StyledRun {
655                        text: merged_text,
656                        style: run.style.clone(),
657                        logical_start_byte: run.logical_start_byte,
658                        source_node_id: run.source_node_id,
659                    });
660                    new_content.remove(run_idx + 1);
661
662                    return (new_content, *cursor);
663                }
664                // Next run is a non-text item — Delete removes it.
665                Some(_) => {
666                    new_content.remove(run_idx + 1);
667                    return (new_content, *cursor);
668                }
669                None => {}
670            }
671        }
672    }
673
674    (content.to_vec(), *cursor)
675}
676
677/// Edit text with different text per selection (for N-lines-to-N-cursors paste).
678///
679/// Each selection gets its own text inserted. Selections are processed back-to-front
680/// to avoid index invalidation. Returns the new content and updated cursors.
681///
682/// # Panics
683///
684/// Panics if `texts.len() != selections.len()`.
685#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] // bounded layout/render numeric cast
686#[must_use] pub fn edit_text_multi(
687    content: &[InlineContent],
688    selections: &[Selection],
689    texts: &[&str],
690) -> (Vec<InlineContent>, Vec<Selection>) {
691    assert_eq!(
692        selections.len(),
693        texts.len(),
694        "edit_text_multi: selections and texts must have the same length"
695    );
696
697    if selections.is_empty() {
698        return (content.to_vec(), Vec::new());
699    }
700
701    let mut new_content = content.to_vec();
702    let mut new_selections = Vec::new();
703
704    // Pair selections with their text, sort back-to-front
705    let mut pairs: Vec<(Selection, &str)> = selections
706        .iter()
707        .copied()
708        .zip(texts.iter().copied())
709        .collect();
710    pairs.sort_by(|a, b| {
711        let cursor_a = match &a.0 {
712            Selection::Cursor(c) => c,
713            Selection::Range(r) => &r.start,
714        };
715        let cursor_b = match &b.0 {
716            Selection::Cursor(c) => c,
717            Selection::Range(r) => &r.start,
718        };
719        cursor_b.cluster_id.cmp(&cursor_a.cluster_id) // Reverse sort
720    });
721
722    for (selection, text) in &pairs {
723        let edit = TextEdit::Insert((*text).to_string());
724
725        let edit_run = selection_start_run(selection);
726        let edit_byte = selection_start_byte(selection);
727
728        let old_run_len = run_text_len(&new_content, edit_run);
729        let old_run_count = new_content.len();
730        let (temp_content, new_cursor) =
731            apply_edit_to_selection(&new_content, selection, &edit);
732        let new_run_len = run_text_len(&temp_content, edit_run);
733        let byte_offset_change = new_run_len as i32 - old_run_len as i32;
734        let run_count_change = temp_content.len() as i32 - old_run_count as i32;
735
736        adjust_cursors(&mut new_selections, edit_run, edit_byte, byte_offset_change);
737        adjust_cursor_runs(&mut new_selections, edit_run, run_count_change);
738
739        new_content = temp_content;
740        new_selections.push(Selection::Cursor(new_cursor));
741    }
742
743    new_selections.reverse();
744    (new_content, new_selections)
745}
746
747/// Returns the range and text that a delete operation would remove, without
748/// actually modifying the content.
749///
750/// Useful for callbacks that need to inspect
751/// pending deletes. Returns `None` if nothing would be deleted.
752#[must_use] pub fn inspect_delete(
753    content: &[InlineContent],
754    selection: &Selection,
755    forward: bool,
756) -> Option<(SelectionRange, String)> {
757    match selection {
758        Selection::Range(range) => {
759            // If there's already a selection, that's what would be deleted
760            let deleted_text = extract_text_in_range(content, range);
761            Some((*range, deleted_text))
762        }
763        Selection::Cursor(cursor) => {
764            // No selection - would delete one grapheme cluster
765            if forward {
766                inspect_delete_forward(content, cursor)
767            } else {
768                inspect_delete_backward(content, cursor)
769            }
770        }
771    }
772}
773
774/// Inspect what would be deleted by delete-forward (Delete key)
775#[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
776fn inspect_delete_forward(
777    content: &[InlineContent],
778    cursor: &TextCursor,
779) -> Option<(SelectionRange, String)> {
780    use unicode_segmentation::UnicodeSegmentation;
781
782    let run_idx = cursor.cluster_id.source_run as usize;
783
784    if let Some(InlineContent::Text(run)) = content.get(run_idx) {
785        // Honor cursor affinity, mirroring delete_forward — a Trailing cursor
786        // sits after its grapheme, so the raw start_byte_in_run is wrong here.
787        let byte_offset = cursor_byte_offset_in_run(&run.text, cursor);
788        if byte_offset < run.text.len() {
789            // Delete within same run
790            let next_grapheme_end = run.text[byte_offset..]
791                .grapheme_indices(true)
792                .nth(1)
793                .map_or(run.text.len(), |(i, _)| byte_offset + i);
794
795            let deleted_text = run.text[byte_offset..next_grapheme_end].to_string();
796
797            let range = SelectionRange {
798                start: *cursor,
799                end: TextCursor {
800                    cluster_id: GraphemeClusterId {
801                        source_run: run_idx as u32,
802                        start_byte_in_run: next_grapheme_end as u32,
803                    },
804                    affinity: CursorAffinity::Leading,
805                },
806            };
807
808            return Some((range, deleted_text));
809        } else if run_idx < content.len() - 1 {
810            // Would delete across run boundary
811            if let Some(InlineContent::Text(next_run)) = content.get(run_idx + 1) {
812                let deleted_text = next_run.text.graphemes(true).next()?.to_string();
813
814                let next_grapheme_end = next_run
815                    .text
816                    .grapheme_indices(true)
817                    .nth(1)
818                    .map_or(next_run.text.len(), |(i, _)| i);
819
820                let range = SelectionRange {
821                    start: *cursor,
822                    end: TextCursor {
823                        cluster_id: GraphemeClusterId {
824                            source_run: (run_idx + 1) as u32,
825                            start_byte_in_run: next_grapheme_end as u32,
826                        },
827                        affinity: CursorAffinity::Leading,
828                    },
829                };
830
831                return Some((range, deleted_text));
832            }
833        }
834    }
835
836    None // At end of document, nothing to delete
837}
838
839/// Inspect what would be deleted by delete-backward (Backspace key)
840#[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
841fn inspect_delete_backward(
842    content: &[InlineContent],
843    cursor: &TextCursor,
844) -> Option<(SelectionRange, String)> {
845    use unicode_segmentation::UnicodeSegmentation;
846
847    let run_idx = cursor.cluster_id.source_run as usize;
848
849    if let Some(InlineContent::Text(run)) = content.get(run_idx) {
850        // Honor cursor affinity, mirroring delete_backward — a Trailing cursor
851        // sits after its grapheme, so the raw start_byte_in_run is wrong here.
852        let byte_offset = cursor_byte_offset_in_run(&run.text, cursor);
853        if byte_offset > 0 {
854            // Delete within same run
855            let prev_grapheme_start = run.text[..byte_offset]
856                .grapheme_indices(true)
857                .next_back()
858                .map_or(0, |(i, _)| i);
859
860            let deleted_text = run.text[prev_grapheme_start..byte_offset].to_string();
861
862            let range = SelectionRange {
863                start: TextCursor {
864                    cluster_id: GraphemeClusterId {
865                        source_run: run_idx as u32,
866                        start_byte_in_run: prev_grapheme_start as u32,
867                    },
868                    affinity: CursorAffinity::Leading,
869                },
870                end: *cursor,
871            };
872
873            return Some((range, deleted_text));
874        } else if run_idx > 0 {
875            // Would delete across run boundary
876            if let Some(InlineContent::Text(prev_run)) = content.get(run_idx - 1) {
877                let deleted_text = prev_run.text.graphemes(true).next_back()?.to_string();
878
879                let prev_grapheme_start = prev_run.text[..]
880                    .grapheme_indices(true)
881                    .next_back()
882                    .map_or(0, |(i, _)| i);
883
884                let range = SelectionRange {
885                    start: TextCursor {
886                        cluster_id: GraphemeClusterId {
887                            source_run: (run_idx - 1) as u32,
888                            start_byte_in_run: prev_grapheme_start as u32,
889                        },
890                        affinity: CursorAffinity::Leading,
891                    },
892                    end: *cursor,
893                };
894
895                return Some((range, deleted_text));
896            }
897        }
898    }
899
900    None // At start of document, nothing to delete
901}
902
903/// Extract the text within a selection range
904fn extract_text_in_range(content: &[InlineContent], range: &SelectionRange) -> String {
905    let start_run = range.start.cluster_id.source_run as usize;
906    let end_run = range.end.cluster_id.source_run as usize;
907    let start_byte = range.start.cluster_id.start_byte_in_run as usize;
908    let end_byte = range.end.cluster_id.start_byte_in_run as usize;
909
910    if start_run == end_run {
911        // Single run
912        if let Some(InlineContent::Text(run)) = content.get(start_run) {
913            if start_byte <= end_byte && end_byte <= run.text.len() {
914                return run.text[start_byte..end_byte].to_string();
915            }
916        }
917    } else {
918        // Multi-run selection (simplified - full implementation would handle images, etc.)
919        let mut result = String::new();
920
921        for (idx, item) in content.iter().enumerate() {
922            if let InlineContent::Text(run) = item {
923                if idx == start_run {
924                    // First run - from start_byte to end
925                    if start_byte < run.text.len() {
926                        result.push_str(&run.text[start_byte..]);
927                    }
928                } else if idx > start_run && idx < end_run {
929                    // Middle runs - entire text
930                    result.push_str(&run.text);
931                } else if idx == end_run {
932                    // Last run - from 0 to end_byte
933                    if end_byte <= run.text.len() {
934                        result.push_str(&run.text[..end_byte]);
935                    }
936                    break;
937                }
938            }
939        }
940
941        return result;
942    }
943
944    String::new()
945}
946
947#[cfg(test)]
948#[allow(clippy::float_cmp, clippy::too_many_lines)]
949mod autotest_generated {
950    use std::sync::Arc;
951
952    use unicode_segmentation::UnicodeSegmentation;
953
954    use super::*;
955    use crate::text3::cache::StyleProperties;
956
957    // ---------------------------------------------------------------- helpers
958
959    fn style_a() -> Arc<StyleProperties> {
960        Arc::new(StyleProperties::default())
961    }
962
963    /// A style that compares unequal to [`style_a`] (`StyleProperties: PartialEq`),
964    /// so `delete_range`'s style-based run merge can be exercised both ways.
965    fn style_b() -> Arc<StyleProperties> {
966        Arc::new(StyleProperties {
967            font_size_px: 99.0,
968            ..StyleProperties::default()
969        })
970    }
971
972    fn text(s: &str) -> InlineContent {
973        InlineContent::Text(StyledRun {
974            text: s.to_string(),
975            style: style_a(),
976            logical_start_byte: 0,
977            source_node_id: None,
978        })
979    }
980
981    fn text_styled(s: &str, style: Arc<StyleProperties>) -> InlineContent {
982        InlineContent::Text(StyledRun {
983            text: s.to_string(),
984            style,
985            logical_start_byte: 0,
986            source_node_id: None,
987        })
988    }
989
990    /// A non-text inline item (stands in for an inline image / object / shape).
991    /// `Tab` is the cheapest such variant to build — it carries only a style.
992    fn obj() -> InlineContent {
993        InlineContent::Tab { style: style_a() }
994    }
995
996    /// `InlineContent` has no `PartialEq`, so compare a printable projection:
997    /// text runs render as their text, everything else as `<obj>`.
998    fn dump(content: &[InlineContent]) -> Vec<String> {
999        content
1000            .iter()
1001            .map(|c| match c {
1002                InlineContent::Text(r) => r.text.clone(),
1003                _ => "<obj>".to_string(),
1004            })
1005            .collect()
1006    }
1007
1008    fn lead(run: u32, byte: u32) -> TextCursor {
1009        TextCursor {
1010            cluster_id: GraphemeClusterId {
1011                source_run: run,
1012                start_byte_in_run: byte,
1013            },
1014            affinity: CursorAffinity::Leading,
1015        }
1016    }
1017
1018    fn trail(run: u32, byte: u32) -> TextCursor {
1019        TextCursor {
1020            cluster_id: GraphemeClusterId {
1021                source_run: run,
1022                start_byte_in_run: byte,
1023            },
1024            affinity: CursorAffinity::Trailing,
1025        }
1026    }
1027
1028    fn range_sel(start: TextCursor, end: TextCursor) -> Selection {
1029        Selection::Range(SelectionRange { start, end })
1030    }
1031
1032    fn cursor_of(sel: &Selection) -> TextCursor {
1033        match sel {
1034            Selection::Cursor(c) => *c,
1035            Selection::Range(r) => r.start,
1036        }
1037    }
1038
1039    /// A ZWJ emoji family: 👨(4) + ZWJ(3) + 👩(4) + ZWJ(3) + 👧(4) = 18 bytes,
1040    /// but exactly ONE extended grapheme cluster.
1041    const FAMILY: &str = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}";
1042
1043    #[test]
1044    fn family_constant_is_one_grapheme_of_18_bytes() {
1045        // Guards the fixture the grapheme tests below rely on.
1046        assert_eq!(FAMILY.len(), 18);
1047        assert_eq!(FAMILY.graphemes(true).count(), 1);
1048    }
1049
1050    // ------------------------------------------- selection_start_run / _byte
1051
1052    #[test]
1053    fn selection_start_run_and_byte_read_the_cursor() {
1054        let sel = Selection::Cursor(lead(3, 7));
1055        assert_eq!(selection_start_run(&sel), 3);
1056        assert_eq!(selection_start_byte(&sel), 7);
1057    }
1058
1059    #[test]
1060    fn selection_start_run_and_byte_read_range_start_not_end() {
1061        // Even for a BACKWARD range (start after end) the raw `start` is reported.
1062        let sel = range_sel(lead(9, 40), lead(1, 2));
1063        assert_eq!(selection_start_run(&sel), 9);
1064        assert_eq!(selection_start_byte(&sel), 40);
1065    }
1066
1067    #[test]
1068    fn selection_start_accessors_survive_u32_max() {
1069        let sel = Selection::Cursor(trail(u32::MAX, u32::MAX));
1070        assert_eq!(selection_start_run(&sel), u32::MAX);
1071        assert_eq!(selection_start_byte(&sel), u32::MAX);
1072
1073        let sel = range_sel(lead(u32::MAX, u32::MAX), lead(0, 0));
1074        assert_eq!(selection_start_run(&sel), u32::MAX);
1075        assert_eq!(selection_start_byte(&sel), u32::MAX);
1076    }
1077
1078    // ------------------------------------------ sort_selections_back_to_front
1079
1080    #[test]
1081    fn sort_back_to_front_empty_and_single() {
1082        assert!(sort_selections_back_to_front(&[]).is_empty());
1083        let one = [Selection::Cursor(lead(0, 0))];
1084        assert_eq!(sort_selections_back_to_front(&one).len(), 1);
1085    }
1086
1087    #[test]
1088    fn sort_back_to_front_is_descending_by_cluster_id() {
1089        let sels = [
1090            Selection::Cursor(lead(0, 0)),
1091            Selection::Cursor(lead(2, 5)),
1092            Selection::Cursor(lead(1, 3)),
1093            Selection::Cursor(lead(2, 1)),
1094        ];
1095        let sorted = sort_selections_back_to_front(&sels);
1096        let keys: Vec<(u32, u32)> = sorted
1097            .iter()
1098            .map(|s| {
1099                let c = cursor_of(s).cluster_id;
1100                (c.source_run, c.start_byte_in_run)
1101            })
1102            .collect();
1103        assert_eq!(keys, vec![(2, 5), (2, 1), (1, 3), (0, 0)]);
1104        // Monotonically non-increasing — the invariant the multi-cursor edit loop
1105        // depends on for its byte offsets to stay valid.
1106        assert!(keys.windows(2).all(|w| w[0] >= w[1]));
1107    }
1108
1109    #[test]
1110    fn sort_back_to_front_is_a_permutation_with_duplicates() {
1111        let sels = [
1112            Selection::Cursor(lead(1, 1)),
1113            Selection::Cursor(lead(1, 1)),
1114            Selection::Cursor(lead(0, 0)),
1115        ];
1116        let sorted = sort_selections_back_to_front(&sels);
1117        assert_eq!(sorted.len(), 3);
1118        let mut got: Vec<Selection> = sorted;
1119        let mut want: Vec<Selection> = sels.to_vec();
1120        got.sort();
1121        want.sort();
1122        assert_eq!(got, want);
1123    }
1124
1125    #[test]
1126    fn sort_back_to_front_keys_ranges_on_their_start() {
1127        // Range starts at run 5; the plain cursor is at run 0 -> range sorts first.
1128        let sels = [
1129            Selection::Cursor(lead(0, 0)),
1130            range_sel(lead(5, 0), lead(0, 0)),
1131        ];
1132        let sorted = sort_selections_back_to_front(&sels);
1133        assert!(matches!(sorted[0], Selection::Range(_)));
1134        assert!(matches!(sorted[1], Selection::Cursor(_)));
1135    }
1136
1137    #[test]
1138    fn sort_back_to_front_handles_u32_max_keys() {
1139        let sels = [
1140            Selection::Cursor(lead(u32::MAX, u32::MAX)),
1141            Selection::Cursor(lead(0, 0)),
1142        ];
1143        let sorted = sort_selections_back_to_front(&sels);
1144        assert_eq!(cursor_of(&sorted[0]).cluster_id.source_run, u32::MAX);
1145    }
1146
1147    // ------------------------------------------------------- adjust_cursors
1148
1149    fn byte_at(sels: &[Selection], i: usize) -> u32 {
1150        cursor_of(&sels[i]).cluster_id.start_byte_in_run
1151    }
1152
1153    #[test]
1154    fn adjust_cursors_empty_slice_is_a_noop() {
1155        let mut sels: Vec<Selection> = Vec::new();
1156        adjust_cursors(&mut sels, 0, 0, i32::MIN);
1157        assert!(sels.is_empty());
1158    }
1159
1160    #[test]
1161    fn adjust_cursors_zero_change_leaves_everything_alone() {
1162        let mut sels = vec![
1163            Selection::Cursor(lead(0, 0)),
1164            Selection::Cursor(lead(0, 10)),
1165        ];
1166        adjust_cursors(&mut sels, 0, 0, 0);
1167        assert_eq!(byte_at(&sels, 0), 0);
1168        assert_eq!(byte_at(&sels, 1), 10);
1169    }
1170
1171    #[test]
1172    fn adjust_cursors_only_shifts_at_or_after_edit_byte_in_the_edit_run() {
1173        let mut sels = vec![
1174            Selection::Cursor(lead(0, 2)),  // before edit_byte -> untouched
1175            Selection::Cursor(lead(0, 5)),  // AT edit_byte     -> shifted
1176            Selection::Cursor(lead(0, 9)),  // after edit_byte  -> shifted
1177            Selection::Cursor(lead(1, 0)),  // other run        -> untouched
1178        ];
1179        adjust_cursors(&mut sels, 0, 5, 3);
1180        assert_eq!(byte_at(&sels, 0), 2);
1181        assert_eq!(byte_at(&sels, 1), 8);
1182        assert_eq!(byte_at(&sels, 2), 12);
1183        assert_eq!(byte_at(&sels, 3), 0);
1184    }
1185
1186    #[test]
1187    fn adjust_cursors_negative_change_clamps_at_zero() {
1188        let mut sels = vec![Selection::Cursor(lead(0, 3))];
1189        adjust_cursors(&mut sels, 0, 0, -100);
1190        assert_eq!(byte_at(&sels, 0), 0, "documented clamp-to-zero");
1191    }
1192
1193    #[test]
1194    fn adjust_cursors_i32_extremes_do_not_panic() {
1195        // i32::MAX applied to byte 0 saturates the offset, not the process.
1196        let mut sels = vec![Selection::Cursor(lead(0, 0))];
1197        adjust_cursors(&mut sels, 0, 0, i32::MAX);
1198        assert_eq!(byte_at(&sels, 0), i32::MAX as u32);
1199
1200        // i32::MIN applied to byte 0 clamps to zero rather than wrapping.
1201        let mut sels = vec![Selection::Cursor(lead(0, 0))];
1202        adjust_cursors(&mut sels, 0, 0, i32::MIN);
1203        assert_eq!(byte_at(&sels, 0), 0);
1204    }
1205
1206    #[test]
1207    fn adjust_cursors_u32_max_byte_collapses_to_zero() {
1208        // NOTE (reported): `start_byte_in_run as i32` WRAPS — u32::MAX becomes -1,
1209        // so a no-op (+0) adjustment silently relocates the cursor to byte 0.
1210        // Not reachable from real 2 GiB-run content, but it is the current behavior.
1211        let mut sels = vec![Selection::Cursor(lead(0, u32::MAX))];
1212        adjust_cursors(&mut sels, 0, 0, 0);
1213        assert_eq!(byte_at(&sels, 0), 0);
1214    }
1215
1216    #[test]
1217    fn adjust_cursors_never_touches_range_selections() {
1218        let mut sels = vec![range_sel(lead(0, 4), lead(0, 8))];
1219        adjust_cursors(&mut sels, 0, 0, 100);
1220        match &sels[0] {
1221            Selection::Range(r) => {
1222                assert_eq!(r.start.cluster_id.start_byte_in_run, 4);
1223                assert_eq!(r.end.cluster_id.start_byte_in_run, 8);
1224            }
1225            Selection::Cursor(_) => panic!("range must stay a range"),
1226        }
1227    }
1228
1229    // --------------------------------------------------- adjust_cursor_runs
1230
1231    fn run_at(sels: &[Selection], i: usize) -> u32 {
1232        cursor_of(&sels[i]).cluster_id.source_run
1233    }
1234
1235    #[test]
1236    fn adjust_cursor_runs_zero_change_returns_early() {
1237        let mut sels = vec![Selection::Cursor(lead(u32::MAX, 0))];
1238        adjust_cursor_runs(&mut sels, 0, 0);
1239        assert_eq!(run_at(&sels, 0), u32::MAX, "zero change must not touch runs");
1240    }
1241
1242    #[test]
1243    fn adjust_cursor_runs_shifts_only_runs_strictly_after_the_boundary() {
1244        let mut sels = vec![
1245            Selection::Cursor(lead(0, 0)), // before boundary -> untouched
1246            Selection::Cursor(lead(1, 0)), // AT boundary     -> untouched
1247            Selection::Cursor(lead(2, 0)), // after           -> -1
1248            Selection::Cursor(lead(3, 0)), // after           -> -1
1249        ];
1250        adjust_cursor_runs(&mut sels, 1, -1);
1251        assert_eq!(run_at(&sels, 0), 0);
1252        assert_eq!(run_at(&sels, 1), 1);
1253        assert_eq!(run_at(&sels, 2), 1);
1254        assert_eq!(run_at(&sels, 3), 2);
1255    }
1256
1257    #[test]
1258    fn adjust_cursor_runs_positive_change_shifts_up() {
1259        let mut sels = vec![Selection::Cursor(lead(2, 0))];
1260        adjust_cursor_runs(&mut sels, 0, 3);
1261        assert_eq!(run_at(&sels, 0), 5);
1262    }
1263
1264    #[test]
1265    fn adjust_cursor_runs_negative_overshoot_clamps_to_the_boundary_run() {
1266        let mut sels = vec![Selection::Cursor(lead(3, 0))];
1267        adjust_cursor_runs(&mut sels, 1, -100);
1268        assert_eq!(run_at(&sels, 0), 1, "never drops below the surviving run");
1269    }
1270
1271    #[test]
1272    fn adjust_cursor_runs_i32_min_clamps_instead_of_wrapping() {
1273        // 3 + i32::MIN stays inside i32 (no overflow) and the clamp catches it.
1274        let mut sels = vec![Selection::Cursor(lead(3, 0))];
1275        adjust_cursor_runs(&mut sels, 2, i32::MIN);
1276        assert_eq!(run_at(&sels, 0), 2);
1277
1278        let mut sels = vec![Selection::Cursor(lead(1, 0))];
1279        adjust_cursor_runs(&mut sels, 0, i32::MIN);
1280        assert_eq!(run_at(&sels, 0), 0);
1281    }
1282
1283    #[test]
1284    fn adjust_cursor_runs_i32_max_is_inert_when_no_cursor_qualifies() {
1285        // Every cursor is at/below the boundary, so the huge delta is never applied.
1286        let mut sels = vec![
1287            Selection::Cursor(lead(0, 0)),
1288            Selection::Cursor(lead(5, 0)),
1289        ];
1290        adjust_cursor_runs(&mut sels, 5, i32::MAX);
1291        assert_eq!(run_at(&sels, 0), 0);
1292        assert_eq!(run_at(&sels, 1), 5);
1293    }
1294
1295    #[test]
1296    fn adjust_cursor_runs_never_touches_range_selections() {
1297        let mut sels = vec![range_sel(lead(9, 0), lead(9, 1))];
1298        adjust_cursor_runs(&mut sels, 0, -5);
1299        match &sels[0] {
1300            Selection::Range(r) => assert_eq!(r.start.cluster_id.source_run, 9),
1301            Selection::Cursor(_) => panic!("range must stay a range"),
1302        }
1303    }
1304
1305    // ---------------------------------------------------------- run_text_len
1306
1307    #[test]
1308    fn run_text_len_counts_bytes_not_chars() {
1309        let content = vec![text("héllo")]; // é is 2 bytes -> 6 bytes, 5 chars
1310        assert_eq!(run_text_len(&content, 0), 6);
1311        assert_eq!(content_text_chars(&content), 5);
1312    }
1313
1314    fn content_text_chars(content: &[InlineContent]) -> usize {
1315        content
1316            .iter()
1317            .map(|c| match c {
1318                InlineContent::Text(r) => r.text.chars().count(),
1319                _ => 0,
1320            })
1321            .sum()
1322    }
1323
1324    #[test]
1325    fn run_text_len_zero_for_empty_missing_and_non_text_runs() {
1326        let content = vec![text(""), obj()];
1327        assert_eq!(run_text_len(&content, 0), 0, "empty text run");
1328        assert_eq!(run_text_len(&content, 1), 0, "non-text run");
1329        assert_eq!(run_text_len(&content, 2), 0, "one past the end");
1330        assert_eq!(run_text_len(&content, u32::MAX), 0, "u32::MAX index");
1331        assert_eq!(run_text_len(&[], 0), 0, "empty content");
1332    }
1333
1334    // ------------------------------------------------------------- edit_text
1335
1336    #[test]
1337    fn edit_text_empty_selections_returns_content_unchanged() {
1338        let content = vec![text("hello")];
1339        let (new_content, sels) = edit_text(&content, &[], &TextEdit::Insert("x".into()));
1340        assert_eq!(dump(&new_content), vec!["hello"]);
1341        assert!(sels.is_empty());
1342    }
1343
1344    #[test]
1345    fn edit_text_on_empty_content_does_not_panic() {
1346        let (new_content, sels) = edit_text(
1347            &[],
1348            &[Selection::Cursor(lead(0, 0))],
1349            &TextEdit::Insert("x".into()),
1350        );
1351        assert!(new_content.is_empty());
1352        assert_eq!(sels.len(), 1, "the cursor survives, unmoved");
1353        assert_eq!(cursor_of(&sels[0]), lead(0, 0));
1354    }
1355
1356    #[test]
1357    fn edit_text_out_of_range_cursor_is_a_noop_not_a_panic() {
1358        let content = vec![text("hi")];
1359        let (new_content, sels) = edit_text(
1360            &content,
1361            &[Selection::Cursor(lead(u32::MAX, 0))],
1362            &TextEdit::DeleteBackward,
1363        );
1364        assert_eq!(dump(&new_content), vec!["hi"]);
1365        assert_eq!(sels.len(), 1);
1366    }
1367
1368    #[test]
1369    fn edit_text_multi_cursor_insert_keeps_both_cursors_correct() {
1370        // Two cursors in the same run: the earlier edit must shift the later cursor
1371        // by the ACTUAL byte delta (this is what adjust_cursors exists for).
1372        let content = vec![text("hello")];
1373        let sels = [
1374            Selection::Cursor(lead(0, 0)),
1375            Selection::Cursor(lead(0, 3)),
1376        ];
1377        let (new_content, new_sels) = edit_text(&content, &sels, &TextEdit::Insert("X".into()));
1378        assert_eq!(dump(&new_content), vec!["XhelXlo"]);
1379        assert_eq!(cursor_of(&new_sels[0]), lead(0, 1));
1380        assert_eq!(cursor_of(&new_sels[1]), lead(0, 5));
1381    }
1382
1383    #[test]
1384    fn edit_text_multi_cursor_insert_shifts_by_multibyte_length_not_one() {
1385        // Inserting a 4-byte emoji must move the trailing cursor by 4 bytes.
1386        let content = vec![text("ab")];
1387        let sels = [
1388            Selection::Cursor(lead(0, 0)),
1389            Selection::Cursor(lead(0, 2)),
1390        ];
1391        let (new_content, new_sels) = edit_text(&content, &sels, &TextEdit::Insert("👍".into()));
1392        assert_eq!(dump(&new_content), vec!["👍ab👍"]);
1393        assert_eq!(cursor_of(&new_sels[0]), lead(0, 4));
1394        assert_eq!(cursor_of(&new_sels[1]), lead(0, 10)); // 4 + "ab" + 4
1395    }
1396
1397    #[test]
1398    fn edit_text_backspace_with_a_range_deletes_the_range_only() {
1399        // Regression guard for the documented rule: Backspace on a selection removes
1400        // the selection, NOT the selection plus one more grapheme.
1401        let content = vec![text("hello")];
1402        let sel = [range_sel(lead(0, 1), lead(0, 3))];
1403        let (new_content, _) = edit_text(&content, &sel, &TextEdit::DeleteBackward);
1404        assert_eq!(dump(&new_content), vec!["hlo"]);
1405    }
1406
1407    // ------------------------------------------------ apply_edit_to_selection
1408
1409    #[test]
1410    fn apply_edit_range_insert_replaces_the_range() {
1411        let content = vec![text("hello")];
1412        let sel = range_sel(lead(0, 1), lead(0, 4));
1413        let (new_content, cursor) =
1414            apply_edit_to_selection(&content, &sel, &TextEdit::Insert("EY".into()));
1415        assert_eq!(dump(&new_content), vec!["hEYo"]);
1416        assert_eq!(cursor, lead(0, 3));
1417    }
1418
1419    #[test]
1420    fn apply_edit_range_delete_forward_deletes_range_only() {
1421        let content = vec![text("hello")];
1422        let sel = range_sel(lead(0, 1), lead(0, 3));
1423        let (new_content, cursor) =
1424            apply_edit_to_selection(&content, &sel, &TextEdit::DeleteForward);
1425        assert_eq!(dump(&new_content), vec!["hlo"]);
1426        assert_eq!(cursor, lead(0, 1));
1427    }
1428
1429    #[test]
1430    fn apply_edit_cursor_insert_of_empty_string_only_moves_the_caret() {
1431        let content = vec![text("hello")];
1432        let sel = Selection::Cursor(lead(0, 2));
1433        let (new_content, cursor) =
1434            apply_edit_to_selection(&content, &sel, &TextEdit::Insert(String::new()));
1435        assert_eq!(dump(&new_content), vec!["hello"]);
1436        assert_eq!(cursor, lead(0, 2));
1437    }
1438
1439    // ------------------------------------------------ cursor_byte_offset_in_run
1440
1441    #[test]
1442    fn cursor_byte_offset_leading_clamps_past_the_end() {
1443        assert_eq!(cursor_byte_offset_in_run("hi", &lead(0, 999)), 2);
1444        assert_eq!(cursor_byte_offset_in_run("hi", &lead(0, u32::MAX)), 2);
1445        assert_eq!(cursor_byte_offset_in_run("", &lead(0, 5)), 0);
1446    }
1447
1448    #[test]
1449    fn cursor_byte_offset_trailing_clamps_past_the_end() {
1450        assert_eq!(cursor_byte_offset_in_run("hi", &trail(0, 2)), 2);
1451        assert_eq!(cursor_byte_offset_in_run("hi", &trail(0, u32::MAX)), 2);
1452        assert_eq!(cursor_byte_offset_in_run("", &trail(0, 0)), 0);
1453    }
1454
1455    #[test]
1456    fn cursor_byte_offset_trailing_lands_after_the_whole_grapheme() {
1457        // Combining sequence: "e" + U+0301 is one cluster of 3 bytes.
1458        assert_eq!(cursor_byte_offset_in_run("e\u{0301}x", &trail(0, 0)), 3);
1459        // A 4-byte astral char.
1460        assert_eq!(cursor_byte_offset_in_run("👍x", &trail(0, 0)), 4);
1461        // A ZWJ emoji family is ONE cluster — a char-wise implementation would
1462        // return 4 here instead of 18.
1463        assert_eq!(cursor_byte_offset_in_run(FAMILY, &trail(0, 0)), 18);
1464    }
1465
1466    #[test]
1467    fn cursor_byte_offset_leading_is_the_raw_offset() {
1468        assert_eq!(cursor_byte_offset_in_run(FAMILY, &lead(0, 0)), 0);
1469        assert_eq!(cursor_byte_offset_in_run("abc", &lead(0, 1)), 1);
1470    }
1471
1472    // ---------------------------------------------------------- delete_range
1473
1474    #[test]
1475    fn delete_range_within_one_run() {
1476        let content = vec![text("hello")];
1477        let r = SelectionRange {
1478            start: lead(0, 1),
1479            end: lead(0, 3),
1480        };
1481        let (new_content, cursor) = delete_range(&content, &r);
1482        assert_eq!(dump(&new_content), vec!["hlo"]);
1483        assert_eq!(cursor, lead(0, 1));
1484    }
1485
1486    #[test]
1487    fn delete_range_backward_range_is_normalized() {
1488        // Right-to-left selection (Shift+Left / Shift+Home): start is AFTER end.
1489        // It must delete the same bytes as the forward range, not silently no-op.
1490        let content = vec![text("hello")];
1491        let backward = SelectionRange {
1492            start: lead(0, 3),
1493            end: lead(0, 1),
1494        };
1495        let (new_content, cursor) = delete_range(&content, &backward);
1496        assert_eq!(dump(&new_content), vec!["hlo"]);
1497        assert_eq!(cursor, lead(0, 1), "caret collapses to the LOW end");
1498    }
1499
1500    #[test]
1501    fn delete_range_collapsed_range_deletes_nothing() {
1502        let content = vec![text("hello")];
1503        let r = SelectionRange {
1504            start: lead(0, 2),
1505            end: lead(0, 2),
1506        };
1507        let (new_content, cursor) = delete_range(&content, &r);
1508        assert_eq!(dump(&new_content), vec!["hello"]);
1509        assert_eq!(cursor, lead(0, 2));
1510    }
1511
1512    #[test]
1513    fn delete_range_select_all_with_trailing_end_covers_the_last_cluster() {
1514        // The end cursor of a select-all sits Trailing on the last cluster; the
1515        // affinity-aware offset is what makes the final grapheme part of the range.
1516        let content = vec![text("hello")];
1517        let r = SelectionRange {
1518            start: lead(0, 0),
1519            end: trail(0, 4),
1520        };
1521        let (new_content, cursor) = delete_range(&content, &r);
1522        assert_eq!(dump(&new_content), vec![""]);
1523        assert_eq!(cursor, lead(0, 0));
1524    }
1525
1526    #[test]
1527    fn delete_range_spanning_runs_merges_matching_styles() {
1528        let content = vec![text("abc"), text("def")];
1529        let r = SelectionRange {
1530            start: lead(0, 1),
1531            end: lead(1, 2),
1532        };
1533        let (new_content, cursor) = delete_range(&content, &r);
1534        assert_eq!(dump(&new_content), vec!["af"], "same style -> one run");
1535        assert_eq!(cursor, lead(0, 1));
1536    }
1537
1538    #[test]
1539    fn delete_range_spanning_runs_keeps_differing_styles_apart() {
1540        let content = vec![text_styled("abc", style_a()), text_styled("def", style_b())];
1541        let r = SelectionRange {
1542            start: lead(0, 1),
1543            end: lead(1, 2),
1544        };
1545        let (new_content, cursor) = delete_range(&content, &r);
1546        assert_eq!(dump(&new_content), vec!["a", "f"], "styles differ -> no merge");
1547        assert_eq!(cursor, lead(0, 1));
1548    }
1549
1550    #[test]
1551    fn delete_range_drops_the_runs_strictly_between_the_boundaries() {
1552        let content = vec![text("abc"), text("XYZ"), obj(), text("def")];
1553        let r = SelectionRange {
1554            start: lead(0, 1),
1555            end: lead(3, 2),
1556        };
1557        let (new_content, _) = delete_range(&content, &r);
1558        assert_eq!(dump(&new_content), vec!["af"], "middle text AND obj dropped");
1559    }
1560
1561    #[test]
1562    fn delete_range_over_a_single_non_text_item_removes_it() {
1563        let content = vec![text("ab"), obj(), text("cd")];
1564        // start != end (affinity differs) so the guard against a zero-width delete passes.
1565        let r = SelectionRange {
1566            start: lead(1, 0),
1567            end: trail(1, 0),
1568        };
1569        let (new_content, cursor) = delete_range(&content, &r);
1570        assert_eq!(dump(&new_content), vec!["ab", "cd"]);
1571        assert_eq!(cursor, lead(1, 0));
1572    }
1573
1574    #[test]
1575    fn delete_range_collapsed_on_a_non_text_item_keeps_it() {
1576        let content = vec![text("ab"), obj()];
1577        let r = SelectionRange {
1578            start: lead(1, 0),
1579            end: lead(1, 0),
1580        };
1581        let (new_content, _) = delete_range(&content, &r);
1582        assert_eq!(dump(&new_content), vec!["ab", "<obj>"]);
1583    }
1584
1585    #[test]
1586    fn delete_range_out_of_bounds_runs_do_not_panic() {
1587        let content = vec![text("ab")];
1588
1589        // Both ends past the end (same-run path).
1590        let r = SelectionRange {
1591            start: lead(99, 0),
1592            end: lead(99, 5),
1593        };
1594        let (new_content, _) = delete_range(&content, &r);
1595        assert_eq!(dump(&new_content), vec!["ab"]);
1596
1597        // Multi-run path with a bogus `hi_run` — the drain must be clamped.
1598        let r = SelectionRange {
1599            start: lead(0, 0),
1600            end: lead(u32::MAX, 0),
1601        };
1602        let (new_content, cursor) = delete_range(&content, &r);
1603        assert_eq!(dump(&new_content), vec![""]);
1604        assert_eq!(cursor, lead(0, 0));
1605    }
1606
1607    #[test]
1608    fn delete_range_backward_across_runs_is_normalized() {
1609        let content = vec![text("abc"), text("def")];
1610        let backward = SelectionRange {
1611            start: lead(1, 2),
1612            end: lead(0, 1),
1613        };
1614        let (new_content, cursor) = delete_range(&content, &backward);
1615        assert_eq!(dump(&new_content), vec!["af"]);
1616        assert_eq!(cursor, lead(0, 1));
1617    }
1618
1619    // ---------------------------------------------------------- insert_text
1620
1621    #[test]
1622    fn insert_text_leading_inserts_before_the_cluster() {
1623        let content = vec![text("hello")];
1624        let (new_content, cursor) = insert_text(&content, &lead(0, 2), "XY");
1625        assert_eq!(dump(&new_content), vec!["heXYllo"]);
1626        assert_eq!(cursor, lead(0, 4));
1627    }
1628
1629    #[test]
1630    fn insert_text_trailing_inserts_after_the_whole_grapheme() {
1631        // Trailing on the 4-byte emoji must land at byte 4, not byte 1.
1632        let content = vec![text("👍z")];
1633        let (new_content, cursor) = insert_text(&content, &trail(0, 0), "X");
1634        assert_eq!(dump(&new_content), vec!["👍Xz"]);
1635        assert_eq!(cursor, lead(0, 5));
1636    }
1637
1638    #[test]
1639    fn insert_text_trailing_past_the_end_appends() {
1640        let content = vec![text("hi")];
1641        let (new_content, cursor) = insert_text(&content, &trail(0, 999), "!");
1642        assert_eq!(dump(&new_content), vec!["hi!"]);
1643        assert_eq!(cursor, lead(0, 3));
1644    }
1645
1646    #[test]
1647    fn insert_text_leading_past_the_end_is_a_noop() {
1648        // Asymmetry with the Trailing case above: a Leading offset beyond the run
1649        // is NOT clamped, the insert is dropped and the caret is returned as-is.
1650        let content = vec![text("hi")];
1651        let (new_content, cursor) = insert_text(&content, &lead(0, 999), "!");
1652        assert_eq!(dump(&new_content), vec!["hi"]);
1653        assert_eq!(cursor, lead(0, 999));
1654    }
1655
1656    #[test]
1657    fn insert_text_into_missing_or_non_text_run_is_a_noop() {
1658        let content = vec![obj()];
1659        let (new_content, cursor) = insert_text(&content, &lead(0, 0), "x");
1660        assert_eq!(dump(&new_content), vec!["<obj>"]);
1661        assert_eq!(cursor, lead(0, 0));
1662
1663        let (new_content, cursor) = insert_text(&content, &lead(u32::MAX, 0), "x");
1664        assert_eq!(dump(&new_content), vec!["<obj>"]);
1665        assert_eq!(cursor, lead(u32::MAX, 0));
1666
1667        let (new_content, _) = insert_text(&[], &lead(0, 0), "x");
1668        assert!(new_content.is_empty());
1669    }
1670
1671    #[test]
1672    fn insert_text_empty_string_leaves_the_text_alone() {
1673        let content = vec![text("hi")];
1674        let (new_content, cursor) = insert_text(&content, &lead(0, 1), "");
1675        assert_eq!(dump(&new_content), vec!["hi"]);
1676        assert_eq!(cursor, lead(0, 1));
1677    }
1678
1679    #[test]
1680    fn insert_text_cursor_advances_by_bytes_not_chars() {
1681        let content = vec![text("")];
1682        let (new_content, cursor) = insert_text(&content, &lead(0, 0), FAMILY);
1683        assert_eq!(dump(&new_content), vec![FAMILY]);
1684        assert_eq!(cursor, lead(0, 18));
1685    }
1686
1687    #[test]
1688    fn insert_text_of_a_huge_string_does_not_panic() {
1689        let big = "a".repeat(200_000);
1690        let content = vec![text("hi")];
1691        let (new_content, cursor) = insert_text(&content, &lead(0, 1), &big);
1692        assert_eq!(run_text_len(&new_content, 0), 200_002);
1693        assert_eq!(cursor, lead(0, 200_001));
1694    }
1695
1696    // ------------------------------------------------------- delete_backward
1697
1698    #[test]
1699    fn delete_backward_on_empty_content_is_a_noop() {
1700        let (new_content, cursor) = delete_backward(&[], &lead(0, 0));
1701        assert!(new_content.is_empty());
1702        assert_eq!(cursor, lead(0, 0));
1703    }
1704
1705    #[test]
1706    fn delete_backward_at_the_start_of_the_document_is_a_noop() {
1707        let content = vec![text("hi")];
1708        let (new_content, cursor) = delete_backward(&content, &lead(0, 0));
1709        assert_eq!(dump(&new_content), vec!["hi"]);
1710        assert_eq!(cursor, lead(0, 0));
1711    }
1712
1713    #[test]
1714    fn delete_backward_removes_a_whole_grapheme_cluster() {
1715        let content = vec![text(&format!("a{FAMILY}"))];
1716        let (new_content, cursor) = delete_backward(&content, &lead(0, 19));
1717        assert_eq!(dump(&new_content), vec!["a"], "all 18 bytes go at once");
1718        assert_eq!(cursor, lead(0, 1));
1719    }
1720
1721    #[test]
1722    fn delete_backward_trailing_affinity_removes_the_current_cluster() {
1723        let content = vec![text("ab")];
1724        let (new_content, cursor) = delete_backward(&content, &trail(0, 0));
1725        assert_eq!(dump(&new_content), vec!["b"]);
1726        assert_eq!(cursor, lead(0, 0));
1727    }
1728
1729    #[test]
1730    fn delete_backward_merges_across_a_run_boundary() {
1731        let content = vec![text("ab"), text("cd")];
1732        let (new_content, cursor) = delete_backward(&content, &lead(1, 0));
1733        assert_eq!(dump(&new_content), vec!["abcd"]);
1734        assert_eq!(cursor, lead(0, 2), "caret sits at the join point");
1735    }
1736
1737    #[test]
1738    fn delete_backward_removes_a_non_text_item_sitting_before_the_caret() {
1739        let content = vec![text("ab"), obj(), text("cd")];
1740        let (new_content, cursor) = delete_backward(&content, &lead(2, 0));
1741        assert_eq!(dump(&new_content), vec!["ab", "cd"]);
1742        assert_eq!(cursor, lead(1, 0));
1743    }
1744
1745    #[test]
1746    fn delete_backward_with_the_caret_after_a_non_text_item_removes_the_item() {
1747        let content = vec![text("ab"), obj()];
1748        let (new_content, _) = delete_backward(&content, &trail(1, 0));
1749        assert_eq!(dump(&new_content), vec!["ab"]);
1750    }
1751
1752    #[test]
1753    fn delete_backward_with_the_caret_before_a_non_text_item_acts_on_the_previous_run() {
1754        let content = vec![text("ab"), obj()];
1755        let (new_content, cursor) = delete_backward(&content, &lead(1, 0));
1756        assert_eq!(dump(&new_content), vec!["a", "<obj>"], "the item survives");
1757        assert_eq!(cursor, lead(0, 1));
1758    }
1759
1760    #[test]
1761    fn delete_backward_before_a_leading_non_text_item_at_run_zero_is_a_noop() {
1762        let content = vec![obj()];
1763        let (new_content, cursor) = delete_backward(&content, &lead(0, 0));
1764        assert_eq!(dump(&new_content), vec!["<obj>"]);
1765        assert_eq!(cursor, lead(0, 0));
1766    }
1767
1768    #[test]
1769    fn delete_backward_out_of_range_run_is_a_noop() {
1770        let content = vec![text("hi")];
1771        let (new_content, cursor) = delete_backward(&content, &lead(u32::MAX, u32::MAX));
1772        assert_eq!(dump(&new_content), vec!["hi"]);
1773        assert_eq!(cursor, lead(u32::MAX, u32::MAX));
1774    }
1775
1776    // -------------------------------------------------------- delete_forward
1777
1778    #[test]
1779    fn delete_forward_on_empty_content_is_a_noop() {
1780        let (new_content, cursor) = delete_forward(&[], &lead(0, 0));
1781        assert!(new_content.is_empty());
1782        assert_eq!(cursor, lead(0, 0));
1783    }
1784
1785    #[test]
1786    fn delete_forward_at_the_end_of_the_document_is_a_noop() {
1787        let content = vec![text("hi")];
1788        let (new_content, cursor) = delete_forward(&content, &lead(0, 2));
1789        assert_eq!(dump(&new_content), vec!["hi"]);
1790        assert_eq!(cursor, lead(0, 2));
1791    }
1792
1793    #[test]
1794    fn delete_forward_removes_a_whole_grapheme_cluster() {
1795        let content = vec![text(&format!("{FAMILY}z"))];
1796        let (new_content, cursor) = delete_forward(&content, &lead(0, 0));
1797        assert_eq!(dump(&new_content), vec!["z"]);
1798        assert_eq!(cursor, lead(0, 0));
1799    }
1800
1801    #[test]
1802    fn delete_forward_merges_across_a_run_boundary() {
1803        let content = vec![text("ab"), text("cd")];
1804        let (new_content, cursor) = delete_forward(&content, &lead(0, 2));
1805        assert_eq!(dump(&new_content), vec!["abcd"]);
1806        assert_eq!(cursor, lead(0, 2));
1807    }
1808
1809    #[test]
1810    fn delete_forward_removes_a_non_text_item_sitting_after_the_caret() {
1811        let content = vec![text("ab"), obj()];
1812        let (new_content, _) = delete_forward(&content, &lead(0, 2));
1813        assert_eq!(dump(&new_content), vec!["ab"]);
1814    }
1815
1816    #[test]
1817    fn delete_forward_with_the_caret_before_a_non_text_item_removes_the_item() {
1818        let content = vec![obj(), text("ab")];
1819        let (new_content, cursor) = delete_forward(&content, &lead(0, 0));
1820        assert_eq!(dump(&new_content), vec!["ab"]);
1821        assert_eq!(cursor, lead(0, 0));
1822    }
1823
1824    #[test]
1825    fn delete_forward_with_the_caret_after_a_non_text_item_acts_on_the_next_run() {
1826        let content = vec![obj(), text("ab")];
1827        let (new_content, cursor) = delete_forward(&content, &trail(0, 0));
1828        assert_eq!(dump(&new_content), vec!["<obj>", "b"], "the item survives");
1829        assert_eq!(cursor, lead(1, 0));
1830    }
1831
1832    #[test]
1833    fn delete_forward_after_a_trailing_non_text_item_at_the_last_run_is_a_noop() {
1834        let content = vec![text("ab"), obj()];
1835        let (new_content, cursor) = delete_forward(&content, &trail(1, 0));
1836        assert_eq!(dump(&new_content), vec!["ab", "<obj>"]);
1837        assert_eq!(cursor, trail(1, 0));
1838    }
1839
1840    #[test]
1841    fn delete_forward_out_of_range_run_is_a_noop() {
1842        let content = vec![text("hi")];
1843        let (new_content, cursor) = delete_forward(&content, &lead(u32::MAX, u32::MAX));
1844        assert_eq!(dump(&new_content), vec!["hi"]);
1845        assert_eq!(cursor, lead(u32::MAX, u32::MAX));
1846    }
1847
1848    // ------------------------------------------------------- edit_text_multi
1849
1850    #[test]
1851    #[should_panic(expected = "same length")]
1852    fn edit_text_multi_panics_on_a_length_mismatch() {
1853        // Documented in the function's `# Panics` section.
1854        let content = vec![text("hi")];
1855        let sels = [Selection::Cursor(lead(0, 0))];
1856        let _ = edit_text_multi(&content, &sels, &["a", "b"]);
1857    }
1858
1859    #[test]
1860    fn edit_text_multi_with_no_selections_returns_content_unchanged() {
1861        let content = vec![text("hi")];
1862        let (new_content, sels) = edit_text_multi(&content, &[], &[]);
1863        assert_eq!(dump(&new_content), vec!["hi"]);
1864        assert!(sels.is_empty());
1865    }
1866
1867    #[test]
1868    fn edit_text_multi_gives_each_cursor_its_own_text() {
1869        let content = vec![text("ab")];
1870        let sels = [
1871            Selection::Cursor(lead(0, 0)),
1872            Selection::Cursor(lead(0, 2)),
1873        ];
1874        let (new_content, new_sels) = edit_text_multi(&content, &sels, &["X", "Y"]);
1875        assert_eq!(dump(&new_content), vec!["XabY"]);
1876        assert_eq!(cursor_of(&new_sels[0]), lead(0, 1));
1877        assert_eq!(cursor_of(&new_sels[1]), lead(0, 4));
1878    }
1879
1880    #[test]
1881    fn edit_text_multi_with_empty_texts_only_moves_the_carets() {
1882        let content = vec![text("ab")];
1883        let sels = [
1884            Selection::Cursor(lead(0, 0)),
1885            Selection::Cursor(lead(0, 1)),
1886        ];
1887        let (new_content, new_sels) = edit_text_multi(&content, &sels, &["", ""]);
1888        assert_eq!(dump(&new_content), vec!["ab"]);
1889        assert_eq!(new_sels.len(), 2);
1890    }
1891
1892    // ---------------------------------------------------------- inspect_delete
1893
1894    #[test]
1895    fn inspect_delete_forward_at_the_end_of_the_document_is_none() {
1896        let content = vec![text("hi")];
1897        assert!(inspect_delete(&content, &Selection::Cursor(lead(0, 2)), true).is_none());
1898        assert!(inspect_delete(&[], &Selection::Cursor(lead(0, 0)), true).is_none());
1899    }
1900
1901    #[test]
1902    fn inspect_delete_backward_at_the_start_of_the_document_is_none() {
1903        let content = vec![text("hi")];
1904        assert!(inspect_delete(&content, &Selection::Cursor(lead(0, 0)), false).is_none());
1905        assert!(inspect_delete(&[], &Selection::Cursor(lead(0, 0)), false).is_none());
1906    }
1907
1908    #[test]
1909    fn inspect_delete_forward_reports_exactly_what_delete_forward_removes() {
1910        let content = vec![text("héllo")]; // é starts at byte 1, 2 bytes long
1911        let cursor = lead(0, 1);
1912        let (_, reported) = inspect_delete(&content, &Selection::Cursor(cursor), true).unwrap();
1913        let (after, _) = delete_forward(&content, &cursor);
1914        assert_eq!(reported, "é");
1915        assert_eq!(dump(&after), vec!["hllo"], "inspect and delete agree");
1916    }
1917
1918    #[test]
1919    fn inspect_delete_backward_reports_exactly_what_delete_backward_removes() {
1920        let content = vec![text(&format!("a{FAMILY}"))];
1921        let cursor = lead(0, 19);
1922        let (range, reported) =
1923            inspect_delete(&content, &Selection::Cursor(cursor), false).unwrap();
1924        let (after, _) = delete_backward(&content, &cursor);
1925        assert_eq!(reported, FAMILY, "the whole ZWJ cluster, not one codepoint");
1926        assert_eq!(range.start, lead(0, 1));
1927        assert_eq!(dump(&after), vec!["a"]);
1928    }
1929
1930    #[test]
1931    fn inspect_delete_forward_honors_trailing_affinity() {
1932        // A Trailing cursor sits AFTER its grapheme, so Delete removes the NEXT one.
1933        let content = vec![text("abc")];
1934        let (_, reported) =
1935            inspect_delete(&content, &Selection::Cursor(trail(0, 0)), true).unwrap();
1936        assert_eq!(reported, "b");
1937    }
1938
1939    #[test]
1940    fn inspect_delete_across_a_run_boundary_reports_the_neighbouring_grapheme() {
1941        let content = vec![text("ab"), text("cd")];
1942        let (_, fwd) = inspect_delete(&content, &Selection::Cursor(lead(0, 2)), true).unwrap();
1943        assert_eq!(fwd, "c");
1944        let (_, back) = inspect_delete(&content, &Selection::Cursor(lead(1, 0)), false).unwrap();
1945        assert_eq!(back, "b");
1946    }
1947
1948    #[test]
1949    fn inspect_delete_reports_none_for_a_non_text_neighbour_that_delete_would_remove() {
1950        // BUG (reported): inspect_delete_forward/backward only match on a TEXT
1951        // neighbour, so they answer "nothing would be deleted" while
1952        // delete_forward/delete_backward actually remove the inline item. A callback
1953        // relying on inspect_delete to veto or log the edit sees nothing coming.
1954        let content = vec![text("ab"), obj()];
1955        assert!(inspect_delete(&content, &Selection::Cursor(lead(0, 2)), true).is_none());
1956        let (after, _) = delete_forward(&content, &lead(0, 2));
1957        assert_eq!(dump(&after), vec!["ab"], "...but the item IS removed");
1958
1959        let content = vec![obj(), text("ab")];
1960        assert!(inspect_delete(&content, &Selection::Cursor(lead(1, 0)), false).is_none());
1961        let (after, _) = delete_backward(&content, &lead(1, 0));
1962        assert_eq!(dump(&after), vec!["ab"], "...but the item IS removed");
1963    }
1964
1965    #[test]
1966    fn inspect_delete_on_a_range_returns_the_range_and_its_text() {
1967        let content = vec![text("hello")];
1968        let sel = range_sel(lead(0, 1), lead(0, 3));
1969        let (range, reported) = inspect_delete(&content, &sel, false).unwrap();
1970        assert_eq!(range.start, lead(0, 1));
1971        assert_eq!(range.end, lead(0, 3));
1972        assert_eq!(reported, "el");
1973        // ...and that is exactly what the delete removes.
1974        let (after, _) = apply_edit_to_selection(&content, &sel, &TextEdit::DeleteBackward);
1975        assert_eq!(dump(&after), vec!["hlo"]);
1976    }
1977
1978    #[test]
1979    fn inspect_delete_out_of_range_cursor_is_none_not_a_panic() {
1980        let content = vec![text("hi")];
1981        assert!(inspect_delete(&content, &Selection::Cursor(lead(u32::MAX, 0)), true).is_none());
1982        assert!(inspect_delete(&content, &Selection::Cursor(lead(u32::MAX, 0)), false).is_none());
1983    }
1984
1985    // ---------------------------------------------------- extract_text_in_range
1986
1987    #[test]
1988    fn extract_text_in_range_single_run() {
1989        let content = vec![text("hello")];
1990        let r = SelectionRange {
1991            start: lead(0, 1),
1992            end: lead(0, 4),
1993        };
1994        assert_eq!(extract_text_in_range(&content, &r), "ell");
1995    }
1996
1997    #[test]
1998    fn extract_text_in_range_multi_run_concatenates_the_span() {
1999        let content = vec![text("abc"), text("MID"), text("def")];
2000        let r = SelectionRange {
2001            start: lead(0, 1),
2002            end: lead(2, 2),
2003        };
2004        assert_eq!(extract_text_in_range(&content, &r), "bcMIDde");
2005    }
2006
2007    #[test]
2008    fn extract_text_in_range_skips_non_text_items_in_the_span() {
2009        let content = vec![text("abc"), obj(), text("def")];
2010        let r = SelectionRange {
2011            start: lead(0, 1),
2012            end: lead(2, 2),
2013        };
2014        assert_eq!(extract_text_in_range(&content, &r), "bcde");
2015    }
2016
2017    #[test]
2018    fn extract_text_in_range_out_of_bounds_yields_empty_string() {
2019        let content = vec![text("hi")];
2020
2021        // end_byte past the run length.
2022        let r = SelectionRange {
2023            start: lead(0, 0),
2024            end: lead(0, 99),
2025        };
2026        assert_eq!(extract_text_in_range(&content, &r), "");
2027
2028        // Both runs past the end.
2029        let r = SelectionRange {
2030            start: lead(9, 0),
2031            end: lead(9, 1),
2032        };
2033        assert_eq!(extract_text_in_range(&content, &r), "");
2034
2035        // Empty content.
2036        let r = SelectionRange {
2037            start: lead(0, 0),
2038            end: lead(0, 1),
2039        };
2040        assert_eq!(extract_text_in_range(&[], &r), "");
2041    }
2042
2043    #[test]
2044    fn extract_text_in_range_backward_single_run_yields_empty_string() {
2045        // Unlike delete_range, extract does NOT normalize direction — a
2046        // right-to-left selection inside one run reports no text at all.
2047        let content = vec![text("hello")];
2048        let r = SelectionRange {
2049            start: lead(0, 3),
2050            end: lead(0, 1),
2051        };
2052        assert_eq!(extract_text_in_range(&content, &r), "");
2053    }
2054
2055    #[test]
2056    fn extract_text_in_range_ignores_affinity_and_drops_the_last_cluster() {
2057        // BUG (reported): extract_text_in_range reads the RAW `start_byte_in_run`
2058        // while delete_range goes through cursor_byte_offset_in_run. On a select-all
2059        // (end cursor Trailing on the last cluster) inspect_delete therefore reports
2060        // one grapheme LESS than the delete actually removes.
2061        let content = vec![text("hello")];
2062        let r = SelectionRange {
2063            start: lead(0, 0),
2064            end: trail(0, 4),
2065        };
2066        assert_eq!(extract_text_in_range(&content, &r), "hell", "the 'o' is missing");
2067
2068        let (after, _) = delete_range(&content, &r);
2069        assert_eq!(dump(&after), vec![""], "...yet delete_range removes all of it");
2070    }
2071}