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}