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 std::sync::Arc;
8
9use azul_core::selection::{
10    CursorAffinity, GraphemeClusterId, Selection, SelectionRange, TextCursor,
11};
12
13use crate::text3::cache::{ContentIndex, InlineContent, StyledRun};
14
15/// An enum representing a single text editing action.
16#[derive(Debug, Clone)]
17pub enum TextEdit {
18    /// Insert the given string at the cursor position.
19    Insert(String),
20    /// Delete one grapheme cluster before the cursor (Backspace).
21    DeleteBackward,
22    /// Delete one grapheme cluster after the cursor (Delete key).
23    DeleteForward,
24}
25
26/// The primary entry point for text modification. Takes the current content and selections,
27/// applies an edit, and returns the new content and the resulting cursor positions.
28pub fn edit_text(
29    content: &[InlineContent],
30    selections: &[Selection],
31    edit: &TextEdit,
32) -> (Vec<InlineContent>, Vec<Selection>) {
33    if selections.is_empty() {
34        return (content.to_vec(), Vec::new());
35    }
36
37    let mut new_content = content.to_vec();
38    let mut new_selections = Vec::new();
39
40    // To handle multiple cursors correctly, we must process edits
41    // from the end of the document to the beginning. This ensures that
42    // earlier edits do not invalidate the indices of later edits.
43    let mut sorted_selections = selections.to_vec();
44    sorted_selections.sort_by(|a, b| {
45        let cursor_a = match a {
46            Selection::Cursor(c) => c,
47            Selection::Range(r) => &r.start,
48        };
49        let cursor_b = match b {
50            Selection::Cursor(c) => c,
51            Selection::Range(r) => &r.start,
52        };
53        cursor_b.cluster_id.cmp(&cursor_a.cluster_id) // Reverse sort
54    });
55
56    for selection in sorted_selections {
57        let (mut temp_content, new_cursor) =
58            apply_edit_to_selection(&new_content, &selection, edit);
59
60        // When we insert/delete text, we need to adjust all previously-processed cursors
61        // that come after this edit position in the same run
62        let edit_run = match selection {
63            Selection::Cursor(c) => c.cluster_id.source_run,
64            Selection::Range(r) => r.start.cluster_id.source_run,
65        };
66        let edit_byte = match selection {
67            Selection::Cursor(c) => c.cluster_id.start_byte_in_run,
68            Selection::Range(r) => r.start.cluster_id.start_byte_in_run,
69        };
70
71        // Calculate the byte offset change
72        let byte_offset_change: i32 = match edit {
73            TextEdit::Insert(text) => text.len() as i32,
74            TextEdit::DeleteBackward | TextEdit::DeleteForward => {
75                // For simplicity, assume 1 grapheme deleted = some bytes
76                // A full implementation would track actual bytes deleted
77                -1
78            }
79        };
80
81        // Adjust all previously-processed cursors in the same run that come after this position
82        for prev_selection in new_selections.iter_mut() {
83            if let Selection::Cursor(cursor) = prev_selection {
84                if cursor.cluster_id.source_run == edit_run
85                    && cursor.cluster_id.start_byte_in_run >= edit_byte
86                {
87                    cursor.cluster_id.start_byte_in_run =
88                        (cursor.cluster_id.start_byte_in_run as i32 + byte_offset_change).max(0)
89                            as u32;
90                }
91            }
92        }
93
94        new_content = temp_content;
95        new_selections.push(Selection::Cursor(new_cursor));
96    }
97
98    // The new selections were added in reverse order, so we reverse them back.
99    new_selections.reverse();
100
101    (new_content, new_selections)
102}
103
104/// Applies a single edit to a single selection.
105///
106/// When the selection is a Range:
107/// - `Insert`: deletes the range, then inserts text at the collapsed cursor
108/// - `DeleteBackward`/`DeleteForward`: deletes the range ONLY (the range
109///   deletion replaces the character-level delete — pressing Backspace with
110///   a selection should remove the selection, not the selection + 1 char)
111pub fn apply_edit_to_selection(
112    content: &[InlineContent],
113    selection: &Selection,
114    edit: &TextEdit,
115) -> (Vec<InlineContent>, TextCursor) {
116    let mut new_content = content.to_vec();
117
118    match selection {
119        Selection::Range(range) => {
120            // Delete the range first
121            let (content_after_delete, cursor_pos) = delete_range(&new_content, range);
122            match edit {
123                // Insert: replace the deleted range with new text
124                TextEdit::Insert(text_to_insert) => {
125                    let mut c = content_after_delete;
126                    insert_text(&mut c, &cursor_pos, text_to_insert)
127                }
128                // Delete: range deletion is sufficient — don't delete again
129                TextEdit::DeleteBackward | TextEdit::DeleteForward => {
130                    (content_after_delete, cursor_pos)
131                }
132            }
133        }
134        Selection::Cursor(cursor) => {
135            match edit {
136                TextEdit::Insert(text_to_insert) => {
137                    insert_text(&mut new_content, cursor, text_to_insert)
138                }
139                TextEdit::DeleteBackward => delete_backward(&mut new_content, cursor),
140                TextEdit::DeleteForward => delete_forward(&mut new_content, cursor),
141            }
142        }
143    }
144}
145
146/// Deletes the content within a given range.
147pub fn delete_range(
148    content: &[InlineContent],
149    range: &SelectionRange,
150) -> (Vec<InlineContent>, TextCursor) {
151    // This is a highly complex function. A full implementation needs to handle:
152    //
153    // - Deletions within a single text run.
154    // - Deletions that span across multiple text runs.
155    // - Deletions that include non-text items like images.
156    //
157    // For now, we provide a simplified version that handles deletion within a
158    // single run.
159
160    let mut new_content = content.to_vec();
161    let start_run_idx = range.start.cluster_id.source_run as usize;
162    let end_run_idx = range.end.cluster_id.source_run as usize;
163
164    if start_run_idx == end_run_idx {
165        if let Some(InlineContent::Text(run)) = new_content.get_mut(start_run_idx) {
166            let start_byte = range.start.cluster_id.start_byte_in_run as usize;
167            let end_byte = range.end.cluster_id.start_byte_in_run as usize;
168            if start_byte <= end_byte && end_byte <= run.text.len() {
169                run.text.drain(start_byte..end_byte);
170            }
171        }
172    } else {
173        // TODO: Handle multi-run deletion
174    }
175
176    (new_content, range.start) // Return cursor at the start of the deleted range
177}
178
179/// Inserts text at a cursor position.
180/// 
181/// The cursor's affinity determines the exact insertion point:
182/// - `Leading`: Insert at the start of the referenced cluster (start_byte_in_run)
183/// - `Trailing`: Insert at the end of the referenced cluster (after the grapheme)
184pub fn insert_text(
185    content: &mut Vec<InlineContent>,
186    cursor: &TextCursor,
187    text_to_insert: &str,
188) -> (Vec<InlineContent>, TextCursor) {
189    use unicode_segmentation::UnicodeSegmentation;
190    
191    let mut new_content = content.clone();
192    let run_idx = cursor.cluster_id.source_run as usize;
193    let cluster_start_byte = cursor.cluster_id.start_byte_in_run as usize;
194
195    if let Some(InlineContent::Text(run)) = new_content.get_mut(run_idx) {
196        // Calculate the actual insertion byte offset based on affinity
197        let byte_offset = match cursor.affinity {
198            CursorAffinity::Leading => {
199                // Insert at the start of the cluster
200                cluster_start_byte
201            },
202            CursorAffinity::Trailing => {
203                // Insert at the end of the cluster - find the next grapheme boundary
204                // We need to find where this grapheme cluster ends
205                if cluster_start_byte >= run.text.len() {
206                    // Cursor is at/past end of run - insert at end
207                    run.text.len()
208                } else {
209                    // Find the grapheme that starts at cluster_start_byte and get its end
210                    run.text[cluster_start_byte..]
211                        .grapheme_indices(true)
212                        .next()
213                        .map(|(_, grapheme)| cluster_start_byte + grapheme.len())
214                        .unwrap_or(run.text.len())
215                }
216            },
217        };
218        
219        if byte_offset <= run.text.len() {
220            run.text.insert_str(byte_offset, text_to_insert);
221
222            let new_cursor = TextCursor {
223                cluster_id: GraphemeClusterId {
224                    source_run: run_idx as u32,
225                    start_byte_in_run: (byte_offset + text_to_insert.len()) as u32,
226                },
227                affinity: CursorAffinity::Leading,
228            };
229            return (new_content, new_cursor);
230        }
231    }
232
233    // If insertion failed, return original state
234    (content.to_vec(), *cursor)
235}
236
237/// Deletes one grapheme cluster backward from the cursor.
238/// 
239/// The cursor's affinity determines the actual cursor position:
240/// - `Leading`: Cursor is at start of cluster, delete the previous grapheme
241/// - `Trailing`: Cursor is at end of cluster, delete the current grapheme
242pub fn delete_backward(
243    content: &mut Vec<InlineContent>,
244    cursor: &TextCursor,
245) -> (Vec<InlineContent>, TextCursor) {
246    use unicode_segmentation::UnicodeSegmentation;
247    let mut new_content = content.clone();
248    let run_idx = cursor.cluster_id.source_run as usize;
249    let cluster_start_byte = cursor.cluster_id.start_byte_in_run as usize;
250
251    if let Some(InlineContent::Text(run)) = new_content.get_mut(run_idx) {
252        // Calculate the actual cursor byte offset based on affinity
253        let byte_offset = match cursor.affinity {
254            CursorAffinity::Leading => cluster_start_byte,
255            CursorAffinity::Trailing => {
256                // Cursor is at end of cluster - find the next grapheme boundary
257                if cluster_start_byte >= run.text.len() {
258                    run.text.len()
259                } else {
260                    run.text[cluster_start_byte..]
261                        .grapheme_indices(true)
262                        .next()
263                        .map(|(_, grapheme)| cluster_start_byte + grapheme.len())
264                        .unwrap_or(run.text.len())
265                }
266            },
267        };
268        
269        if byte_offset > 0 {
270            let prev_grapheme_start = run.text[..byte_offset]
271                .grapheme_indices(true)
272                .last()
273                .map_or(0, |(i, _)| i);
274            run.text.drain(prev_grapheme_start..byte_offset);
275
276            let new_cursor = TextCursor {
277                cluster_id: GraphemeClusterId {
278                    source_run: run_idx as u32,
279                    start_byte_in_run: prev_grapheme_start as u32,
280                },
281                affinity: CursorAffinity::Leading,
282            };
283            return (new_content, new_cursor);
284        } else if run_idx > 0 {
285            // Handle deleting across run boundaries (merge with previous run)
286            if let Some(InlineContent::Text(prev_run)) = content.get(run_idx - 1).cloned() {
287                let mut merged_text = prev_run.text;
288                let new_cursor_byte_offset = merged_text.len();
289                merged_text.push_str(&run.text);
290
291                new_content[run_idx - 1] = InlineContent::Text(StyledRun {
292                    text: merged_text,
293                    style: prev_run.style,
294                    logical_start_byte: prev_run.logical_start_byte,
295                    source_node_id: prev_run.source_node_id,
296                });
297                new_content.remove(run_idx);
298
299                let new_cursor = TextCursor {
300                    cluster_id: GraphemeClusterId {
301                        source_run: (run_idx - 1) as u32,
302                        start_byte_in_run: new_cursor_byte_offset as u32,
303                    },
304                    affinity: CursorAffinity::Leading,
305                };
306                return (new_content, new_cursor);
307            }
308        }
309    }
310
311    (content.to_vec(), *cursor)
312}
313
314/// Deletes one grapheme cluster forward from the cursor.
315/// 
316/// The cursor's affinity determines the actual cursor position:
317/// - `Leading`: Cursor is at start of cluster, delete the current grapheme
318/// - `Trailing`: Cursor is at end of cluster, delete the next grapheme
319pub fn delete_forward(
320    content: &mut Vec<InlineContent>,
321    cursor: &TextCursor,
322) -> (Vec<InlineContent>, TextCursor) {
323    use unicode_segmentation::UnicodeSegmentation;
324    let mut new_content = content.clone();
325    let run_idx = cursor.cluster_id.source_run as usize;
326    let cluster_start_byte = cursor.cluster_id.start_byte_in_run as usize;
327
328    if let Some(InlineContent::Text(run)) = new_content.get_mut(run_idx) {
329        // Calculate the actual cursor byte offset based on affinity
330        let byte_offset = match cursor.affinity {
331            CursorAffinity::Leading => cluster_start_byte,
332            CursorAffinity::Trailing => {
333                // Cursor is at end of cluster - find the next grapheme boundary
334                if cluster_start_byte >= run.text.len() {
335                    run.text.len()
336                } else {
337                    run.text[cluster_start_byte..]
338                        .grapheme_indices(true)
339                        .next()
340                        .map(|(_, grapheme)| cluster_start_byte + grapheme.len())
341                        .unwrap_or(run.text.len())
342                }
343            },
344        };
345        
346        if byte_offset < run.text.len() {
347            let next_grapheme_end = run.text[byte_offset..]
348                .grapheme_indices(true)
349                .nth(1)
350                .map_or(run.text.len(), |(i, _)| byte_offset + i);
351            run.text.drain(byte_offset..next_grapheme_end);
352
353            // Cursor position stays at the same byte offset but with Leading affinity
354            let new_cursor = TextCursor {
355                cluster_id: GraphemeClusterId {
356                    source_run: run_idx as u32,
357                    start_byte_in_run: byte_offset as u32,
358                },
359                affinity: CursorAffinity::Leading,
360            };
361            return (new_content, new_cursor);
362        } else if run_idx < content.len() - 1 {
363            // Handle deleting across run boundaries (merge with next run)
364            if let Some(InlineContent::Text(next_run)) = content.get(run_idx + 1).cloned() {
365                let mut merged_text = run.text.clone();
366                merged_text.push_str(&next_run.text);
367
368                new_content[run_idx] = InlineContent::Text(StyledRun {
369                    text: merged_text,
370                    style: run.style.clone(),
371                    logical_start_byte: run.logical_start_byte,
372                    source_node_id: run.source_node_id,
373                });
374                new_content.remove(run_idx + 1);
375
376                return (new_content, *cursor);
377            }
378        }
379    }
380
381    (content.to_vec(), *cursor)
382}
383
384/// Edit text with different text per selection (for N-lines-to-N-cursors paste).
385///
386/// Each selection gets its own text inserted. Selections are processed back-to-front
387/// to avoid index invalidation. Returns the new content and updated cursors.
388///
389/// # Panics
390///
391/// Panics if `texts.len() != selections.len()`.
392pub fn edit_text_multi(
393    content: &[InlineContent],
394    selections: &[Selection],
395    texts: &[&str],
396) -> (Vec<InlineContent>, Vec<Selection>) {
397    assert_eq!(
398        selections.len(),
399        texts.len(),
400        "edit_text_multi: selections and texts must have the same length"
401    );
402
403    if selections.is_empty() {
404        return (content.to_vec(), Vec::new());
405    }
406
407    let mut new_content = content.to_vec();
408    let mut new_selections = Vec::new();
409
410    // Pair selections with their text, sort back-to-front
411    let mut pairs: Vec<(Selection, &str)> = selections
412        .iter()
413        .copied()
414        .zip(texts.iter().copied())
415        .collect();
416    pairs.sort_by(|a, b| {
417        let cursor_a = match &a.0 {
418            Selection::Cursor(c) => c,
419            Selection::Range(r) => &r.start,
420        };
421        let cursor_b = match &b.0 {
422            Selection::Cursor(c) => c,
423            Selection::Range(r) => &r.start,
424        };
425        cursor_b.cluster_id.cmp(&cursor_a.cluster_id) // Reverse sort
426    });
427
428    for (selection, text) in &pairs {
429        let edit = TextEdit::Insert(text.to_string());
430        let (temp_content, new_cursor) =
431            apply_edit_to_selection(&new_content, selection, &edit);
432
433        let edit_run = match selection {
434            Selection::Cursor(c) => c.cluster_id.source_run,
435            Selection::Range(r) => r.start.cluster_id.source_run,
436        };
437        let edit_byte = match selection {
438            Selection::Cursor(c) => c.cluster_id.start_byte_in_run,
439            Selection::Range(r) => r.start.cluster_id.start_byte_in_run,
440        };
441
442        let byte_offset_change = text.len() as i32;
443
444        for prev_selection in new_selections.iter_mut() {
445            if let Selection::Cursor(cursor) = prev_selection {
446                if cursor.cluster_id.source_run == edit_run
447                    && cursor.cluster_id.start_byte_in_run >= edit_byte
448                {
449                    cursor.cluster_id.start_byte_in_run =
450                        (cursor.cluster_id.start_byte_in_run as i32 + byte_offset_change).max(0)
451                            as u32;
452                }
453            }
454        }
455
456        new_content = temp_content;
457        new_selections.push(Selection::Cursor(new_cursor));
458    }
459
460    new_selections.reverse();
461    (new_content, new_selections)
462}
463
464/// Returns the range and text that a delete operation would remove, without
465/// actually modifying the content. Useful for callbacks that need to inspect
466/// pending deletes. Returns `None` if nothing would be deleted.
467pub fn inspect_delete(
468    content: &[InlineContent],
469    selection: &Selection,
470    forward: bool,
471) -> Option<(SelectionRange, String)> {
472    match selection {
473        Selection::Range(range) => {
474            // If there's already a selection, that's what would be deleted
475            let deleted_text = extract_text_in_range(content, range);
476            Some((*range, deleted_text))
477        }
478        Selection::Cursor(cursor) => {
479            // No selection - would delete one grapheme cluster
480            if forward {
481                inspect_delete_forward(content, cursor)
482            } else {
483                inspect_delete_backward(content, cursor)
484            }
485        }
486    }
487}
488
489/// Inspect what would be deleted by delete-forward (Delete key)
490fn inspect_delete_forward(
491    content: &[InlineContent],
492    cursor: &TextCursor,
493) -> Option<(SelectionRange, String)> {
494    use unicode_segmentation::UnicodeSegmentation;
495
496    let run_idx = cursor.cluster_id.source_run as usize;
497    let byte_offset = cursor.cluster_id.start_byte_in_run as usize;
498
499    if let Some(InlineContent::Text(run)) = content.get(run_idx) {
500        if byte_offset < run.text.len() {
501            // Delete within same run
502            let next_grapheme_end = run.text[byte_offset..]
503                .grapheme_indices(true)
504                .nth(1)
505                .map_or(run.text.len(), |(i, _)| byte_offset + i);
506
507            let deleted_text = run.text[byte_offset..next_grapheme_end].to_string();
508
509            let range = SelectionRange {
510                start: *cursor,
511                end: TextCursor {
512                    cluster_id: GraphemeClusterId {
513                        source_run: run_idx as u32,
514                        start_byte_in_run: next_grapheme_end as u32,
515                    },
516                    affinity: CursorAffinity::Leading,
517                },
518            };
519
520            return Some((range, deleted_text));
521        } else if run_idx < content.len() - 1 {
522            // Would delete across run boundary
523            if let Some(InlineContent::Text(next_run)) = content.get(run_idx + 1) {
524                let deleted_text = next_run.text.graphemes(true).next()?.to_string();
525
526                let next_grapheme_end = next_run
527                    .text
528                    .grapheme_indices(true)
529                    .nth(1)
530                    .map_or(next_run.text.len(), |(i, _)| i);
531
532                let range = SelectionRange {
533                    start: *cursor,
534                    end: TextCursor {
535                        cluster_id: GraphemeClusterId {
536                            source_run: (run_idx + 1) as u32,
537                            start_byte_in_run: next_grapheme_end as u32,
538                        },
539                        affinity: CursorAffinity::Leading,
540                    },
541                };
542
543                return Some((range, deleted_text));
544            }
545        }
546    }
547
548    None // At end of document, nothing to delete
549}
550
551/// Inspect what would be deleted by delete-backward (Backspace key)
552fn inspect_delete_backward(
553    content: &[InlineContent],
554    cursor: &TextCursor,
555) -> Option<(SelectionRange, String)> {
556    use unicode_segmentation::UnicodeSegmentation;
557
558    let run_idx = cursor.cluster_id.source_run as usize;
559    let byte_offset = cursor.cluster_id.start_byte_in_run as usize;
560
561    if let Some(InlineContent::Text(run)) = content.get(run_idx) {
562        if byte_offset > 0 {
563            // Delete within same run
564            let prev_grapheme_start = run.text[..byte_offset]
565                .grapheme_indices(true)
566                .last()
567                .map_or(0, |(i, _)| i);
568
569            let deleted_text = run.text[prev_grapheme_start..byte_offset].to_string();
570
571            let range = SelectionRange {
572                start: TextCursor {
573                    cluster_id: GraphemeClusterId {
574                        source_run: run_idx as u32,
575                        start_byte_in_run: prev_grapheme_start as u32,
576                    },
577                    affinity: CursorAffinity::Leading,
578                },
579                end: *cursor,
580            };
581
582            return Some((range, deleted_text));
583        } else if run_idx > 0 {
584            // Would delete across run boundary
585            if let Some(InlineContent::Text(prev_run)) = content.get(run_idx - 1) {
586                let deleted_text = prev_run.text.graphemes(true).last()?.to_string();
587
588                let prev_grapheme_start = prev_run.text[..]
589                    .grapheme_indices(true)
590                    .last()
591                    .map_or(0, |(i, _)| i);
592
593                let range = SelectionRange {
594                    start: TextCursor {
595                        cluster_id: GraphemeClusterId {
596                            source_run: (run_idx - 1) as u32,
597                            start_byte_in_run: prev_grapheme_start as u32,
598                        },
599                        affinity: CursorAffinity::Leading,
600                    },
601                    end: *cursor,
602                };
603
604                return Some((range, deleted_text));
605            }
606        }
607    }
608
609    None // At start of document, nothing to delete
610}
611
612/// Extract the text within a selection range
613fn extract_text_in_range(content: &[InlineContent], range: &SelectionRange) -> String {
614    let start_run = range.start.cluster_id.source_run as usize;
615    let end_run = range.end.cluster_id.source_run as usize;
616    let start_byte = range.start.cluster_id.start_byte_in_run as usize;
617    let end_byte = range.end.cluster_id.start_byte_in_run as usize;
618
619    if start_run == end_run {
620        // Single run
621        if let Some(InlineContent::Text(run)) = content.get(start_run) {
622            if start_byte <= end_byte && end_byte <= run.text.len() {
623                return run.text[start_byte..end_byte].to_string();
624            }
625        }
626    } else {
627        // Multi-run selection (simplified - full implementation would handle images, etc.)
628        let mut result = String::new();
629
630        for (idx, item) in content.iter().enumerate() {
631            if let InlineContent::Text(run) = item {
632                if idx == start_run {
633                    // First run - from start_byte to end
634                    if start_byte < run.text.len() {
635                        result.push_str(&run.text[start_byte..]);
636                    }
637                } else if idx > start_run && idx < end_run {
638                    // Middle runs - entire text
639                    result.push_str(&run.text);
640                } else if idx == end_run {
641                    // Last run - from 0 to end_byte
642                    if end_byte <= run.text.len() {
643                        result.push_str(&run.text[..end_byte]);
644                    }
645                    break;
646                }
647            }
648        }
649
650        return result;
651    }
652
653    String::new()
654}