Skip to main content

oxiui_text/
editor.rs

1//! Multi-line text editor state.
2//!
3//! [`TextArea`] is a headless, pure-data multi-line editor supporting:
4//! vertical scroll, line numbers, soft/hard wrap, and undo/redo with
5//! consecutive-character coalescing.  No rendering — adapters own that.
6
7use crate::selection::Selection;
8use std::collections::HashSet;
9
10// ── WrapMode ──────────────────────────────────────────────────────────────────
11
12/// Wrap mode for the text area.
13#[derive(Clone, Debug, PartialEq)]
14pub enum WrapMode {
15    /// Hard wrap: newlines only, no soft wrap.
16    Hard,
17    /// Soft wrap: wrap at `max_width` pixels.
18    ///
19    /// When rendering, long lines are split using an estimated char width of
20    /// `max_width / 8.0` pixels (since this layer has no access to a live
21    /// shaper).
22    Soft(f32),
23}
24
25// ── EditOp ────────────────────────────────────────────────────────────────────
26
27/// A reversible edit operation used in the undo/redo stack.
28#[derive(Clone, Debug)]
29enum EditOp {
30    /// Inserted `text` at (row, col).
31    Insert {
32        row: usize,
33        col: usize,
34        text: String,
35    },
36    /// Deleted chars from `col_start..col_end` on `row`, content was `deleted`.
37    Delete {
38        row: usize,
39        col_start: usize,
40        col_end: usize,
41        deleted: String,
42    },
43    /// Split line `row` at `col` (inserted a newline).
44    InsertNewline { row: usize, col: usize },
45    /// Joined line `row` with line `row + 1` (deleted the newline).
46    DeleteNewline { row: usize },
47}
48
49// ── TextArea ──────────────────────────────────────────────────────────────────
50
51/// Multi-line text editor state (headless — no rendering).
52///
53/// Lines are stored individually without their trailing newline characters.
54/// The logical text is reconstructed by [`TextArea::text`].
55pub struct TextArea {
56    /// Each line of text, stored **without** a trailing newline.
57    lines: Vec<String>,
58    /// Cursor position as `(row, col)` in *char* indices (not byte offsets).
59    cursor: (usize, usize),
60    /// Optional selection anchor in `(row, col)` char coordinates.
61    selection_anchor: Option<(usize, usize)>,
62    /// Vertical scroll in pixels.
63    scroll_offset: f32,
64    /// Wrap mode for display.
65    wrap: WrapMode,
66    /// Undo stack; each entry is a coalesced group of [`EditOp`]s.
67    undo_stack: Vec<Vec<EditOp>>,
68    /// Redo stack; rebuilt by undo operations.
69    redo_stack: Vec<Vec<EditOp>>,
70    /// Accumulated op waiting to be coalesced or committed.
71    pending_op: Option<EditOp>,
72    /// Set of line indices that need re-shaping after an edit.
73    pub dirty_paragraphs: HashSet<usize>,
74    /// Per-line shaped-text cache; `None` means the line needs reshaping.
75    shape_cache: Vec<Option<crate::ShapedText>>,
76}
77
78impl TextArea {
79    /// Create a new [`TextArea`] from `initial_text`, with cursor at `(0, 0)`.
80    pub fn new(initial_text: &str, wrap: WrapMode) -> Self {
81        let lines: Vec<String> = if initial_text.is_empty() {
82            vec![String::new()]
83        } else {
84            initial_text.split('\n').map(|l| l.to_owned()).collect()
85        };
86        let n = lines.len();
87        let dirty_paragraphs: HashSet<usize> = (0..n).collect();
88        let shape_cache: Vec<Option<crate::ShapedText>> = vec![None; n];
89        Self {
90            lines,
91            cursor: (0, 0),
92            selection_anchor: None,
93            scroll_offset: 0.0,
94            wrap,
95            undo_stack: Vec::new(),
96            redo_stack: Vec::new(),
97            pending_op: None,
98            dirty_paragraphs,
99            shape_cache,
100        }
101    }
102
103    /// Return the full text, joining lines with `'\n'`.
104    pub fn text(&self) -> String {
105        self.lines.join("\n")
106    }
107
108    /// Return the number of lines.
109    pub fn line_count(&self) -> usize {
110        self.lines.len()
111    }
112
113    /// Return the current cursor position as `(row, col)` in char indices.
114    pub fn cursor(&self) -> (usize, usize) {
115        self.cursor
116    }
117
118    // ── Internal helpers ───────────────────────────────────────────────────
119
120    /// Length of `row` in chars.  Panics in debug if `row` is out of bounds.
121    fn line_len(&self, row: usize) -> usize {
122        self.lines.get(row).map(|l| l.chars().count()).unwrap_or(0)
123    }
124
125    /// Convert `(row, col_chars)` to a byte offset within `self.lines[row]`.
126    #[cfg(test)]
127    fn col_to_byte(&self, row: usize, col: usize) -> usize {
128        let line = match self.lines.get(row) {
129            Some(l) => l,
130            None => return 0,
131        };
132        Selection::grapheme_to_byte(line, col)
133    }
134
135    /// Apply an `EditOp` forward (for do/redo).
136    ///
137    /// Also updates the dirty-paragraph set and shape cache so that
138    /// [`TextArea::shaped_paragraphs`] only re-shapes the affected lines.
139    fn apply_op(&mut self, op: &EditOp) {
140        match op {
141            EditOp::Insert { row, col, text } => {
142                if let Some(line) = self.lines.get_mut(*row) {
143                    let byte = Selection::grapheme_to_byte(line, *col);
144                    line.insert_str(byte, text);
145                    let new_col = col + text.chars().count();
146                    self.cursor = (*row, new_col);
147                    // Mark the edited line dirty.
148                    self.dirty_paragraphs.insert(*row);
149                    if let Some(slot) = self.shape_cache.get_mut(*row) {
150                        *slot = None;
151                    }
152                }
153            }
154            EditOp::Delete {
155                row,
156                col_start,
157                col_end,
158                ..
159            } => {
160                if let Some(line) = self.lines.get_mut(*row) {
161                    let b_start = Selection::grapheme_to_byte(line, *col_start);
162                    let b_end = Selection::grapheme_to_byte(line, *col_end);
163                    line.replace_range(b_start..b_end, "");
164                    self.cursor = (*row, *col_start);
165                    // Mark the edited line dirty.
166                    self.dirty_paragraphs.insert(*row);
167                    if let Some(slot) = self.shape_cache.get_mut(*row) {
168                        *slot = None;
169                    }
170                }
171            }
172            EditOp::InsertNewline { row, col } => {
173                if let Some(line) = self.lines.get_mut(*row) {
174                    let byte = Selection::grapheme_to_byte(line, *col);
175                    let rest = line[byte..].to_owned();
176                    line.truncate(byte);
177                    let row_idx = *row;
178                    self.lines.insert(row_idx + 1, rest);
179                    self.cursor = (row_idx + 1, 0);
180                    // Both the modified row and the newly inserted row are dirty.
181                    self.dirty_paragraphs.insert(row_idx);
182                    self.dirty_paragraphs.insert(row_idx + 1);
183                    // Grow the cache to accommodate the new line.
184                    if row_idx < self.shape_cache.len() {
185                        self.shape_cache[row_idx] = None;
186                        self.shape_cache.insert(row_idx + 1, None);
187                    } else {
188                        self.shape_cache.resize_with(self.lines.len(), || None);
189                    }
190                }
191            }
192            EditOp::DeleteNewline { row } => {
193                let row_idx = *row;
194                if row_idx + 1 < self.lines.len() {
195                    let next = self.lines.remove(row_idx + 1);
196                    let join_col = self.line_len(row_idx);
197                    self.lines[row_idx].push_str(&next);
198                    self.cursor = (row_idx, join_col);
199                    // The merged row is dirty; remove the now-gone row from cache.
200                    self.dirty_paragraphs.insert(row_idx);
201                    if row_idx < self.shape_cache.len() {
202                        self.shape_cache[row_idx] = None;
203                    }
204                    // Remove the deleted row from the cache if it exists.
205                    if row_idx + 1 < self.shape_cache.len() {
206                        self.shape_cache.remove(row_idx + 1);
207                    }
208                    // Also remove from dirty set in case it was queued.
209                    self.dirty_paragraphs.remove(&(row_idx + 1));
210                }
211            }
212        }
213    }
214
215    /// Apply the *inverse* of an `EditOp` (for undo).
216    fn apply_inverse_op(&mut self, op: &EditOp) {
217        match op {
218            EditOp::Insert { row, col, text } => {
219                let col_end = col + text.chars().count();
220                let inverse = EditOp::Delete {
221                    row: *row,
222                    col_start: *col,
223                    col_end,
224                    deleted: text.clone(),
225                };
226                self.apply_op(&inverse);
227            }
228            EditOp::Delete {
229                row,
230                col_start,
231                deleted,
232                ..
233            } => {
234                let inverse = EditOp::Insert {
235                    row: *row,
236                    col: *col_start,
237                    text: deleted.clone(),
238                };
239                self.apply_op(&inverse);
240            }
241            EditOp::InsertNewline { row, col } => {
242                let inverse = EditOp::DeleteNewline { row: *row };
243                // After undoing InsertNewline, cursor goes back to (row, col).
244                self.apply_op(&inverse);
245                self.cursor = (*row, *col);
246            }
247            EditOp::DeleteNewline { row } => {
248                // We need to know where to split; store the original col.
249                // The join_col is implicit from the current line length before
250                // the split, but we reconstruct it: the inverse of DeleteNewline
251                // is InsertNewline at the position where the second line began.
252                // The col is the join_col stored in cursor after the original apply.
253                // We must re-derive it from the current undo context.
254                // At this point `self.lines[row]` contains the joined text.
255                // The original col was the length of the first part, which is
256                // the cursor col stored when DeleteNewline was recorded.
257                // We track this via a helper: record join col in undo.
258                // However, we haven't stored join_col in the op.  The invariant
259                // is: after DeleteNewline is applied, cursor = (row, original_len_of_row).
260                // We can't recover that without extra state in the op.
261                //
262                // Solution: store join_col as part of the EditOp variant.
263                // But the spec shows `DeleteNewline { row: usize }` only.
264                // We must patch this to use a private `_join_col` hint stored
265                // in the undo_stack.
266                //
267                // Instead we use the `DeleteNewline` op's `row` and the current
268                // state: after DeleteNewline, the undo must re-split at the saved
269                // cursor col.  We store it as the cursor col at undo time.
270                // The undo cursor col at this point equals the original line
271                // length before the DeleteNewline was applied.
272                // We maintain this via the cursor value set during forward apply.
273                let join_col = self.cursor.1;
274                let inverse = EditOp::InsertNewline {
275                    row: *row,
276                    col: join_col,
277                };
278                self.apply_op(&inverse);
279            }
280        }
281    }
282
283    // ── Commit / coalesce ──────────────────────────────────────────────────
284
285    /// Commit the pending operation (if any) onto the undo stack.
286    ///
287    /// Consecutive typed characters are coalesced: if `pending_op` is
288    /// `Insert` at the same row and adjacent column, they are merged into one.
289    /// When committed, a new single-item group is pushed onto `undo_stack`.
290    pub fn commit_pending(&mut self) {
291        if let Some(op) = self.pending_op.take() {
292            self.undo_stack.push(vec![op]);
293        }
294    }
295
296    /// Try to coalesce `new_op` into `pending_op`.
297    ///
298    /// Returns `true` when coalescing succeeded (no commit needed).
299    fn try_coalesce(&mut self, new_op: EditOp) -> bool {
300        let can_merge = if let (
301            Some(EditOp::Insert {
302                row: pr,
303                col: pc,
304                text: pt,
305            }),
306            EditOp::Insert {
307                row: nr, col: nc, ..
308            },
309        ) = (&self.pending_op, &new_op)
310        {
311            *pr == *nr && *pc + pt.chars().count() == *nc
312        } else {
313            false
314        };
315
316        if can_merge {
317            if let (Some(EditOp::Insert { text: pt, .. }), EditOp::Insert { text: nt, .. }) =
318                (&mut self.pending_op, &new_op)
319            {
320                pt.push_str(nt);
321                return true;
322            }
323        }
324        false
325    }
326
327    // ── Editing operations ─────────────────────────────────────────────────
328
329    /// Insert a character at the cursor position.
330    ///
331    /// When `ch == '\n'`, delegates to [`TextArea::insert_newline`].
332    /// Otherwise inserts into the current line and advances the column.
333    /// Consecutive inserts on the same row at adjacent columns are coalesced
334    /// into a single undo group.
335    pub fn insert_char(&mut self, ch: char) {
336        if ch == '\n' {
337            self.insert_newline();
338            return;
339        }
340        // Clears redo on any new edit.
341        self.redo_stack.clear();
342
343        let (row, col) = self.cursor;
344        let mut s = String::with_capacity(ch.len_utf8());
345        s.push(ch);
346        let op = EditOp::Insert { row, col, text: s };
347
348        if !self.try_coalesce(op.clone()) {
349            self.commit_pending();
350            self.pending_op = Some(op.clone());
351        }
352        // Apply forward regardless of coalesce result.
353        self.apply_op(&op);
354    }
355
356    /// Split the current line at the cursor column, inserting a new line.
357    ///
358    /// Commits any pending coalesced operation first.
359    pub fn insert_newline(&mut self) {
360        self.redo_stack.clear();
361        let (row, col) = self.cursor;
362        self.commit_pending();
363        let op = EditOp::InsertNewline { row, col };
364        self.apply_op(&op);
365        self.undo_stack.push(vec![op]);
366    }
367
368    /// Delete the character immediately before the cursor (Backspace).
369    ///
370    /// When `col == 0` and `row > 0`, joins the current line with the previous.
371    /// Commits any pending coalesced operation first.
372    pub fn delete_backward(&mut self) {
373        self.redo_stack.clear();
374        self.commit_pending();
375
376        let (row, col) = self.cursor;
377        if col == 0 {
378            if row == 0 {
379                return;
380            }
381            // Join this line with the previous.
382            let op = EditOp::DeleteNewline { row: row - 1 };
383            // Before applying, record the join col for inverse reconstruction.
384            // We set cursor to (row-1, prev_line_len) to enable undo inverse.
385            let prev_len = self.line_len(row - 1);
386            self.cursor = (row - 1, prev_len);
387            self.apply_op(&op);
388            self.undo_stack.push(vec![op]);
389        } else {
390            // Delete char at col-1.
391            let line = match self.lines.get(row) {
392                Some(l) => l.clone(),
393                None => return,
394            };
395            let b_start = Selection::grapheme_to_byte(&line, col - 1);
396            let b_end = Selection::grapheme_to_byte(&line, col);
397            let deleted = line[b_start..b_end].to_owned();
398            let op = EditOp::Delete {
399                row,
400                col_start: col - 1,
401                col_end: col,
402                deleted,
403            };
404            self.apply_op(&op);
405            self.undo_stack.push(vec![op]);
406        }
407    }
408
409    /// Delete the character at the cursor position (Delete key).
410    ///
411    /// When at the end of a line and there is a next line, joins them.
412    /// Commits any pending coalesced operation first.
413    pub fn delete_forward(&mut self) {
414        self.redo_stack.clear();
415        self.commit_pending();
416
417        let (row, col) = self.cursor;
418        let line_len = self.line_len(row);
419        if col >= line_len {
420            if row + 1 >= self.lines.len() {
421                return;
422            }
423            // Join with next line.
424            let op = EditOp::DeleteNewline { row };
425            // Record join col before applying (it's just the current col which equals line_len).
426            self.cursor = (row, line_len);
427            self.apply_op(&op);
428            self.undo_stack.push(vec![op]);
429        } else {
430            let line = match self.lines.get(row) {
431                Some(l) => l.clone(),
432                None => return,
433            };
434            let b_start = Selection::grapheme_to_byte(&line, col);
435            let b_end = Selection::grapheme_to_byte(&line, col + 1);
436            let deleted = line[b_start..b_end].to_owned();
437            let op = EditOp::Delete {
438                row,
439                col_start: col,
440                col_end: col + 1,
441                deleted,
442            };
443            self.apply_op(&op);
444            self.undo_stack.push(vec![op]);
445        }
446    }
447
448    // ── Cursor movement ────────────────────────────────────────────────────
449
450    /// Move the cursor up one row, clamping column to the new line's length.
451    pub fn move_up(&mut self) {
452        let (row, col) = self.cursor;
453        if row == 0 {
454            return;
455        }
456        let new_row = row - 1;
457        let new_col = col.min(self.line_len(new_row));
458        self.cursor = (new_row, new_col);
459        self.selection_anchor = None;
460    }
461
462    /// Move the cursor down one row, clamping column to the new line's length.
463    pub fn move_down(&mut self) {
464        let (row, col) = self.cursor;
465        if row + 1 >= self.lines.len() {
466            return;
467        }
468        let new_row = row + 1;
469        let new_col = col.min(self.line_len(new_row));
470        self.cursor = (new_row, new_col);
471        self.selection_anchor = None;
472    }
473
474    /// Move the cursor one character to the left.
475    ///
476    /// When at column 0 and not on the first row, wraps to the end of the
477    /// previous line.
478    pub fn move_left(&mut self) {
479        let (row, col) = self.cursor;
480        if col > 0 {
481            self.cursor = (row, col - 1);
482        } else if row > 0 {
483            let prev_len = self.line_len(row - 1);
484            self.cursor = (row - 1, prev_len);
485        }
486        self.selection_anchor = None;
487    }
488
489    /// Move the cursor one character to the right.
490    ///
491    /// When at the end of a line and there is a next line, wraps to column 0
492    /// of the next line.
493    pub fn move_right(&mut self) {
494        let (row, col) = self.cursor;
495        let line_len = self.line_len(row);
496        if col < line_len {
497            self.cursor = (row, col + 1);
498        } else if row + 1 < self.lines.len() {
499            self.cursor = (row + 1, 0);
500        }
501        self.selection_anchor = None;
502    }
503
504    /// Move the cursor to column 0 (Home key).
505    pub fn move_home(&mut self) {
506        self.cursor.1 = 0;
507        self.selection_anchor = None;
508    }
509
510    /// Move the cursor to the end of the current line (End key).
511    pub fn move_end(&mut self) {
512        let row = self.cursor.0;
513        self.cursor.1 = self.line_len(row);
514        self.selection_anchor = None;
515    }
516
517    /// Move the cursor to the very beginning of the document.
518    pub fn move_doc_start(&mut self) {
519        self.cursor = (0, 0);
520        self.selection_anchor = None;
521    }
522
523    /// Move the cursor to the very end of the document.
524    pub fn move_doc_end(&mut self) {
525        let last_row = self.lines.len().saturating_sub(1);
526        let last_col = self.line_len(last_row);
527        self.cursor = (last_row, last_col);
528        self.selection_anchor = None;
529    }
530
531    // ── Undo / Redo ────────────────────────────────────────────────────────
532
533    /// Undo the last edit group.
534    ///
535    /// Commits any pending coalesced operation first, then pops the most
536    /// recent entry from the undo stack, applies each op's inverse in reverse
537    /// order, and pushes the group to the redo stack.
538    ///
539    /// Returns `true` when something was undone.
540    pub fn undo(&mut self) -> bool {
541        self.commit_pending();
542        if let Some(group) = self.undo_stack.pop() {
543            // Apply inverses in reverse order.
544            for op in group.iter().rev() {
545                self.apply_inverse_op(op);
546            }
547            self.redo_stack.push(group);
548            true
549        } else {
550            false
551        }
552    }
553
554    /// Redo the last undone edit group.
555    ///
556    /// Commits any pending coalesced operation first, then pops from the
557    /// redo stack, re-applies each op in forward order, and pushes the
558    /// group back onto the undo stack.
559    ///
560    /// Returns `true` when something was redone.
561    pub fn redo(&mut self) -> bool {
562        self.commit_pending();
563        if let Some(group) = self.redo_stack.pop() {
564            for op in &group {
565                self.apply_op(op);
566            }
567            self.undo_stack.push(group);
568            true
569        } else {
570            false
571        }
572    }
573
574    // ── Selection ──────────────────────────────────────────────────────────
575
576    /// Select all text; anchor at `(0, 0)`, cursor at end of last line.
577    pub fn select_all(&mut self) {
578        let last_row = self.lines.len().saturating_sub(1);
579        let last_col = self.line_len(last_row);
580        self.selection_anchor = Some((0, 0));
581        self.cursor = (last_row, last_col);
582    }
583
584    /// Return the selected text, or `None` when the selection is collapsed.
585    pub fn selected_text(&self) -> Option<String> {
586        let anchor = self.selection_anchor?;
587        let cursor = self.cursor;
588        if anchor == cursor {
589            return None;
590        }
591
592        // Normalise to (start, end) in document order.
593        let (start, end) = if anchor <= cursor {
594            (anchor, cursor)
595        } else {
596            (cursor, anchor)
597        };
598        let (start_row, start_col) = start;
599        let (end_row, end_col) = end;
600
601        if start_row == end_row {
602            let line = self.lines.get(start_row)?;
603            let b_start = Selection::grapheme_to_byte(line, start_col);
604            let b_end = Selection::grapheme_to_byte(line, end_col);
605            return Some(line[b_start..b_end].to_owned());
606        }
607
608        let mut parts: Vec<String> = Vec::new();
609        // First partial line.
610        if let Some(line) = self.lines.get(start_row) {
611            let b_start = Selection::grapheme_to_byte(line, start_col);
612            parts.push(line[b_start..].to_owned());
613        }
614        // Middle lines (full).
615        for row in (start_row + 1)..end_row {
616            if let Some(line) = self.lines.get(row) {
617                parts.push(line.clone());
618            }
619        }
620        // Last partial line.
621        if let Some(line) = self.lines.get(end_row) {
622            let b_end = Selection::grapheme_to_byte(line, end_col);
623            parts.push(line[..b_end].to_owned());
624        }
625
626        Some(parts.join("\n"))
627    }
628
629    // ── Metadata ───────────────────────────────────────────────────────────
630
631    /// Return a list of 1-based line numbers: `[1, 2, …, line_count()]`.
632    pub fn line_numbers(&self) -> Vec<usize> {
633        (1..=self.lines.len()).collect()
634    }
635
636    /// Compute the visible line range for the given scroll offset and viewport.
637    ///
638    /// `first_line = floor(scroll_offset / line_height)`,
639    /// `last_line = first_line + ceil(viewport_height / line_height)`,
640    /// clamped to `0..line_count`.
641    pub fn visible_range(&self, line_height: f32, viewport_height: f32) -> std::ops::Range<usize> {
642        let count = self.lines.len();
643        if count == 0 || line_height <= 0.0 {
644            return 0..0;
645        }
646        let first = (self.scroll_offset / line_height).floor() as usize;
647        let visible_count = (viewport_height / line_height).ceil() as usize;
648        let last = (first + visible_count).min(count);
649        let first = first.min(count);
650        first..last
651    }
652
653    /// Adjust `scroll_offset` so that the cursor row is visible.
654    pub fn scroll_to_cursor(&mut self, line_height: f32, viewport_height: f32) {
655        if line_height <= 0.0 {
656            return;
657        }
658        let row = self.cursor.0;
659        let cursor_top = row as f32 * line_height;
660        let cursor_bottom = cursor_top + line_height;
661
662        if cursor_top < self.scroll_offset {
663            self.scroll_offset = cursor_top;
664        } else if cursor_bottom > self.scroll_offset + viewport_height {
665            self.scroll_offset = cursor_bottom - viewport_height;
666        }
667    }
668
669    /// Return display lines using the wrap mode configured at construction.
670    ///
671    /// Delegates to [`TextArea::display_lines`] with `self.wrap`.
672    pub fn display_lines_default(&self) -> Vec<String> {
673        let wrap = self.wrap.clone();
674        self.display_lines(&wrap)
675    }
676
677    /// Return display lines after applying the wrap mode.
678    ///
679    /// For [`WrapMode::Hard`], lines are returned as-is.
680    /// For [`WrapMode::Soft`], each logical line is split into
681    /// visual lines using an estimated char width of `max_width / 8.0`.
682    pub fn display_lines(&self, wrap: &WrapMode) -> Vec<String> {
683        match wrap {
684            WrapMode::Hard => self.lines.clone(),
685            WrapMode::Soft(max_width) => {
686                let chars_per_line = (max_width / 8.0).max(1.0) as usize;
687                let mut result = Vec::new();
688                for line in &self.lines {
689                    if line.is_empty() {
690                        result.push(String::new());
691                        continue;
692                    }
693                    let total_chars = line.chars().count();
694                    if total_chars <= chars_per_line {
695                        result.push(line.clone());
696                    } else {
697                        // Split into chunks of `chars_per_line` chars.
698                        let chars: Vec<char> = line.chars().collect();
699                        let mut start = 0;
700                        while start < total_chars {
701                            let end = (start + chars_per_line).min(total_chars);
702                            let chunk: String = chars[start..end].iter().collect();
703                            result.push(chunk);
704                            start = end;
705                        }
706                    }
707                }
708                result
709            }
710        }
711    }
712
713    /// Return `true` if any edits have been recorded in the undo stack.
714    pub fn is_modified(&self) -> bool {
715        !self.undo_stack.is_empty()
716    }
717
718    /// Shape all dirty paragraphs using `pipeline` and `style`, then return
719    /// the full per-line shaped-text cache.
720    ///
721    /// Only lines listed in `dirty_paragraphs` are re-shaped; all other lines
722    /// are returned from the cache without re-shaping.  Lines that could not be
723    /// shaped (e.g. because the pipeline reported an error) are represented as
724    /// an empty [`crate::ShapedText`].
725    ///
726    /// After this call `dirty_paragraphs` is cleared.
727    pub fn shaped_paragraphs(
728        &mut self,
729        pipeline: &mut crate::TextPipeline,
730        style: &crate::TextStyle,
731    ) -> Vec<crate::ShapedText> {
732        // Keep cache length in sync with line count.
733        self.shape_cache.resize_with(self.lines.len(), || None);
734
735        // Collect dirty indices into a Vec so we can iterate without holding
736        // an immutable borrow on `self.dirty_paragraphs` while mutating.
737        let dirty: Vec<usize> = self.dirty_paragraphs.iter().copied().collect();
738        for idx in dirty {
739            if idx < self.lines.len() {
740                let line = &self.lines[idx];
741                self.shape_cache[idx] = pipeline.shape(line, style).ok();
742            }
743        }
744        self.dirty_paragraphs.clear();
745
746        // Return the full cache; replace any `None` entries (failed / empty
747        // lines) with an empty `ShapedText` so indices stay aligned.
748        self.shape_cache
749            .iter()
750            .map(|opt| {
751                opt.clone().unwrap_or(crate::ShapedText {
752                    lines: Vec::new(),
753                    total_width: 0.0,
754                    total_height: 0.0,
755                })
756            })
757            .collect()
758    }
759
760    /// Return the byte offset within `self.lines[row]` for a given char column.
761    ///
762    /// Exposed for testing; returns 0 if `row` is out of bounds.
763    #[cfg(test)]
764    fn col_byte_offset(&self, row: usize, col: usize) -> usize {
765        self.col_to_byte(row, col)
766    }
767}
768
769// ── Tests ─────────────────────────────────────────────────────────────────────
770
771#[cfg(test)]
772mod tests {
773    use super::*;
774
775    fn area(text: &str) -> TextArea {
776        TextArea::new(text, WrapMode::Hard)
777    }
778
779    // ── 1. insert_char advances cursor ────────────────────────────────────
780
781    #[test]
782    fn test_insert_char_advances_cursor() {
783        let mut ta = area("hello");
784        ta.cursor = (0, 5);
785        ta.insert_char('!');
786        assert_eq!(ta.cursor, (0, 6));
787        assert_eq!(ta.lines[0], "hello!");
788    }
789
790    // ── 2. insert_newline splits line ─────────────────────────────────────
791
792    #[test]
793    fn test_insert_newline_splits_line() {
794        let mut ta = area("helloworld");
795        ta.cursor = (0, 5);
796        ta.insert_newline();
797        assert_eq!(ta.lines.len(), 2);
798        assert_eq!(ta.lines[0], "hello");
799        assert_eq!(ta.lines[1], "world");
800        assert_eq!(ta.cursor, (1, 0));
801    }
802
803    // ── 3. delete_backward removes char ──────────────────────────────────
804
805    #[test]
806    fn test_delete_backward_removes_char() {
807        let mut ta = area("abc");
808        ta.cursor = (0, 3);
809        ta.delete_backward();
810        assert_eq!(ta.lines[0], "ab");
811        assert_eq!(ta.cursor, (0, 2));
812    }
813
814    // ── 4. delete_backward joins lines ────────────────────────────────────
815
816    #[test]
817    fn test_delete_backward_joins_lines() {
818        let mut ta = area("hello\nworld");
819        ta.cursor = (1, 0);
820        ta.delete_backward();
821        assert_eq!(ta.lines.len(), 1);
822        assert_eq!(ta.lines[0], "helloworld");
823        assert_eq!(ta.cursor, (0, 5));
824    }
825
826    // ── 5. cursor up/down clamped col ─────────────────────────────────────
827
828    #[test]
829    fn test_cursor_up_down_preserves_goal_column() {
830        let mut ta = area("hello world\nhi");
831        // Place cursor at col 10 on long line.
832        ta.cursor = (0, 10);
833        ta.move_down();
834        // "hi" has length 2; col must be clamped.
835        assert_eq!(ta.cursor.0, 1);
836        assert!(ta.cursor.1 <= 2);
837        // Move back up; col was 10 but after clamping to 2 it stays ≤10.
838        ta.move_up();
839        assert_eq!(ta.cursor.0, 0);
840        assert!(ta.cursor.1 <= 10);
841    }
842
843    // ── 6. move_left wraps to prev line ──────────────────────────────────
844
845    #[test]
846    fn test_move_left_wraps_to_prev_line() {
847        let mut ta = area("abc\ndef");
848        ta.cursor = (1, 0);
849        ta.move_left();
850        assert_eq!(ta.cursor, (0, 3)); // end of "abc"
851    }
852
853    // ── 7. move_right wraps to next line ─────────────────────────────────
854
855    #[test]
856    fn test_move_right_wraps_to_next_line() {
857        let mut ta = area("abc\ndef");
858        ta.cursor = (0, 3); // end of "abc"
859        ta.move_right();
860        assert_eq!(ta.cursor, (1, 0));
861    }
862
863    // ── 8. soft wrap splits at width ──────────────────────────────────────
864
865    #[test]
866    fn test_soft_wrap_splits_at_width() {
867        // 80px / 8px-per-char = 10 chars per visual line.
868        let ta = TextArea::new("abcdefghij12345", WrapMode::Soft(80.0));
869        let display = ta.display_lines(&WrapMode::Soft(80.0));
870        assert!(
871            display.len() >= 2,
872            "long line should split into >=2 visual lines"
873        );
874        assert_eq!(display[0].chars().count(), 10);
875    }
876
877    // ── 9. hard wrap keeps explicit newlines ──────────────────────────────
878
879    #[test]
880    fn test_hard_wrap_keeps_explicit_newlines() {
881        let text = "line one\nline two\nline three";
882        let ta = TextArea::new(text, WrapMode::Hard);
883        let display = ta.display_lines(&WrapMode::Hard);
884        assert_eq!(display.len(), 3);
885        assert_eq!(display[0], "line one");
886        assert_eq!(display[1], "line two");
887    }
888
889    // ── 10. undo reverses insert ─────────────────────────────────────────
890
891    #[test]
892    fn test_undo_reverses_insert() {
893        let mut ta = area("hello");
894        ta.cursor = (0, 5);
895        ta.insert_char('!');
896        // Commit the pending op.
897        ta.commit_pending();
898        let did_undo = ta.undo();
899        assert!(did_undo);
900        assert_eq!(ta.lines[0], "hello");
901    }
902
903    // ── 11. redo reapplies ────────────────────────────────────────────────
904
905    #[test]
906    fn test_redo_reapplies() {
907        let mut ta = area("hello");
908        ta.cursor = (0, 5);
909        ta.insert_char('!');
910        ta.commit_pending();
911        ta.undo();
912        let did_redo = ta.redo();
913        assert!(did_redo);
914        assert_eq!(ta.lines[0], "hello!");
915    }
916
917    // ── 12. undo coalesces consecutive chars ──────────────────────────────
918
919    #[test]
920    fn test_undo_coalesces_consecutive_chars() {
921        let mut ta = area("");
922        ta.insert_char('a');
923        ta.insert_char('b');
924        ta.insert_char('c');
925        // All three chars should be in one pending Insert op.
926        // undo() commits pending, then pops the group.
927        let did_undo = ta.undo();
928        assert!(did_undo, "undo should succeed");
929        assert_eq!(
930            ta.lines[0], "",
931            "all three inserted chars should be removed"
932        );
933    }
934
935    // ── 13. visible_range maps scroll offset ─────────────────────────────
936
937    #[test]
938    fn test_visible_range_maps_scroll_offset() {
939        let text = (0..20)
940            .map(|i| format!("line {i}"))
941            .collect::<Vec<_>>()
942            .join("\n");
943        let mut ta = TextArea::new(&text, WrapMode::Hard);
944        // Each line is 20px tall, viewport = 60px → 3 visible lines.
945        ta.scroll_offset = 40.0; // start at line 2 (0-indexed).
946        let range = ta.visible_range(20.0, 60.0);
947        assert_eq!(range.start, 2);
948        assert_eq!(range.end, 5);
949    }
950
951    // ── 14. line_numbers gutter count ────────────────────────────────────
952
953    #[test]
954    fn test_line_numbers_gutter_count() {
955        let ta = TextArea::new("one\ntwo\nthree", WrapMode::Hard);
956        let nums = ta.line_numbers();
957        assert_eq!(nums, vec![1, 2, 3]);
958    }
959
960    // ── Extra: col_byte_offset helper ────────────────────────────────────
961
962    #[test]
963    fn test_col_byte_offset_ascii() {
964        let ta = area("hello");
965        assert_eq!(ta.col_byte_offset(0, 0), 0);
966        assert_eq!(ta.col_byte_offset(0, 3), 3);
967        assert_eq!(ta.col_byte_offset(0, 5), 5);
968    }
969
970    // ── 15. Dirty tracking: insert_char marks only affected line ─────────
971
972    #[test]
973    fn dirty_tracking_marks_only_affected_line_on_insert() {
974        let text = "l0\nl1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9";
975        let mut ta = TextArea::new(text, WrapMode::Hard);
976        // Simulate all lines having been shaped (clear dirty set).
977        ta.dirty_paragraphs.clear();
978
979        // Position cursor at line 5, col 0 and insert a character.
980        ta.cursor = (5, 0);
981        ta.insert_char('x');
982
983        // Only line 5 should be dirty.
984        assert_eq!(
985            ta.dirty_paragraphs,
986            std::collections::HashSet::from([5usize])
987        );
988    }
989
990    // ── 16. Dirty tracking: insert_newline marks both halves ─────────────
991
992    #[test]
993    fn dirty_tracking_marks_both_lines_on_newline() {
994        let mut ta = TextArea::new("hello world", WrapMode::Hard);
995        ta.dirty_paragraphs.clear();
996
997        ta.cursor = (0, 5);
998        ta.insert_newline();
999
1000        // Both the split row (0) and the new row (1) must be dirty.
1001        assert!(ta.dirty_paragraphs.contains(&0));
1002        assert!(ta.dirty_paragraphs.contains(&1));
1003        // The shape cache for both rows must be None.
1004        assert!(ta.shape_cache[0].is_none());
1005        assert!(ta.shape_cache[1].is_none());
1006    }
1007
1008    // ── 17. Dirty tracking: delete_backward at col>0 marks single row ────
1009
1010    #[test]
1011    fn dirty_tracking_marks_row_on_delete_backward_inline() {
1012        let mut ta = TextArea::new("abc\ndef", WrapMode::Hard);
1013        ta.dirty_paragraphs.clear();
1014
1015        ta.cursor = (1, 2);
1016        ta.delete_backward();
1017
1018        // Only row 1 should be dirty.
1019        assert!(ta.dirty_paragraphs.contains(&1));
1020        assert!(!ta.dirty_paragraphs.contains(&0));
1021    }
1022
1023    // ── 18. Shape cache grows with insert_newline ─────────────────────────
1024
1025    #[test]
1026    fn shape_cache_grows_after_insert_newline() {
1027        let mut ta = TextArea::new("one line", WrapMode::Hard);
1028        assert_eq!(ta.shape_cache.len(), 1);
1029
1030        ta.cursor = (0, 3);
1031        ta.insert_newline();
1032
1033        assert_eq!(ta.shape_cache.len(), 2);
1034    }
1035
1036    // ── 19. Shape cache shrinks with delete_backward join ─────────────────
1037
1038    #[test]
1039    fn shape_cache_shrinks_after_line_join() {
1040        let mut ta = TextArea::new("a\nb", WrapMode::Hard);
1041        assert_eq!(ta.shape_cache.len(), 2);
1042
1043        ta.cursor = (1, 0);
1044        ta.delete_backward(); // joins lines → 1 line
1045
1046        assert_eq!(ta.shape_cache.len(), 1);
1047    }
1048}