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

            
7
use azul_core::selection::{
8
    CursorAffinity, GraphemeClusterId, Selection, SelectionRange, TextCursor,
9
};
10

            
11
use crate::text3::cache::{InlineContent, StyledRun};
12

            
13
/// An enum representing a single text editing action.
14
#[derive(Debug, Clone)]
15
pub 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

            
24
1977
const fn selection_start_run(selection: &Selection) -> u32 {
25
1977
    match selection {
26
1965
        Selection::Cursor(c) => c.cluster_id.source_run,
27
12
        Selection::Range(r) => r.start.cluster_id.source_run,
28
    }
29
1977
}
30

            
31
1977
const fn selection_start_byte(selection: &Selection) -> u32 {
32
1977
    match selection {
33
1965
        Selection::Cursor(c) => c.cluster_id.start_byte_in_run,
34
12
        Selection::Range(r) => r.start.cluster_id.start_byte_in_run,
35
    }
36
1977
}
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.
41
1946
fn sort_selections_back_to_front(selections: &[Selection]) -> Vec<Selection> {
42
1946
    let mut sorted = selections.to_vec();
43
1947
    sorted.sort_by(|a, b| {
44
39
        let cursor_a = match a {
45
38
            Selection::Cursor(c) => c,
46
1
            Selection::Range(r) => &r.start,
47
        };
48
39
        let cursor_b = match b {
49
39
            Selection::Cursor(c) => c,
50
            Selection::Range(r) => &r.start,
51
        };
52
39
        cursor_b.cluster_id.cmp(&cursor_a.cluster_id) // Reverse sort
53
39
    });
54
1946
    sorted
55
1946
}
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
60
1981
fn adjust_cursors(
61
1981
    selections: &mut [Selection],
62
1981
    edit_run: u32,
63
1981
    edit_byte: u32,
64
1981
    byte_offset_change: i32,
65
1981
) {
66
1981
    for sel in selections.iter_mut() {
67
42
        if let Selection::Cursor(cursor) = sel {
68
41
            if cursor.cluster_id.source_run == edit_run
69
40
                && cursor.cluster_id.start_byte_in_run >= edit_byte
70
39
            {
71
39
                cursor.cluster_id.start_byte_in_run =
72
39
                    (cursor.cluster_id.start_byte_in_run as i32 + byte_offset_change).max(0) as u32;
73
39
            }
74
1
        }
75
    }
76
1981
}
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
89
1981
fn adjust_cursor_runs(selections: &mut [Selection], boundary_run: u32, run_count_change: i32) {
90
1981
    if run_count_change == 0 {
91
1974
        return;
92
7
    }
93
11
    for sel in selections.iter_mut() {
94
11
        if let Selection::Cursor(cursor) = sel {
95
10
            if cursor.cluster_id.source_run > boundary_run {
96
6
                let shifted = (cursor.cluster_id.source_run as i32 + run_count_change)
97
6
                    .max(boundary_run as i32);
98
6
                cursor.cluster_id.source_run = shifted as u32;
99
6
            }
100
1
        }
101
    }
102
1981
}
103

            
104
/// Byte length of the text in the run at `run_idx`, or 0 for non-text / missing runs.
105
3953
fn run_text_len(content: &[InlineContent], run_idx: u32) -> usize {
106
3953
    match content.get(run_idx as usize) {
107
3945
        Some(InlineContent::Text(run)) => run.text.len(),
108
8
        _ => 0,
109
    }
110
3953
}
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
1941
#[must_use] pub fn edit_text(
116
1941
    content: &[InlineContent],
117
1941
    selections: &[Selection],
118
1941
    edit: &TextEdit,
119
1941
) -> (Vec<InlineContent>, Vec<Selection>) {
120
1941
    if selections.is_empty() {
121
1
        return (content.to_vec(), Vec::new());
122
1940
    }
123

            
124
1940
    let mut new_content = content.to_vec();
125
1940
    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
1940
    let sorted_selections = sort_selections_back_to_front(selections);
131

            
132
3909
    for selection in sorted_selections {
133
1969
        let edit_run = selection_start_run(&selection);
134
1969
        let edit_byte = selection_start_byte(&selection);
135
1969

            
136
1969
        // Measure the affected run before and after the edit so we can shift
137
1969
        // previously-processed cursors by the ACTUAL byte delta. The old code
138
1969
        // hardcoded -1 for any delete, which mis-tracked multi-byte graphemes.
139
1969
        let old_run_len = run_text_len(&new_content, edit_run);
140
1969
        let old_run_count = new_content.len();
141
1969
        let (temp_content, new_cursor) =
142
1969
            apply_edit_to_selection(&new_content, &selection, edit);
143
1969
        let new_run_len = run_text_len(&temp_content, edit_run);
144
1969
        let byte_offset_change = new_run_len as i32 - old_run_len as i32;
145
1969
        let run_count_change = temp_content.len() as i32 - old_run_count as i32;
146
1969

            
147
1969
        // Adjust all previously-processed cursors in the same run that come after this position
148
1969
        adjust_cursors(&mut new_selections, edit_run, edit_byte, byte_offset_change);
149
1969
        // If the edit changed the run COUNT (multi-run delete / cross-run delete /
150
1969
        // image removal), reindex later cursors whose run sits after this edit.
151
1969
        adjust_cursor_runs(&mut new_selections, edit_run, run_count_change);
152
1969

            
153
1969
        new_content = temp_content;
154
1969
        new_selections.push(Selection::Cursor(new_cursor));
155
1969
    }
156

            
157
    // The new selections were added in reverse order, so we reverse them back.
158
1940
    new_selections.reverse();
159

            
160
1940
    (new_content, new_selections)
161
1941
}
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
1977
#[must_use] pub fn apply_edit_to_selection(
171
1977
    content: &[InlineContent],
172
1977
    selection: &Selection,
173
1977
    edit: &TextEdit,
174
1977
) -> (Vec<InlineContent>, TextCursor) {
175
1977
    let mut new_content = content.to_vec();
176

            
177
1977
    match selection {
178
13
        Selection::Range(range) => {
179
            // Delete the range first
180
13
            let (content_after_delete, cursor_pos) = delete_range(&new_content, range);
181
13
            match edit {
182
                // Insert: replace the deleted range with new text
183
1
                TextEdit::Insert(text_to_insert) => {
184
1
                    let mut c = content_after_delete;
185
1
                    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
12
                    (content_after_delete, cursor_pos)
190
                }
191
            }
192
        }
193
1964
        Selection::Cursor(cursor) => {
194
1964
            match edit {
195
1954
                TextEdit::Insert(text_to_insert) => {
196
1954
                    insert_text(&new_content, cursor, text_to_insert)
197
                }
198
10
                TextEdit::DeleteBackward => delete_backward(&new_content, cursor),
199
                TextEdit::DeleteForward => delete_forward(&new_content, cursor),
200
            }
201
        }
202
    }
203
1977
}
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.
211
263
pub(crate) fn cursor_byte_offset_in_run(text: &str, cursor: &TextCursor) -> usize {
212
    use unicode_segmentation::UnicodeSegmentation;
213
263
    let csb = cursor.cluster_id.start_byte_in_run as usize;
214
263
    match cursor.affinity {
215
236
        CursorAffinity::Leading => csb.min(text.len()),
216
        CursorAffinity::Trailing => {
217
27
            if csb >= text.len() {
218
3
                text.len()
219
            } else {
220
24
                text[csb..]
221
24
                    .grapheme_indices(true)
222
24
                    .next()
223
24
                    .map_or(text.len(), |(_, g)| csb + g.len())
224
            }
225
        }
226
    }
227
263
}
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
35
#[must_use] pub fn delete_range(
242
35
    content: &[InlineContent],
243
35
    range: &SelectionRange,
244
35
) -> (Vec<InlineContent>, TextCursor) {
245
35
    let mut new_content = content.to_vec();
246
35
    let start_run_idx = range.start.cluster_id.source_run as usize;
247
35
    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
35
    let mut cursor_after = range.start;
255
35
    if start_run_idx == end_run_idx {
256
21
        if let Some(InlineContent::Text(run)) = new_content.get_mut(start_run_idx) {
257
18
            let a = cursor_byte_offset_in_run(&run.text, &range.start);
258
18
            let b = cursor_byte_offset_in_run(&run.text, &range.end);
259
18
            let lo = a.min(b);
260
18
            let hi = a.max(b);
261
18
            if hi <= run.text.len() && lo < hi {
262
17
                let mut t = String::from(&*run.text);
263
17
                t.drain(lo..hi);
264
17
                run.text = alloc::sync::Arc::from(t.as_str());
265
17
                // Collapse the caret to the start of the deleted region (the low
266
17
                // end), regardless of the original selection direction.
267
17
                cursor_after = TextCursor {
268
17
                    cluster_id: GraphemeClusterId {
269
17
                        source_run: start_run_idx as u32,
270
17
                        start_byte_in_run: lo as u32,
271
17
                    },
272
17
                    affinity: CursorAffinity::Leading,
273
17
                };
274
17
            }
275
3
        } else if start_run_idx < new_content.len() && range.start != range.end {
276
1
            // The selection covers a single NON-text run (inline image / object /
277
1
            // shape). A byte-offset drain can't remove it; delete the whole item and
278
1
            // collapse the caret to its former index. `range.start != range.end`
279
1
            // guards against a zero-width (collapsed) selection deleting the item.
280
1
            new_content.remove(start_run_idx);
281
1
            cursor_after = TextCursor {
282
1
                cluster_id: GraphemeClusterId {
283
1
                    source_run: start_run_idx as u32,
284
1
                    start_byte_in_run: 0,
285
1
                },
286
1
                affinity: CursorAffinity::Leading,
287
1
            };
288
2
        }
289
    } else {
290
        // Multi-run deletion.
291
        //
292
        // Normalize direction so `lo` precedes `hi` in document order (the range
293
        // may be backward if the user selected right-to-left across runs). Then:
294
        //   1. truncate the start (lo) run to the text BEFORE the selection,
295
        //   2. truncate the end (hi) run to the text AFTER the selection,
296
        //   3. drop every run strictly between them,
297
        //   4. merge the two truncated runs when they share the same style.
298
14
        let (lo_run, lo_cursor, hi_run, hi_cursor) = if start_run_idx <= end_run_idx {
299
13
            (start_run_idx, range.start, end_run_idx, range.end)
300
        } else {
301
1
            (end_run_idx, range.end, start_run_idx, range.start)
302
        };
303

            
304
        // Affinity-aware byte offsets within the two boundary runs. Non-text
305
        // boundary runs resolve to 0 (nothing to truncate there).
306
14
        let lo_byte = match new_content.get(lo_run) {
307
14
            Some(InlineContent::Text(run)) => cursor_byte_offset_in_run(&run.text, &lo_cursor),
308
            _ => 0,
309
        };
310
14
        let hi_byte = match new_content.get(hi_run) {
311
13
            Some(InlineContent::Text(run)) => cursor_byte_offset_in_run(&run.text, &hi_cursor),
312
1
            _ => 0,
313
        };
314

            
315
        // 1. Keep only text[..lo_byte] in the start run; remember the head length
316
        //    (the collapse point for the caret).
317
14
        let head_len = if let Some(InlineContent::Text(run)) = new_content.get_mut(lo_run) {
318
14
            let cut = lo_byte.min(run.text.len());
319
14
            let mut t = String::from(&*run.text);
320
14
            t.truncate(cut);
321
14
            run.text = alloc::sync::Arc::from(t.as_str());
322
14
            cut
323
        } else {
324
            0
325
        };
326

            
327
        // 2. Keep only text[hi_byte..] in the end run.
328
14
        if let Some(InlineContent::Text(run)) = new_content.get_mut(hi_run) {
329
13
            let cut = hi_byte.min(run.text.len());
330
13
            let mut t = String::from(&*run.text);
331
13
            t.drain(..cut);
332
13
            run.text = alloc::sync::Arc::from(t.as_str());
333
13
        }
334

            
335
        // 3. Drop the intermediate runs. After draining, the end run sits at
336
        //    `lo_run + 1`. Clamp the end so a bogus out-of-range `hi_run` can
337
        //    never panic the drain.
338
14
        let drain_end = hi_run.min(new_content.len());
339
14
        if drain_end > lo_run + 1 {
340
10
            new_content.drain((lo_run + 1)..drain_end);
341
13
        }
342
14
        let tail_idx = lo_run + 1;
343

            
344
        // 4. Merge head and tail when both are text with matching style. Compared
345
        //    by value (`StyleProperties: PartialEq`) so runs that were split from
346
        //    one DOM element — or otherwise carry identical styling — re-join into
347
        //    a single run, while genuinely different styles stay separate.
348
14
        let mergeable = matches!(
349
14
            (new_content.get(lo_run), new_content.get(tail_idx)),
350
13
            (Some(InlineContent::Text(a)), Some(InlineContent::Text(b)))
351
13
                if a.style == b.style
352
        );
353
14
        if mergeable {
354
12
            if let InlineContent::Text(tail) = new_content.remove(tail_idx) {
355
12
                if let Some(InlineContent::Text(head)) = new_content.get_mut(lo_run) {
356
12
                    let mut t = String::from(&*head.text);
357
12
                    t.push_str(&tail.text);
358
12
                    head.text = alloc::sync::Arc::from(t.as_str());
359
12
                }
360
            }
361
2
        }
362

            
363
        // Collapse the caret to the join point (start of the deleted region).
364
14
        cursor_after = TextCursor {
365
14
            cluster_id: GraphemeClusterId {
366
14
                source_run: lo_run as u32,
367
14
                start_byte_in_run: head_len as u32,
368
14
            },
369
14
            affinity: CursorAffinity::Leading,
370
14
        };
371
    }
372

            
373
35
    (new_content, cursor_after) // caret at the start of the deleted range
374
35
}
375

            
376
/// Inserts text at a cursor position.
377
/// 
378
/// The cursor's affinity determines the exact insertion point:
379
/// - `Leading`: Insert at the start of the referenced cluster (`start_byte_in_run`)
380
/// - `Trailing`: Insert at the end of the referenced cluster (after the grapheme)
381
#[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
382
#[must_use]
383
1983
pub fn insert_text(
384
1983
    content: &[InlineContent],
385
1983
    cursor: &TextCursor,
386
1983
    text_to_insert: &str,
387
1983
) -> (Vec<InlineContent>, TextCursor) {
388
    use unicode_segmentation::UnicodeSegmentation;
389
    
390
1983
    let mut new_content = content.to_vec();
391
1983
    let run_idx = cursor.cluster_id.source_run as usize;
392
1983
    let cluster_start_byte = cursor.cluster_id.start_byte_in_run as usize;
393

            
394
1983
    if let Some(InlineContent::Text(run)) = new_content.get_mut(run_idx) {
395
        // Calculate the actual insertion byte offset based on affinity
396
1979
        let byte_offset = match cursor.affinity {
397
            CursorAffinity::Leading => {
398
                // Insert at the start of the cluster
399
1887
                cluster_start_byte
400
            },
401
            CursorAffinity::Trailing => {
402
                // Insert at the end of the cluster - find the next grapheme boundary
403
                // We need to find where this grapheme cluster ends
404
92
                if cluster_start_byte >= run.text.len() {
405
                    // Cursor is at/past end of run - insert at end
406
1
                    run.text.len()
407
                } else {
408
                    // Find the grapheme that starts at cluster_start_byte and get its end
409
91
                    run.text[cluster_start_byte..]
410
91
                        .grapheme_indices(true)
411
91
                        .next()
412
91
                        .map_or(run.text.len(), |(_, grapheme)| cluster_start_byte + grapheme.len())
413
                }
414
            },
415
        };
416
        
417
1979
        if byte_offset <= run.text.len() {
418
1978
            let mut t = String::from(&*run.text);
419
1978
            t.insert_str(byte_offset, text_to_insert);
420
1978
            run.text = alloc::sync::Arc::from(t.as_str());
421

            
422
1978
            let new_cursor = TextCursor {
423
1978
                cluster_id: GraphemeClusterId {
424
1978
                    source_run: run_idx as u32,
425
1978
                    start_byte_in_run: (byte_offset + text_to_insert.len()) as u32,
426
1978
                },
427
1978
                affinity: CursorAffinity::Leading,
428
1978
            };
429
1978
            return (new_content, new_cursor);
430
1
        }
431
4
    }
432

            
433
    // If insertion failed, return original state
434
5
    (content.to_vec(), *cursor)
435
1983
}
436

            
437
/// Deletes one grapheme cluster backward from the cursor.
438
/// 
439
/// The cursor's affinity determines the actual cursor position:
440
/// - `Leading`: Cursor is at start of cluster, delete the previous grapheme
441
/// - `Trailing`: Cursor is at end of cluster, delete the current grapheme
442
#[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
443
#[allow(clippy::too_many_lines)] // cohesive grapheme-deletion routine: one branch per cursor affinity
444
#[must_use]
445
50
pub fn delete_backward(
446
50
    content: &[InlineContent],
447
50
    cursor: &TextCursor,
448
50
) -> (Vec<InlineContent>, TextCursor) {
449
    use unicode_segmentation::UnicodeSegmentation;
450
50
    let mut new_content = content.to_vec();
451
50
    let run_idx = cursor.cluster_id.source_run as usize;
452
50
    let cluster_start_byte = cursor.cluster_id.start_byte_in_run as usize;
453

            
454
    // Non-text run (inline image / object / shape) under the cursor. A grapheme
455
    // drain can't act on it, so handle it explicitly instead of silently no-op'ing.
456
50
    if new_content.get(run_idx).is_some()
457
47
        && !matches!(new_content.get(run_idx), Some(InlineContent::Text(_)))
458
    {
459
2
        return match cursor.affinity {
460
            // Caret sits AFTER the item — Backspace removes the item itself.
461
            CursorAffinity::Trailing => {
462
1
                new_content.remove(run_idx);
463
1
                (
464
1
                    new_content,
465
1
                    TextCursor {
466
1
                        cluster_id: GraphemeClusterId {
467
1
                            source_run: run_idx as u32,
468
1
                            start_byte_in_run: 0,
469
1
                        },
470
1
                        affinity: CursorAffinity::Leading,
471
1
                    },
472
1
                )
473
            }
474
            // Caret sits BEFORE the item — Backspace acts on the previous run.
475
2
            CursorAffinity::Leading if run_idx > 0 => {
476
1
                let prev_byte = match content.get(run_idx - 1) {
477
1
                    Some(InlineContent::Text(r)) => r.text.len() as u32,
478
                    _ => 0,
479
                };
480
1
                delete_backward(
481
1
                    content,
482
1
                    &TextCursor {
483
1
                        cluster_id: GraphemeClusterId {
484
1
                            source_run: (run_idx - 1) as u32,
485
1
                            start_byte_in_run: prev_byte,
486
1
                        },
487
1
                        affinity: CursorAffinity::Trailing,
488
1
                    },
489
                )
490
            }
491
1
            CursorAffinity::Leading => (content.to_vec(), *cursor),
492
        };
493
47
    }
494

            
495
47
    if let Some(InlineContent::Text(run)) = new_content.get_mut(run_idx) {
496
        // Calculate the actual cursor byte offset based on affinity
497
44
        let byte_offset = match cursor.affinity {
498
15
            CursorAffinity::Leading => cluster_start_byte,
499
            CursorAffinity::Trailing => {
500
                // Cursor is at end of cluster - find the next grapheme boundary
501
29
                if cluster_start_byte >= run.text.len() {
502
10
                    run.text.len()
503
                } else {
504
19
                    run.text[cluster_start_byte..]
505
19
                        .grapheme_indices(true)
506
19
                        .next()
507
19
                        .map_or(run.text.len(), |(_, grapheme)| cluster_start_byte + grapheme.len())
508
                }
509
            },
510
        };
511

            
512
44
        if byte_offset > 0 {
513
40
            let prev_grapheme_start = run.text[..byte_offset]
514
40
                .grapheme_indices(true)
515
40
                .next_back()
516
40
                .map_or(0, |(i, _)| i);
517
40
            let mut t = String::from(&*run.text);
518
40
            t.drain(prev_grapheme_start..byte_offset);
519
40
            run.text = alloc::sync::Arc::from(t.as_str());
520

            
521
40
            let new_cursor = TextCursor {
522
40
                cluster_id: GraphemeClusterId {
523
40
                    source_run: run_idx as u32,
524
40
                    start_byte_in_run: prev_grapheme_start as u32,
525
40
                },
526
40
                affinity: CursorAffinity::Leading,
527
40
            };
528
40
            return (new_content, new_cursor);
529
4
        } else if run_idx > 0 {
530
            // Handle deleting across run boundaries.
531
3
            match content.get(run_idx - 1).cloned() {
532
                // Previous run is text — merge the two runs.
533
1
                Some(InlineContent::Text(prev_run)) => {
534
1
                    let mut merged_text = String::from(&*prev_run.text);
535
1
                    let new_cursor_byte_offset = merged_text.len();
536
1
                    merged_text.push_str(&run.text);
537

            
538
1
                    new_content[run_idx - 1] = InlineContent::Text(StyledRun {
539
1
                        text: alloc::sync::Arc::from(merged_text.as_str()),
540
1
                        style: prev_run.style,
541
1
                        logical_start_byte: prev_run.logical_start_byte,
542
1
                        source_node_id: prev_run.source_node_id,
543
1
                    });
544
1
                    new_content.remove(run_idx);
545

            
546
1
                    let new_cursor = TextCursor {
547
1
                        cluster_id: GraphemeClusterId {
548
1
                            source_run: (run_idx - 1) as u32,
549
1
                            start_byte_in_run: new_cursor_byte_offset as u32,
550
1
                        },
551
1
                        affinity: CursorAffinity::Leading,
552
1
                    };
553
1
                    return (new_content, new_cursor);
554
                }
555
                // Previous run is a non-text item — Backspace removes it.
556
                Some(_) => {
557
2
                    new_content.remove(run_idx - 1);
558
2
                    let new_cursor = TextCursor {
559
2
                        cluster_id: GraphemeClusterId {
560
2
                            source_run: (run_idx - 1) as u32,
561
2
                            start_byte_in_run: 0,
562
2
                        },
563
2
                        affinity: CursorAffinity::Leading,
564
2
                    };
565
2
                    return (new_content, new_cursor);
566
                }
567
                None => {}
568
            }
569
1
        }
570
3
    }
571

            
572
4
    (content.to_vec(), *cursor)
573
50
}
574

            
575
/// Deletes one grapheme cluster forward from the cursor.
576
/// 
577
/// The cursor's affinity determines the actual cursor position:
578
/// - `Leading`: Cursor is at start of cluster, delete the current grapheme
579
/// - `Trailing`: Cursor is at end of cluster, delete the next grapheme
580
#[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
581
#[must_use]
582
21
pub fn delete_forward(
583
21
    content: &[InlineContent],
584
21
    cursor: &TextCursor,
585
21
) -> (Vec<InlineContent>, TextCursor) {
586
    use unicode_segmentation::UnicodeSegmentation;
587
21
    let mut new_content = content.to_vec();
588
21
    let run_idx = cursor.cluster_id.source_run as usize;
589
21
    let cluster_start_byte = cursor.cluster_id.start_byte_in_run as usize;
590

            
591
    // Non-text run (inline image / object / shape) under the cursor.
592
21
    if new_content.get(run_idx).is_some()
593
19
        && !matches!(new_content.get(run_idx), Some(InlineContent::Text(_)))
594
    {
595
2
        return match cursor.affinity {
596
            // Caret sits BEFORE the item — Delete removes the item itself.
597
            CursorAffinity::Leading => {
598
1
                new_content.remove(run_idx);
599
1
                (
600
1
                    new_content,
601
1
                    TextCursor {
602
1
                        cluster_id: GraphemeClusterId {
603
1
                            source_run: run_idx as u32,
604
1
                            start_byte_in_run: 0,
605
1
                        },
606
1
                        affinity: CursorAffinity::Leading,
607
1
                    },
608
1
                )
609
            }
610
            // Caret sits AFTER the item — Delete acts on the next run.
611
2
            CursorAffinity::Trailing if run_idx + 1 < content.len() => delete_forward(
612
1
                content,
613
1
                &TextCursor {
614
1
                    cluster_id: GraphemeClusterId {
615
1
                        source_run: (run_idx + 1) as u32,
616
1
                        start_byte_in_run: 0,
617
1
                    },
618
1
                    affinity: CursorAffinity::Leading,
619
1
                },
620
            ),
621
1
            CursorAffinity::Trailing => (content.to_vec(), *cursor),
622
        };
623
18
    }
624

            
625
18
    if let Some(InlineContent::Text(run)) = new_content.get_mut(run_idx) {
626
        // Calculate the actual cursor byte offset based on affinity
627
16
        let byte_offset = match cursor.affinity {
628
16
            CursorAffinity::Leading => cluster_start_byte,
629
            CursorAffinity::Trailing => {
630
                // Cursor is at end of cluster - find the next grapheme boundary
631
                if cluster_start_byte >= run.text.len() {
632
                    run.text.len()
633
                } else {
634
                    run.text[cluster_start_byte..]
635
                        .grapheme_indices(true)
636
                        .next()
637
                        .map_or(run.text.len(), |(_, grapheme)| cluster_start_byte + grapheme.len())
638
                }
639
            },
640
        };
641

            
642
16
        if byte_offset < run.text.len() {
643
12
            let next_grapheme_end = run.text[byte_offset..]
644
12
                .grapheme_indices(true)
645
12
                .nth(1)
646
12
                .map_or(run.text.len(), |(i, _)| byte_offset + i);
647
12
            let mut t = String::from(&*run.text);
648
12
            t.drain(byte_offset..next_grapheme_end);
649
12
            run.text = alloc::sync::Arc::from(t.as_str());
650

            
651
            // Cursor position stays at the same byte offset but with Leading affinity
652
12
            let new_cursor = TextCursor {
653
12
                cluster_id: GraphemeClusterId {
654
12
                    source_run: run_idx as u32,
655
12
                    start_byte_in_run: byte_offset as u32,
656
12
                },
657
12
                affinity: CursorAffinity::Leading,
658
12
            };
659
12
            return (new_content, new_cursor);
660
4
        } else if run_idx < content.len() - 1 {
661
            // Handle deleting across run boundaries.
662
3
            match content.get(run_idx + 1).cloned() {
663
                // Next run is text — merge the two runs.
664
1
                Some(InlineContent::Text(next_run)) => {
665
1
                    let mut merged_text = String::from(&*run.text);
666
1
                    merged_text.push_str(&next_run.text);
667

            
668
1
                    new_content[run_idx] = InlineContent::Text(StyledRun {
669
1
                        text: alloc::sync::Arc::from(merged_text.as_str()),
670
1
                        style: run.style.clone(),
671
1
                        logical_start_byte: run.logical_start_byte,
672
1
                        source_node_id: run.source_node_id,
673
1
                    });
674
1
                    new_content.remove(run_idx + 1);
675

            
676
1
                    return (new_content, *cursor);
677
                }
678
                // Next run is a non-text item — Delete removes it.
679
                Some(_) => {
680
2
                    new_content.remove(run_idx + 1);
681
2
                    return (new_content, *cursor);
682
                }
683
                None => {}
684
            }
685
1
        }
686
2
    }
687

            
688
3
    (content.to_vec(), *cursor)
689
21
}
690

            
691
/// Edit text with different text per selection (for N-lines-to-N-cursors paste).
692
///
693
/// Each selection gets its own text inserted. Selections are processed back-to-front
694
/// to avoid index invalidation. Returns the new content and updated cursors.
695
///
696
/// # Panics
697
///
698
/// Panics if `texts.len() != selections.len()`.
699
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] // bounded layout/render numeric cast
700
4
#[must_use] pub fn edit_text_multi(
701
4
    content: &[InlineContent],
702
4
    selections: &[Selection],
703
4
    texts: &[&str],
704
4
) -> (Vec<InlineContent>, Vec<Selection>) {
705
4
    assert_eq!(
706
4
        selections.len(),
707
4
        texts.len(),
708
1
        "edit_text_multi: selections and texts must have the same length"
709
    );
710

            
711
3
    if selections.is_empty() {
712
1
        return (content.to_vec(), Vec::new());
713
2
    }
714

            
715
2
    let mut new_content = content.to_vec();
716
2
    let mut new_selections = Vec::new();
717

            
718
    // Pair selections with their text, sort back-to-front
719
2
    let mut pairs: Vec<(Selection, &str)> = selections
720
2
        .iter()
721
2
        .copied()
722
2
        .zip(texts.iter().copied())
723
2
        .collect();
724
2
    pairs.sort_by(|a, b| {
725
2
        let cursor_a = match &a.0 {
726
2
            Selection::Cursor(c) => c,
727
            Selection::Range(r) => &r.start,
728
        };
729
2
        let cursor_b = match &b.0 {
730
2
            Selection::Cursor(c) => c,
731
            Selection::Range(r) => &r.start,
732
        };
733
2
        cursor_b.cluster_id.cmp(&cursor_a.cluster_id) // Reverse sort
734
2
    });
735

            
736
6
    for (selection, text) in &pairs {
737
4
        let edit = TextEdit::Insert((*text).to_string());
738
4

            
739
4
        let edit_run = selection_start_run(selection);
740
4
        let edit_byte = selection_start_byte(selection);
741
4

            
742
4
        let old_run_len = run_text_len(&new_content, edit_run);
743
4
        let old_run_count = new_content.len();
744
4
        let (temp_content, new_cursor) =
745
4
            apply_edit_to_selection(&new_content, selection, &edit);
746
4
        let new_run_len = run_text_len(&temp_content, edit_run);
747
4
        let byte_offset_change = new_run_len as i32 - old_run_len as i32;
748
4
        let run_count_change = temp_content.len() as i32 - old_run_count as i32;
749
4

            
750
4
        adjust_cursors(&mut new_selections, edit_run, edit_byte, byte_offset_change);
751
4
        adjust_cursor_runs(&mut new_selections, edit_run, run_count_change);
752
4

            
753
4
        new_content = temp_content;
754
4
        new_selections.push(Selection::Cursor(new_cursor));
755
4
    }
756

            
757
2
    new_selections.reverse();
758
2
    (new_content, new_selections)
759
3
}
760

            
761
/// Returns the range and text that a delete operation would remove, without
762
/// actually modifying the content.
763
///
764
/// Useful for callbacks that need to inspect
765
/// pending deletes. Returns `None` if nothing would be deleted.
766
14
#[must_use] pub fn inspect_delete(
767
14
    content: &[InlineContent],
768
14
    selection: &Selection,
769
14
    forward: bool,
770
14
) -> Option<(SelectionRange, String)> {
771
14
    match selection {
772
1
        Selection::Range(range) => {
773
            // If there's already a selection, that's what would be deleted
774
1
            let deleted_text = extract_text_in_range(content, range);
775
1
            Some((*range, deleted_text))
776
        }
777
13
        Selection::Cursor(cursor) => {
778
            // No selection - would delete one grapheme cluster
779
13
            if forward {
780
7
                inspect_delete_forward(content, cursor)
781
            } else {
782
6
                inspect_delete_backward(content, cursor)
783
            }
784
        }
785
    }
786
14
}
787

            
788
/// Inspect what would be deleted by delete-forward (Delete key)
789
#[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
790
7
fn inspect_delete_forward(
791
7
    content: &[InlineContent],
792
7
    cursor: &TextCursor,
793
7
) -> Option<(SelectionRange, String)> {
794
    use unicode_segmentation::UnicodeSegmentation;
795

            
796
7
    let run_idx = cursor.cluster_id.source_run as usize;
797

            
798
7
    if let Some(InlineContent::Text(run)) = content.get(run_idx) {
799
        // Honor cursor affinity, mirroring delete_forward — a Trailing cursor
800
        // sits after its grapheme, so the raw start_byte_in_run is wrong here.
801
5
        let byte_offset = cursor_byte_offset_in_run(&run.text, cursor);
802
5
        if byte_offset < run.text.len() {
803
            // Delete within same run
804
2
            let next_grapheme_end = run.text[byte_offset..]
805
2
                .grapheme_indices(true)
806
2
                .nth(1)
807
2
                .map_or(run.text.len(), |(i, _)| byte_offset + i);
808

            
809
2
            let deleted_text = run.text[byte_offset..next_grapheme_end].to_string();
810

            
811
2
            let range = SelectionRange {
812
2
                start: *cursor,
813
2
                end: TextCursor {
814
2
                    cluster_id: GraphemeClusterId {
815
2
                        source_run: run_idx as u32,
816
2
                        start_byte_in_run: next_grapheme_end as u32,
817
2
                    },
818
2
                    affinity: CursorAffinity::Leading,
819
2
                },
820
2
            };
821

            
822
2
            return Some((range, deleted_text));
823
3
        } else if run_idx < content.len() - 1 {
824
            // Would delete across run boundary
825
2
            if let Some(InlineContent::Text(next_run)) = content.get(run_idx + 1) {
826
1
                let deleted_text = next_run.text.graphemes(true).next()?.to_string();
827

            
828
1
                let next_grapheme_end = next_run
829
1
                    .text
830
1
                    .grapheme_indices(true)
831
1
                    .nth(1)
832
1
                    .map_or(next_run.text.len(), |(i, _)| i);
833

            
834
1
                let range = SelectionRange {
835
1
                    start: *cursor,
836
1
                    end: TextCursor {
837
1
                        cluster_id: GraphemeClusterId {
838
1
                            source_run: (run_idx + 1) as u32,
839
1
                            start_byte_in_run: next_grapheme_end as u32,
840
1
                        },
841
1
                        affinity: CursorAffinity::Leading,
842
1
                    },
843
1
                };
844

            
845
1
                return Some((range, deleted_text));
846
1
            }
847
1
        }
848
2
    }
849

            
850
4
    None // At end of document, nothing to delete
851
7
}
852

            
853
/// Inspect what would be deleted by delete-backward (Backspace key)
854
#[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
855
6
fn inspect_delete_backward(
856
6
    content: &[InlineContent],
857
6
    cursor: &TextCursor,
858
6
) -> Option<(SelectionRange, String)> {
859
    use unicode_segmentation::UnicodeSegmentation;
860

            
861
6
    let run_idx = cursor.cluster_id.source_run as usize;
862

            
863
6
    if let Some(InlineContent::Text(run)) = content.get(run_idx) {
864
        // Honor cursor affinity, mirroring delete_backward — a Trailing cursor
865
        // sits after its grapheme, so the raw start_byte_in_run is wrong here.
866
4
        let byte_offset = cursor_byte_offset_in_run(&run.text, cursor);
867
4
        if byte_offset > 0 {
868
            // Delete within same run
869
1
            let prev_grapheme_start = run.text[..byte_offset]
870
1
                .grapheme_indices(true)
871
1
                .next_back()
872
1
                .map_or(0, |(i, _)| i);
873

            
874
1
            let deleted_text = run.text[prev_grapheme_start..byte_offset].to_string();
875

            
876
1
            let range = SelectionRange {
877
1
                start: TextCursor {
878
1
                    cluster_id: GraphemeClusterId {
879
1
                        source_run: run_idx as u32,
880
1
                        start_byte_in_run: prev_grapheme_start as u32,
881
1
                    },
882
1
                    affinity: CursorAffinity::Leading,
883
1
                },
884
1
                end: *cursor,
885
1
            };
886

            
887
1
            return Some((range, deleted_text));
888
3
        } else if run_idx > 0 {
889
            // Would delete across run boundary
890
2
            if let Some(InlineContent::Text(prev_run)) = content.get(run_idx - 1) {
891
1
                let deleted_text = prev_run.text.graphemes(true).next_back()?.to_string();
892

            
893
1
                let prev_grapheme_start = prev_run.text[..]
894
1
                    .grapheme_indices(true)
895
1
                    .next_back()
896
1
                    .map_or(0, |(i, _)| i);
897

            
898
1
                let range = SelectionRange {
899
1
                    start: TextCursor {
900
1
                        cluster_id: GraphemeClusterId {
901
1
                            source_run: (run_idx - 1) as u32,
902
1
                            start_byte_in_run: prev_grapheme_start as u32,
903
1
                        },
904
1
                        affinity: CursorAffinity::Leading,
905
1
                    },
906
1
                    end: *cursor,
907
1
                };
908

            
909
1
                return Some((range, deleted_text));
910
1
            }
911
1
        }
912
2
    }
913

            
914
4
    None // At start of document, nothing to delete
915
6
}
916

            
917
/// Extract the text within a selection range
918
9
fn extract_text_in_range(content: &[InlineContent], range: &SelectionRange) -> String {
919
9
    let start_run = range.start.cluster_id.source_run as usize;
920
9
    let end_run = range.end.cluster_id.source_run as usize;
921
9
    let start_byte = range.start.cluster_id.start_byte_in_run as usize;
922
9
    let end_byte = range.end.cluster_id.start_byte_in_run as usize;
923

            
924
9
    if start_run == end_run {
925
        // Single run
926
7
        if let Some(InlineContent::Text(run)) = content.get(start_run) {
927
5
            if start_byte <= end_byte && end_byte <= run.text.len() {
928
3
                return run.text[start_byte..end_byte].to_string();
929
2
            }
930
2
        }
931
    } else {
932
        // Multi-run selection (simplified - full implementation would handle images, etc.)
933
2
        let mut result = String::new();
934

            
935
6
        for (idx, item) in content.iter().enumerate() {
936
6
            if let InlineContent::Text(run) = item {
937
5
                if idx == start_run {
938
                    // First run - from start_byte to end
939
2
                    if start_byte < run.text.len() {
940
2
                        result.push_str(&run.text[start_byte..]);
941
2
                    }
942
3
                } else if idx > start_run && idx < end_run {
943
1
                    // Middle runs - entire text
944
1
                    result.push_str(&run.text);
945
2
                } else if idx == end_run {
946
                    // Last run - from 0 to end_byte
947
2
                    if end_byte <= run.text.len() {
948
2
                        result.push_str(&run.text[..end_byte]);
949
2
                    }
950
2
                    break;
951
                }
952
1
            }
953
        }
954

            
955
2
        return result;
956
    }
957

            
958
4
    String::new()
959
9
}
960

            
961
#[cfg(test)]
962
#[allow(clippy::float_cmp, clippy::too_many_lines)]
963
mod autotest_generated {
964
    use std::sync::Arc;
965

            
966
    use unicode_segmentation::UnicodeSegmentation;
967

            
968
    use super::*;
969
    use crate::text3::cache::StyleProperties;
970

            
971
    // ---------------------------------------------------------------- helpers
972

            
973
    fn style_a() -> Arc<StyleProperties> {
974
        Arc::new(StyleProperties::default())
975
    }
976

            
977
    /// A style that compares unequal to [`style_a`] (`StyleProperties: PartialEq`),
978
    /// so `delete_range`'s style-based run merge can be exercised both ways.
979
    fn style_b() -> Arc<StyleProperties> {
980
        Arc::new(StyleProperties {
981
            font_size_px: 99.0,
982
            ..StyleProperties::default()
983
        })
984
    }
985

            
986
    fn text(s: &str) -> InlineContent {
987
        InlineContent::Text(StyledRun {
988
            text: Arc::from(s),
989
            style: style_a(),
990
            logical_start_byte: 0,
991
            source_node_id: None,
992
        })
993
    }
994

            
995
    fn text_styled(s: &str, style: Arc<StyleProperties>) -> InlineContent {
996
        InlineContent::Text(StyledRun {
997
            text: Arc::from(s),
998
            style,
999
            logical_start_byte: 0,
            source_node_id: None,
        })
    }
    /// A non-text inline item (stands in for an inline image / object / shape).
    /// `Tab` is the cheapest such variant to build — it carries only a style.
    fn obj() -> InlineContent {
        InlineContent::Tab { style: style_a() }
    }
    /// `InlineContent` has no `PartialEq`, so compare a printable projection:
    /// text runs render as their text, everything else as `<obj>`.
    fn dump(content: &[InlineContent]) -> Vec<String> {
        content
            .iter()
            .map(|c| match c {
                InlineContent::Text(r) => String::from(&*r.text),
                _ => "<obj>".to_string(),
            })
            .collect()
    }
    fn lead(run: u32, byte: u32) -> TextCursor {
        TextCursor {
            cluster_id: GraphemeClusterId {
                source_run: run,
                start_byte_in_run: byte,
            },
            affinity: CursorAffinity::Leading,
        }
    }
    fn trail(run: u32, byte: u32) -> TextCursor {
        TextCursor {
            cluster_id: GraphemeClusterId {
                source_run: run,
                start_byte_in_run: byte,
            },
            affinity: CursorAffinity::Trailing,
        }
    }
    fn range_sel(start: TextCursor, end: TextCursor) -> Selection {
        Selection::Range(SelectionRange { start, end })
    }
    fn cursor_of(sel: &Selection) -> TextCursor {
        match sel {
            Selection::Cursor(c) => *c,
            Selection::Range(r) => r.start,
        }
    }
    /// A ZWJ emoji family: 👨(4) + ZWJ(3) + 👩(4) + ZWJ(3) + 👧(4) = 18 bytes,
    /// but exactly ONE extended grapheme cluster.
    const FAMILY: &str = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}";
    #[test]
    fn family_constant_is_one_grapheme_of_18_bytes() {
        // Guards the fixture the grapheme tests below rely on.
        assert_eq!(FAMILY.len(), 18);
        assert_eq!(FAMILY.graphemes(true).count(), 1);
    }
    // ------------------------------------------- selection_start_run / _byte
    #[test]
    fn selection_start_run_and_byte_read_the_cursor() {
        let sel = Selection::Cursor(lead(3, 7));
        assert_eq!(selection_start_run(&sel), 3);
        assert_eq!(selection_start_byte(&sel), 7);
    }
    #[test]
    fn selection_start_run_and_byte_read_range_start_not_end() {
        // Even for a BACKWARD range (start after end) the raw `start` is reported.
        let sel = range_sel(lead(9, 40), lead(1, 2));
        assert_eq!(selection_start_run(&sel), 9);
        assert_eq!(selection_start_byte(&sel), 40);
    }
    #[test]
    fn selection_start_accessors_survive_u32_max() {
        let sel = Selection::Cursor(trail(u32::MAX, u32::MAX));
        assert_eq!(selection_start_run(&sel), u32::MAX);
        assert_eq!(selection_start_byte(&sel), u32::MAX);
        let sel = range_sel(lead(u32::MAX, u32::MAX), lead(0, 0));
        assert_eq!(selection_start_run(&sel), u32::MAX);
        assert_eq!(selection_start_byte(&sel), u32::MAX);
    }
    // ------------------------------------------ sort_selections_back_to_front
    #[test]
    fn sort_back_to_front_empty_and_single() {
        assert!(sort_selections_back_to_front(&[]).is_empty());
        let one = [Selection::Cursor(lead(0, 0))];
        assert_eq!(sort_selections_back_to_front(&one).len(), 1);
    }
    #[test]
    fn sort_back_to_front_is_descending_by_cluster_id() {
        let sels = [
            Selection::Cursor(lead(0, 0)),
            Selection::Cursor(lead(2, 5)),
            Selection::Cursor(lead(1, 3)),
            Selection::Cursor(lead(2, 1)),
        ];
        let sorted = sort_selections_back_to_front(&sels);
        let keys: Vec<(u32, u32)> = sorted
            .iter()
            .map(|s| {
                let c = cursor_of(s).cluster_id;
                (c.source_run, c.start_byte_in_run)
            })
            .collect();
        assert_eq!(keys, vec![(2, 5), (2, 1), (1, 3), (0, 0)]);
        // Monotonically non-increasing — the invariant the multi-cursor edit loop
        // depends on for its byte offsets to stay valid.
        assert!(keys.windows(2).all(|w| w[0] >= w[1]));
    }
    #[test]
    fn sort_back_to_front_is_a_permutation_with_duplicates() {
        let sels = [
            Selection::Cursor(lead(1, 1)),
            Selection::Cursor(lead(1, 1)),
            Selection::Cursor(lead(0, 0)),
        ];
        let sorted = sort_selections_back_to_front(&sels);
        assert_eq!(sorted.len(), 3);
        let mut got: Vec<Selection> = sorted;
        let mut want: Vec<Selection> = sels.to_vec();
        got.sort();
        want.sort();
        assert_eq!(got, want);
    }
    #[test]
    fn sort_back_to_front_keys_ranges_on_their_start() {
        // Range starts at run 5; the plain cursor is at run 0 -> range sorts first.
        let sels = [
            Selection::Cursor(lead(0, 0)),
            range_sel(lead(5, 0), lead(0, 0)),
        ];
        let sorted = sort_selections_back_to_front(&sels);
        assert!(matches!(sorted[0], Selection::Range(_)));
        assert!(matches!(sorted[1], Selection::Cursor(_)));
    }
    #[test]
    fn sort_back_to_front_handles_u32_max_keys() {
        let sels = [
            Selection::Cursor(lead(u32::MAX, u32::MAX)),
            Selection::Cursor(lead(0, 0)),
        ];
        let sorted = sort_selections_back_to_front(&sels);
        assert_eq!(cursor_of(&sorted[0]).cluster_id.source_run, u32::MAX);
    }
    // ------------------------------------------------------- adjust_cursors
    fn byte_at(sels: &[Selection], i: usize) -> u32 {
        cursor_of(&sels[i]).cluster_id.start_byte_in_run
    }
    #[test]
    fn adjust_cursors_empty_slice_is_a_noop() {
        let mut sels: Vec<Selection> = Vec::new();
        adjust_cursors(&mut sels, 0, 0, i32::MIN);
        assert!(sels.is_empty());
    }
    #[test]
    fn adjust_cursors_zero_change_leaves_everything_alone() {
        let mut sels = vec![
            Selection::Cursor(lead(0, 0)),
            Selection::Cursor(lead(0, 10)),
        ];
        adjust_cursors(&mut sels, 0, 0, 0);
        assert_eq!(byte_at(&sels, 0), 0);
        assert_eq!(byte_at(&sels, 1), 10);
    }
    #[test]
    fn adjust_cursors_only_shifts_at_or_after_edit_byte_in_the_edit_run() {
        let mut sels = vec![
            Selection::Cursor(lead(0, 2)),  // before edit_byte -> untouched
            Selection::Cursor(lead(0, 5)),  // AT edit_byte     -> shifted
            Selection::Cursor(lead(0, 9)),  // after edit_byte  -> shifted
            Selection::Cursor(lead(1, 0)),  // other run        -> untouched
        ];
        adjust_cursors(&mut sels, 0, 5, 3);
        assert_eq!(byte_at(&sels, 0), 2);
        assert_eq!(byte_at(&sels, 1), 8);
        assert_eq!(byte_at(&sels, 2), 12);
        assert_eq!(byte_at(&sels, 3), 0);
    }
    #[test]
    fn adjust_cursors_negative_change_clamps_at_zero() {
        let mut sels = vec![Selection::Cursor(lead(0, 3))];
        adjust_cursors(&mut sels, 0, 0, -100);
        assert_eq!(byte_at(&sels, 0), 0, "documented clamp-to-zero");
    }
    #[test]
    fn adjust_cursors_i32_extremes_do_not_panic() {
        // i32::MAX applied to byte 0 saturates the offset, not the process.
        let mut sels = vec![Selection::Cursor(lead(0, 0))];
        adjust_cursors(&mut sels, 0, 0, i32::MAX);
        assert_eq!(byte_at(&sels, 0), i32::MAX as u32);
        // i32::MIN applied to byte 0 clamps to zero rather than wrapping.
        let mut sels = vec![Selection::Cursor(lead(0, 0))];
        adjust_cursors(&mut sels, 0, 0, i32::MIN);
        assert_eq!(byte_at(&sels, 0), 0);
    }
    #[test]
    fn adjust_cursors_u32_max_byte_collapses_to_zero() {
        // NOTE (reported): `start_byte_in_run as i32` WRAPS — u32::MAX becomes -1,
        // so a no-op (+0) adjustment silently relocates the cursor to byte 0.
        // Not reachable from real 2 GiB-run content, but it is the current behavior.
        let mut sels = vec![Selection::Cursor(lead(0, u32::MAX))];
        adjust_cursors(&mut sels, 0, 0, 0);
        assert_eq!(byte_at(&sels, 0), 0);
    }
    #[test]
    fn adjust_cursors_never_touches_range_selections() {
        let mut sels = vec![range_sel(lead(0, 4), lead(0, 8))];
        adjust_cursors(&mut sels, 0, 0, 100);
        match &sels[0] {
            Selection::Range(r) => {
                assert_eq!(r.start.cluster_id.start_byte_in_run, 4);
                assert_eq!(r.end.cluster_id.start_byte_in_run, 8);
            }
            Selection::Cursor(_) => panic!("range must stay a range"),
        }
    }
    // --------------------------------------------------- adjust_cursor_runs
    fn run_at(sels: &[Selection], i: usize) -> u32 {
        cursor_of(&sels[i]).cluster_id.source_run
    }
    #[test]
    fn adjust_cursor_runs_zero_change_returns_early() {
        let mut sels = vec![Selection::Cursor(lead(u32::MAX, 0))];
        adjust_cursor_runs(&mut sels, 0, 0);
        assert_eq!(run_at(&sels, 0), u32::MAX, "zero change must not touch runs");
    }
    #[test]
    fn adjust_cursor_runs_shifts_only_runs_strictly_after_the_boundary() {
        let mut sels = vec![
            Selection::Cursor(lead(0, 0)), // before boundary -> untouched
            Selection::Cursor(lead(1, 0)), // AT boundary     -> untouched
            Selection::Cursor(lead(2, 0)), // after           -> -1
            Selection::Cursor(lead(3, 0)), // after           -> -1
        ];
        adjust_cursor_runs(&mut sels, 1, -1);
        assert_eq!(run_at(&sels, 0), 0);
        assert_eq!(run_at(&sels, 1), 1);
        assert_eq!(run_at(&sels, 2), 1);
        assert_eq!(run_at(&sels, 3), 2);
    }
    #[test]
    fn adjust_cursor_runs_positive_change_shifts_up() {
        let mut sels = vec![Selection::Cursor(lead(2, 0))];
        adjust_cursor_runs(&mut sels, 0, 3);
        assert_eq!(run_at(&sels, 0), 5);
    }
    #[test]
    fn adjust_cursor_runs_negative_overshoot_clamps_to_the_boundary_run() {
        let mut sels = vec![Selection::Cursor(lead(3, 0))];
        adjust_cursor_runs(&mut sels, 1, -100);
        assert_eq!(run_at(&sels, 0), 1, "never drops below the surviving run");
    }
    #[test]
    fn adjust_cursor_runs_i32_min_clamps_instead_of_wrapping() {
        // 3 + i32::MIN stays inside i32 (no overflow) and the clamp catches it.
        let mut sels = vec![Selection::Cursor(lead(3, 0))];
        adjust_cursor_runs(&mut sels, 2, i32::MIN);
        assert_eq!(run_at(&sels, 0), 2);
        let mut sels = vec![Selection::Cursor(lead(1, 0))];
        adjust_cursor_runs(&mut sels, 0, i32::MIN);
        assert_eq!(run_at(&sels, 0), 0);
    }
    #[test]
    fn adjust_cursor_runs_i32_max_is_inert_when_no_cursor_qualifies() {
        // Every cursor is at/below the boundary, so the huge delta is never applied.
        let mut sels = vec![
            Selection::Cursor(lead(0, 0)),
            Selection::Cursor(lead(5, 0)),
        ];
        adjust_cursor_runs(&mut sels, 5, i32::MAX);
        assert_eq!(run_at(&sels, 0), 0);
        assert_eq!(run_at(&sels, 1), 5);
    }
    #[test]
    fn adjust_cursor_runs_never_touches_range_selections() {
        let mut sels = vec![range_sel(lead(9, 0), lead(9, 1))];
        adjust_cursor_runs(&mut sels, 0, -5);
        match &sels[0] {
            Selection::Range(r) => assert_eq!(r.start.cluster_id.source_run, 9),
            Selection::Cursor(_) => panic!("range must stay a range"),
        }
    }
    // ---------------------------------------------------------- run_text_len
    #[test]
    fn run_text_len_counts_bytes_not_chars() {
        let content = vec![text("héllo")]; // é is 2 bytes -> 6 bytes, 5 chars
        assert_eq!(run_text_len(&content, 0), 6);
        assert_eq!(content_text_chars(&content), 5);
    }
    fn content_text_chars(content: &[InlineContent]) -> usize {
        content
            .iter()
            .map(|c| match c {
                InlineContent::Text(r) => r.text.chars().count(),
                _ => 0,
            })
            .sum()
    }
    #[test]
    fn run_text_len_zero_for_empty_missing_and_non_text_runs() {
        let content = vec![text(""), obj()];
        assert_eq!(run_text_len(&content, 0), 0, "empty text run");
        assert_eq!(run_text_len(&content, 1), 0, "non-text run");
        assert_eq!(run_text_len(&content, 2), 0, "one past the end");
        assert_eq!(run_text_len(&content, u32::MAX), 0, "u32::MAX index");
        assert_eq!(run_text_len(&[], 0), 0, "empty content");
    }
    // ------------------------------------------------------------- edit_text
    #[test]
    fn edit_text_empty_selections_returns_content_unchanged() {
        let content = vec![text("hello")];
        let (new_content, sels) = edit_text(&content, &[], &TextEdit::Insert("x".into()));
        assert_eq!(dump(&new_content), vec!["hello"]);
        assert!(sels.is_empty());
    }
    #[test]
    fn edit_text_on_empty_content_does_not_panic() {
        let (new_content, sels) = edit_text(
            &[],
            &[Selection::Cursor(lead(0, 0))],
            &TextEdit::Insert("x".into()),
        );
        assert!(new_content.is_empty());
        assert_eq!(sels.len(), 1, "the cursor survives, unmoved");
        assert_eq!(cursor_of(&sels[0]), lead(0, 0));
    }
    #[test]
    fn edit_text_out_of_range_cursor_is_a_noop_not_a_panic() {
        let content = vec![text("hi")];
        let (new_content, sels) = edit_text(
            &content,
            &[Selection::Cursor(lead(u32::MAX, 0))],
            &TextEdit::DeleteBackward,
        );
        assert_eq!(dump(&new_content), vec!["hi"]);
        assert_eq!(sels.len(), 1);
    }
    #[test]
    fn edit_text_multi_cursor_insert_keeps_both_cursors_correct() {
        // Two cursors in the same run: the earlier edit must shift the later cursor
        // by the ACTUAL byte delta (this is what adjust_cursors exists for).
        let content = vec![text("hello")];
        let sels = [
            Selection::Cursor(lead(0, 0)),
            Selection::Cursor(lead(0, 3)),
        ];
        let (new_content, new_sels) = edit_text(&content, &sels, &TextEdit::Insert("X".into()));
        assert_eq!(dump(&new_content), vec!["XhelXlo"]);
        assert_eq!(cursor_of(&new_sels[0]), lead(0, 1));
        assert_eq!(cursor_of(&new_sels[1]), lead(0, 5));
    }
    #[test]
    fn edit_text_multi_cursor_insert_shifts_by_multibyte_length_not_one() {
        // Inserting a 4-byte emoji must move the trailing cursor by 4 bytes.
        let content = vec![text("ab")];
        let sels = [
            Selection::Cursor(lead(0, 0)),
            Selection::Cursor(lead(0, 2)),
        ];
        let (new_content, new_sels) = edit_text(&content, &sels, &TextEdit::Insert("👍".into()));
        assert_eq!(dump(&new_content), vec!["👍ab👍"]);
        assert_eq!(cursor_of(&new_sels[0]), lead(0, 4));
        assert_eq!(cursor_of(&new_sels[1]), lead(0, 10)); // 4 + "ab" + 4
    }
    #[test]
    fn edit_text_backspace_with_a_range_deletes_the_range_only() {
        // Regression guard for the documented rule: Backspace on a selection removes
        // the selection, NOT the selection plus one more grapheme.
        let content = vec![text("hello")];
        let sel = [range_sel(lead(0, 1), lead(0, 3))];
        let (new_content, _) = edit_text(&content, &sel, &TextEdit::DeleteBackward);
        assert_eq!(dump(&new_content), vec!["hlo"]);
    }
    // ------------------------------------------------ apply_edit_to_selection
    #[test]
    fn apply_edit_range_insert_replaces_the_range() {
        let content = vec![text("hello")];
        let sel = range_sel(lead(0, 1), lead(0, 4));
        let (new_content, cursor) =
            apply_edit_to_selection(&content, &sel, &TextEdit::Insert("EY".into()));
        assert_eq!(dump(&new_content), vec!["hEYo"]);
        assert_eq!(cursor, lead(0, 3));
    }
    #[test]
    fn apply_edit_range_delete_forward_deletes_range_only() {
        let content = vec![text("hello")];
        let sel = range_sel(lead(0, 1), lead(0, 3));
        let (new_content, cursor) =
            apply_edit_to_selection(&content, &sel, &TextEdit::DeleteForward);
        assert_eq!(dump(&new_content), vec!["hlo"]);
        assert_eq!(cursor, lead(0, 1));
    }
    #[test]
    fn apply_edit_cursor_insert_of_empty_string_only_moves_the_caret() {
        let content = vec![text("hello")];
        let sel = Selection::Cursor(lead(0, 2));
        let (new_content, cursor) =
            apply_edit_to_selection(&content, &sel, &TextEdit::Insert(String::new()));
        assert_eq!(dump(&new_content), vec!["hello"]);
        assert_eq!(cursor, lead(0, 2));
    }
    // ------------------------------------------------ cursor_byte_offset_in_run
    #[test]
    fn cursor_byte_offset_leading_clamps_past_the_end() {
        assert_eq!(cursor_byte_offset_in_run("hi", &lead(0, 999)), 2);
        assert_eq!(cursor_byte_offset_in_run("hi", &lead(0, u32::MAX)), 2);
        assert_eq!(cursor_byte_offset_in_run("", &lead(0, 5)), 0);
    }
    #[test]
    fn cursor_byte_offset_trailing_clamps_past_the_end() {
        assert_eq!(cursor_byte_offset_in_run("hi", &trail(0, 2)), 2);
        assert_eq!(cursor_byte_offset_in_run("hi", &trail(0, u32::MAX)), 2);
        assert_eq!(cursor_byte_offset_in_run("", &trail(0, 0)), 0);
    }
    #[test]
    fn cursor_byte_offset_trailing_lands_after_the_whole_grapheme() {
        // Combining sequence: "e" + U+0301 is one cluster of 3 bytes.
        assert_eq!(cursor_byte_offset_in_run("e\u{0301}x", &trail(0, 0)), 3);
        // A 4-byte astral char.
        assert_eq!(cursor_byte_offset_in_run("👍x", &trail(0, 0)), 4);
        // A ZWJ emoji family is ONE cluster — a char-wise implementation would
        // return 4 here instead of 18.
        assert_eq!(cursor_byte_offset_in_run(FAMILY, &trail(0, 0)), 18);
    }
    #[test]
    fn cursor_byte_offset_leading_is_the_raw_offset() {
        assert_eq!(cursor_byte_offset_in_run(FAMILY, &lead(0, 0)), 0);
        assert_eq!(cursor_byte_offset_in_run("abc", &lead(0, 1)), 1);
    }
    // ---------------------------------------------------------- delete_range
    #[test]
    fn delete_range_within_one_run() {
        let content = vec![text("hello")];
        let r = SelectionRange {
            start: lead(0, 1),
            end: lead(0, 3),
        };
        let (new_content, cursor) = delete_range(&content, &r);
        assert_eq!(dump(&new_content), vec!["hlo"]);
        assert_eq!(cursor, lead(0, 1));
    }
    #[test]
    fn delete_range_backward_range_is_normalized() {
        // Right-to-left selection (Shift+Left / Shift+Home): start is AFTER end.
        // It must delete the same bytes as the forward range, not silently no-op.
        let content = vec![text("hello")];
        let backward = SelectionRange {
            start: lead(0, 3),
            end: lead(0, 1),
        };
        let (new_content, cursor) = delete_range(&content, &backward);
        assert_eq!(dump(&new_content), vec!["hlo"]);
        assert_eq!(cursor, lead(0, 1), "caret collapses to the LOW end");
    }
    #[test]
    fn delete_range_collapsed_range_deletes_nothing() {
        let content = vec![text("hello")];
        let r = SelectionRange {
            start: lead(0, 2),
            end: lead(0, 2),
        };
        let (new_content, cursor) = delete_range(&content, &r);
        assert_eq!(dump(&new_content), vec!["hello"]);
        assert_eq!(cursor, lead(0, 2));
    }
    #[test]
    fn delete_range_select_all_with_trailing_end_covers_the_last_cluster() {
        // The end cursor of a select-all sits Trailing on the last cluster; the
        // affinity-aware offset is what makes the final grapheme part of the range.
        let content = vec![text("hello")];
        let r = SelectionRange {
            start: lead(0, 0),
            end: trail(0, 4),
        };
        let (new_content, cursor) = delete_range(&content, &r);
        assert_eq!(dump(&new_content), vec![""]);
        assert_eq!(cursor, lead(0, 0));
    }
    #[test]
    fn delete_range_spanning_runs_merges_matching_styles() {
        let content = vec![text("abc"), text("def")];
        let r = SelectionRange {
            start: lead(0, 1),
            end: lead(1, 2),
        };
        let (new_content, cursor) = delete_range(&content, &r);
        assert_eq!(dump(&new_content), vec!["af"], "same style -> one run");
        assert_eq!(cursor, lead(0, 1));
    }
    #[test]
    fn delete_range_spanning_runs_keeps_differing_styles_apart() {
        let content = vec![text_styled("abc", style_a()), text_styled("def", style_b())];
        let r = SelectionRange {
            start: lead(0, 1),
            end: lead(1, 2),
        };
        let (new_content, cursor) = delete_range(&content, &r);
        assert_eq!(dump(&new_content), vec!["a", "f"], "styles differ -> no merge");
        assert_eq!(cursor, lead(0, 1));
    }
    #[test]
    fn delete_range_drops_the_runs_strictly_between_the_boundaries() {
        let content = vec![text("abc"), text("XYZ"), obj(), text("def")];
        let r = SelectionRange {
            start: lead(0, 1),
            end: lead(3, 2),
        };
        let (new_content, _) = delete_range(&content, &r);
        assert_eq!(dump(&new_content), vec!["af"], "middle text AND obj dropped");
    }
    #[test]
    fn delete_range_over_a_single_non_text_item_removes_it() {
        let content = vec![text("ab"), obj(), text("cd")];
        // start != end (affinity differs) so the guard against a zero-width delete passes.
        let r = SelectionRange {
            start: lead(1, 0),
            end: trail(1, 0),
        };
        let (new_content, cursor) = delete_range(&content, &r);
        assert_eq!(dump(&new_content), vec!["ab", "cd"]);
        assert_eq!(cursor, lead(1, 0));
    }
    #[test]
    fn delete_range_collapsed_on_a_non_text_item_keeps_it() {
        let content = vec![text("ab"), obj()];
        let r = SelectionRange {
            start: lead(1, 0),
            end: lead(1, 0),
        };
        let (new_content, _) = delete_range(&content, &r);
        assert_eq!(dump(&new_content), vec!["ab", "<obj>"]);
    }
    #[test]
    fn delete_range_out_of_bounds_runs_do_not_panic() {
        let content = vec![text("ab")];
        // Both ends past the end (same-run path).
        let r = SelectionRange {
            start: lead(99, 0),
            end: lead(99, 5),
        };
        let (new_content, _) = delete_range(&content, &r);
        assert_eq!(dump(&new_content), vec!["ab"]);
        // Multi-run path with a bogus `hi_run` — the drain must be clamped.
        let r = SelectionRange {
            start: lead(0, 0),
            end: lead(u32::MAX, 0),
        };
        let (new_content, cursor) = delete_range(&content, &r);
        assert_eq!(dump(&new_content), vec![""]);
        assert_eq!(cursor, lead(0, 0));
    }
    #[test]
    fn delete_range_backward_across_runs_is_normalized() {
        let content = vec![text("abc"), text("def")];
        let backward = SelectionRange {
            start: lead(1, 2),
            end: lead(0, 1),
        };
        let (new_content, cursor) = delete_range(&content, &backward);
        assert_eq!(dump(&new_content), vec!["af"]);
        assert_eq!(cursor, lead(0, 1));
    }
    // ---------------------------------------------------------- insert_text
    #[test]
    fn insert_text_leading_inserts_before_the_cluster() {
        let content = vec![text("hello")];
        let (new_content, cursor) = insert_text(&content, &lead(0, 2), "XY");
        assert_eq!(dump(&new_content), vec!["heXYllo"]);
        assert_eq!(cursor, lead(0, 4));
    }
    #[test]
    fn insert_text_trailing_inserts_after_the_whole_grapheme() {
        // Trailing on the 4-byte emoji must land at byte 4, not byte 1.
        let content = vec![text("👍z")];
        let (new_content, cursor) = insert_text(&content, &trail(0, 0), "X");
        assert_eq!(dump(&new_content), vec!["👍Xz"]);
        assert_eq!(cursor, lead(0, 5));
    }
    #[test]
    fn insert_text_trailing_past_the_end_appends() {
        let content = vec![text("hi")];
        let (new_content, cursor) = insert_text(&content, &trail(0, 999), "!");
        assert_eq!(dump(&new_content), vec!["hi!"]);
        assert_eq!(cursor, lead(0, 3));
    }
    #[test]
    fn insert_text_leading_past_the_end_is_a_noop() {
        // Asymmetry with the Trailing case above: a Leading offset beyond the run
        // is NOT clamped, the insert is dropped and the caret is returned as-is.
        let content = vec![text("hi")];
        let (new_content, cursor) = insert_text(&content, &lead(0, 999), "!");
        assert_eq!(dump(&new_content), vec!["hi"]);
        assert_eq!(cursor, lead(0, 999));
    }
    #[test]
    fn insert_text_into_missing_or_non_text_run_is_a_noop() {
        let content = vec![obj()];
        let (new_content, cursor) = insert_text(&content, &lead(0, 0), "x");
        assert_eq!(dump(&new_content), vec!["<obj>"]);
        assert_eq!(cursor, lead(0, 0));
        let (new_content, cursor) = insert_text(&content, &lead(u32::MAX, 0), "x");
        assert_eq!(dump(&new_content), vec!["<obj>"]);
        assert_eq!(cursor, lead(u32::MAX, 0));
        let (new_content, _) = insert_text(&[], &lead(0, 0), "x");
        assert!(new_content.is_empty());
    }
    #[test]
    fn insert_text_empty_string_leaves_the_text_alone() {
        let content = vec![text("hi")];
        let (new_content, cursor) = insert_text(&content, &lead(0, 1), "");
        assert_eq!(dump(&new_content), vec!["hi"]);
        assert_eq!(cursor, lead(0, 1));
    }
    #[test]
    fn insert_text_cursor_advances_by_bytes_not_chars() {
        let content = vec![text("")];
        let (new_content, cursor) = insert_text(&content, &lead(0, 0), FAMILY);
        assert_eq!(dump(&new_content), vec![FAMILY]);
        assert_eq!(cursor, lead(0, 18));
    }
    #[test]
    fn insert_text_of_a_huge_string_does_not_panic() {
        let big = "a".repeat(200_000);
        let content = vec![text("hi")];
        let (new_content, cursor) = insert_text(&content, &lead(0, 1), &big);
        assert_eq!(run_text_len(&new_content, 0), 200_002);
        assert_eq!(cursor, lead(0, 200_001));
    }
    // ------------------------------------------------------- delete_backward
    #[test]
    fn delete_backward_on_empty_content_is_a_noop() {
        let (new_content, cursor) = delete_backward(&[], &lead(0, 0));
        assert!(new_content.is_empty());
        assert_eq!(cursor, lead(0, 0));
    }
    #[test]
    fn delete_backward_at_the_start_of_the_document_is_a_noop() {
        let content = vec![text("hi")];
        let (new_content, cursor) = delete_backward(&content, &lead(0, 0));
        assert_eq!(dump(&new_content), vec!["hi"]);
        assert_eq!(cursor, lead(0, 0));
    }
    #[test]
    fn delete_backward_removes_a_whole_grapheme_cluster() {
        let content = vec![text(&format!("a{FAMILY}"))];
        let (new_content, cursor) = delete_backward(&content, &lead(0, 19));
        assert_eq!(dump(&new_content), vec!["a"], "all 18 bytes go at once");
        assert_eq!(cursor, lead(0, 1));
    }
    #[test]
    fn delete_backward_trailing_affinity_removes_the_current_cluster() {
        let content = vec![text("ab")];
        let (new_content, cursor) = delete_backward(&content, &trail(0, 0));
        assert_eq!(dump(&new_content), vec!["b"]);
        assert_eq!(cursor, lead(0, 0));
    }
    #[test]
    fn delete_backward_merges_across_a_run_boundary() {
        let content = vec![text("ab"), text("cd")];
        let (new_content, cursor) = delete_backward(&content, &lead(1, 0));
        assert_eq!(dump(&new_content), vec!["abcd"]);
        assert_eq!(cursor, lead(0, 2), "caret sits at the join point");
    }
    #[test]
    fn delete_backward_removes_a_non_text_item_sitting_before_the_caret() {
        let content = vec![text("ab"), obj(), text("cd")];
        let (new_content, cursor) = delete_backward(&content, &lead(2, 0));
        assert_eq!(dump(&new_content), vec!["ab", "cd"]);
        assert_eq!(cursor, lead(1, 0));
    }
    #[test]
    fn delete_backward_with_the_caret_after_a_non_text_item_removes_the_item() {
        let content = vec![text("ab"), obj()];
        let (new_content, _) = delete_backward(&content, &trail(1, 0));
        assert_eq!(dump(&new_content), vec!["ab"]);
    }
    #[test]
    fn delete_backward_with_the_caret_before_a_non_text_item_acts_on_the_previous_run() {
        let content = vec![text("ab"), obj()];
        let (new_content, cursor) = delete_backward(&content, &lead(1, 0));
        assert_eq!(dump(&new_content), vec!["a", "<obj>"], "the item survives");
        assert_eq!(cursor, lead(0, 1));
    }
    #[test]
    fn delete_backward_before_a_leading_non_text_item_at_run_zero_is_a_noop() {
        let content = vec![obj()];
        let (new_content, cursor) = delete_backward(&content, &lead(0, 0));
        assert_eq!(dump(&new_content), vec!["<obj>"]);
        assert_eq!(cursor, lead(0, 0));
    }
    #[test]
    fn delete_backward_out_of_range_run_is_a_noop() {
        let content = vec![text("hi")];
        let (new_content, cursor) = delete_backward(&content, &lead(u32::MAX, u32::MAX));
        assert_eq!(dump(&new_content), vec!["hi"]);
        assert_eq!(cursor, lead(u32::MAX, u32::MAX));
    }
    // -------------------------------------------------------- delete_forward
    #[test]
    fn delete_forward_on_empty_content_is_a_noop() {
        let (new_content, cursor) = delete_forward(&[], &lead(0, 0));
        assert!(new_content.is_empty());
        assert_eq!(cursor, lead(0, 0));
    }
    #[test]
    fn delete_forward_at_the_end_of_the_document_is_a_noop() {
        let content = vec![text("hi")];
        let (new_content, cursor) = delete_forward(&content, &lead(0, 2));
        assert_eq!(dump(&new_content), vec!["hi"]);
        assert_eq!(cursor, lead(0, 2));
    }
    #[test]
    fn delete_forward_removes_a_whole_grapheme_cluster() {
        let content = vec![text(&format!("{FAMILY}z"))];
        let (new_content, cursor) = delete_forward(&content, &lead(0, 0));
        assert_eq!(dump(&new_content), vec!["z"]);
        assert_eq!(cursor, lead(0, 0));
    }
    #[test]
    fn delete_forward_merges_across_a_run_boundary() {
        let content = vec![text("ab"), text("cd")];
        let (new_content, cursor) = delete_forward(&content, &lead(0, 2));
        assert_eq!(dump(&new_content), vec!["abcd"]);
        assert_eq!(cursor, lead(0, 2));
    }
    #[test]
    fn delete_forward_removes_a_non_text_item_sitting_after_the_caret() {
        let content = vec![text("ab"), obj()];
        let (new_content, _) = delete_forward(&content, &lead(0, 2));
        assert_eq!(dump(&new_content), vec!["ab"]);
    }
    #[test]
    fn delete_forward_with_the_caret_before_a_non_text_item_removes_the_item() {
        let content = vec![obj(), text("ab")];
        let (new_content, cursor) = delete_forward(&content, &lead(0, 0));
        assert_eq!(dump(&new_content), vec!["ab"]);
        assert_eq!(cursor, lead(0, 0));
    }
    #[test]
    fn delete_forward_with_the_caret_after_a_non_text_item_acts_on_the_next_run() {
        let content = vec![obj(), text("ab")];
        let (new_content, cursor) = delete_forward(&content, &trail(0, 0));
        assert_eq!(dump(&new_content), vec!["<obj>", "b"], "the item survives");
        assert_eq!(cursor, lead(1, 0));
    }
    #[test]
    fn delete_forward_after_a_trailing_non_text_item_at_the_last_run_is_a_noop() {
        let content = vec![text("ab"), obj()];
        let (new_content, cursor) = delete_forward(&content, &trail(1, 0));
        assert_eq!(dump(&new_content), vec!["ab", "<obj>"]);
        assert_eq!(cursor, trail(1, 0));
    }
    #[test]
    fn delete_forward_out_of_range_run_is_a_noop() {
        let content = vec![text("hi")];
        let (new_content, cursor) = delete_forward(&content, &lead(u32::MAX, u32::MAX));
        assert_eq!(dump(&new_content), vec!["hi"]);
        assert_eq!(cursor, lead(u32::MAX, u32::MAX));
    }
    // ------------------------------------------------------- edit_text_multi
    #[test]
    #[should_panic(expected = "same length")]
    fn edit_text_multi_panics_on_a_length_mismatch() {
        // Documented in the function's `# Panics` section.
        let content = vec![text("hi")];
        let sels = [Selection::Cursor(lead(0, 0))];
        let _ = edit_text_multi(&content, &sels, &["a", "b"]);
    }
    #[test]
    fn edit_text_multi_with_no_selections_returns_content_unchanged() {
        let content = vec![text("hi")];
        let (new_content, sels) = edit_text_multi(&content, &[], &[]);
        assert_eq!(dump(&new_content), vec!["hi"]);
        assert!(sels.is_empty());
    }
    #[test]
    fn edit_text_multi_gives_each_cursor_its_own_text() {
        let content = vec![text("ab")];
        let sels = [
            Selection::Cursor(lead(0, 0)),
            Selection::Cursor(lead(0, 2)),
        ];
        let (new_content, new_sels) = edit_text_multi(&content, &sels, &["X", "Y"]);
        assert_eq!(dump(&new_content), vec!["XabY"]);
        assert_eq!(cursor_of(&new_sels[0]), lead(0, 1));
        assert_eq!(cursor_of(&new_sels[1]), lead(0, 4));
    }
    #[test]
    fn edit_text_multi_with_empty_texts_only_moves_the_carets() {
        let content = vec![text("ab")];
        let sels = [
            Selection::Cursor(lead(0, 0)),
            Selection::Cursor(lead(0, 1)),
        ];
        let (new_content, new_sels) = edit_text_multi(&content, &sels, &["", ""]);
        assert_eq!(dump(&new_content), vec!["ab"]);
        assert_eq!(new_sels.len(), 2);
    }
    // ---------------------------------------------------------- inspect_delete
    #[test]
    fn inspect_delete_forward_at_the_end_of_the_document_is_none() {
        let content = vec![text("hi")];
        assert!(inspect_delete(&content, &Selection::Cursor(lead(0, 2)), true).is_none());
        assert!(inspect_delete(&[], &Selection::Cursor(lead(0, 0)), true).is_none());
    }
    #[test]
    fn inspect_delete_backward_at_the_start_of_the_document_is_none() {
        let content = vec![text("hi")];
        assert!(inspect_delete(&content, &Selection::Cursor(lead(0, 0)), false).is_none());
        assert!(inspect_delete(&[], &Selection::Cursor(lead(0, 0)), false).is_none());
    }
    #[test]
    fn inspect_delete_forward_reports_exactly_what_delete_forward_removes() {
        let content = vec![text("héllo")]; // é starts at byte 1, 2 bytes long
        let cursor = lead(0, 1);
        let (_, reported) = inspect_delete(&content, &Selection::Cursor(cursor), true).unwrap();
        let (after, _) = delete_forward(&content, &cursor);
        assert_eq!(reported, "é");
        assert_eq!(dump(&after), vec!["hllo"], "inspect and delete agree");
    }
    #[test]
    fn inspect_delete_backward_reports_exactly_what_delete_backward_removes() {
        let content = vec![text(&format!("a{FAMILY}"))];
        let cursor = lead(0, 19);
        let (range, reported) =
            inspect_delete(&content, &Selection::Cursor(cursor), false).unwrap();
        let (after, _) = delete_backward(&content, &cursor);
        assert_eq!(reported, FAMILY, "the whole ZWJ cluster, not one codepoint");
        assert_eq!(range.start, lead(0, 1));
        assert_eq!(dump(&after), vec!["a"]);
    }
    #[test]
    fn inspect_delete_forward_honors_trailing_affinity() {
        // A Trailing cursor sits AFTER its grapheme, so Delete removes the NEXT one.
        let content = vec![text("abc")];
        let (_, reported) =
            inspect_delete(&content, &Selection::Cursor(trail(0, 0)), true).unwrap();
        assert_eq!(reported, "b");
    }
    #[test]
    fn inspect_delete_across_a_run_boundary_reports_the_neighbouring_grapheme() {
        let content = vec![text("ab"), text("cd")];
        let (_, fwd) = inspect_delete(&content, &Selection::Cursor(lead(0, 2)), true).unwrap();
        assert_eq!(fwd, "c");
        let (_, back) = inspect_delete(&content, &Selection::Cursor(lead(1, 0)), false).unwrap();
        assert_eq!(back, "b");
    }
    #[test]
    fn inspect_delete_reports_none_for_a_non_text_neighbour_that_delete_would_remove() {
        // BUG (reported): inspect_delete_forward/backward only match on a TEXT
        // neighbour, so they answer "nothing would be deleted" while
        // delete_forward/delete_backward actually remove the inline item. A callback
        // relying on inspect_delete to veto or log the edit sees nothing coming.
        let content = vec![text("ab"), obj()];
        assert!(inspect_delete(&content, &Selection::Cursor(lead(0, 2)), true).is_none());
        let (after, _) = delete_forward(&content, &lead(0, 2));
        assert_eq!(dump(&after), vec!["ab"], "...but the item IS removed");
        let content = vec![obj(), text("ab")];
        assert!(inspect_delete(&content, &Selection::Cursor(lead(1, 0)), false).is_none());
        let (after, _) = delete_backward(&content, &lead(1, 0));
        assert_eq!(dump(&after), vec!["ab"], "...but the item IS removed");
    }
    #[test]
    fn inspect_delete_on_a_range_returns_the_range_and_its_text() {
        let content = vec![text("hello")];
        let sel = range_sel(lead(0, 1), lead(0, 3));
        let (range, reported) = inspect_delete(&content, &sel, false).unwrap();
        assert_eq!(range.start, lead(0, 1));
        assert_eq!(range.end, lead(0, 3));
        assert_eq!(reported, "el");
        // ...and that is exactly what the delete removes.
        let (after, _) = apply_edit_to_selection(&content, &sel, &TextEdit::DeleteBackward);
        assert_eq!(dump(&after), vec!["hlo"]);
    }
    #[test]
    fn inspect_delete_out_of_range_cursor_is_none_not_a_panic() {
        let content = vec![text("hi")];
        assert!(inspect_delete(&content, &Selection::Cursor(lead(u32::MAX, 0)), true).is_none());
        assert!(inspect_delete(&content, &Selection::Cursor(lead(u32::MAX, 0)), false).is_none());
    }
    // ---------------------------------------------------- extract_text_in_range
    #[test]
    fn extract_text_in_range_single_run() {
        let content = vec![text("hello")];
        let r = SelectionRange {
            start: lead(0, 1),
            end: lead(0, 4),
        };
        assert_eq!(extract_text_in_range(&content, &r), "ell");
    }
    #[test]
    fn extract_text_in_range_multi_run_concatenates_the_span() {
        let content = vec![text("abc"), text("MID"), text("def")];
        let r = SelectionRange {
            start: lead(0, 1),
            end: lead(2, 2),
        };
        assert_eq!(extract_text_in_range(&content, &r), "bcMIDde");
    }
    #[test]
    fn extract_text_in_range_skips_non_text_items_in_the_span() {
        let content = vec![text("abc"), obj(), text("def")];
        let r = SelectionRange {
            start: lead(0, 1),
            end: lead(2, 2),
        };
        assert_eq!(extract_text_in_range(&content, &r), "bcde");
    }
    #[test]
    fn extract_text_in_range_out_of_bounds_yields_empty_string() {
        let content = vec![text("hi")];
        // end_byte past the run length.
        let r = SelectionRange {
            start: lead(0, 0),
            end: lead(0, 99),
        };
        assert_eq!(extract_text_in_range(&content, &r), "");
        // Both runs past the end.
        let r = SelectionRange {
            start: lead(9, 0),
            end: lead(9, 1),
        };
        assert_eq!(extract_text_in_range(&content, &r), "");
        // Empty content.
        let r = SelectionRange {
            start: lead(0, 0),
            end: lead(0, 1),
        };
        assert_eq!(extract_text_in_range(&[], &r), "");
    }
    #[test]
    fn extract_text_in_range_backward_single_run_yields_empty_string() {
        // Unlike delete_range, extract does NOT normalize direction — a
        // right-to-left selection inside one run reports no text at all.
        let content = vec![text("hello")];
        let r = SelectionRange {
            start: lead(0, 3),
            end: lead(0, 1),
        };
        assert_eq!(extract_text_in_range(&content, &r), "");
    }
    #[test]
    fn extract_text_in_range_ignores_affinity_and_drops_the_last_cluster() {
        // BUG (reported): extract_text_in_range reads the RAW `start_byte_in_run`
        // while delete_range goes through cursor_byte_offset_in_run. On a select-all
        // (end cursor Trailing on the last cluster) inspect_delete therefore reports
        // one grapheme LESS than the delete actually removes.
        let content = vec![text("hello")];
        let r = SelectionRange {
            start: lead(0, 0),
            end: trail(0, 4),
        };
        assert_eq!(extract_text_in_range(&content, &r), "hell", "the 'o' is missing");
        let (after, _) = delete_range(&content, &r);
        assert_eq!(dump(&after), vec![""], "...yet delete_range removes all of it");
    }
}