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}