Skip to main content

fresh/view/ui/
text_edit.rs

1//! Reusable multiline text editing state
2//!
3//! This module provides `TextEdit`, a multiline text editor with:
4//! - Cursor navigation (arrows, home/end)
5//! - Selection support (Shift+arrows, Ctrl+A)
6//! - Insert/delete operations
7//! - Word navigation (Ctrl+arrows)
8//!
9//! Single-line editing is a special case (one line, newlines disallowed).
10
11use crate::primitives::word_navigation::{find_word_end_bytes, find_word_start_bytes};
12
13/// Multiline text editing state
14#[derive(Debug, Clone)]
15pub struct TextEdit {
16    /// Lines of text
17    pub lines: Vec<String>,
18    /// Current cursor row (0-indexed)
19    pub cursor_row: usize,
20    /// Current cursor column (0-indexed, in bytes)
21    pub cursor_col: usize,
22    /// Selection anchor position (row, col) - for Shift+Arrow selection
23    pub selection_anchor: Option<(usize, usize)>,
24    /// Whether to allow multiline (newlines)
25    pub multiline: bool,
26}
27
28impl Default for TextEdit {
29    fn default() -> Self {
30        Self::new()
31    }
32}
33
34impl TextEdit {
35    /// Create a new empty text edit (multiline by default)
36    pub fn new() -> Self {
37        Self {
38            lines: vec![String::new()],
39            cursor_row: 0,
40            cursor_col: 0,
41            selection_anchor: None,
42            multiline: true,
43        }
44    }
45
46    /// Create a single-line text edit
47    pub fn single_line() -> Self {
48        Self {
49            lines: vec![String::new()],
50            cursor_row: 0,
51            cursor_col: 0,
52            selection_anchor: None,
53            multiline: false,
54        }
55    }
56
57    /// Create from initial text
58    pub fn with_text(text: &str) -> Self {
59        let lines: Vec<String> = text.lines().map(String::from).collect();
60        let lines = if lines.is_empty() {
61            vec![String::new()]
62        } else {
63            lines
64        };
65        Self {
66            lines,
67            cursor_row: 0,
68            cursor_col: 0,
69            selection_anchor: None,
70            multiline: true,
71        }
72    }
73
74    /// Create single-line from initial text (takes first line only)
75    pub fn single_line_with_text(text: &str) -> Self {
76        let first_line = text.lines().next().unwrap_or("").to_string();
77        Self {
78            lines: vec![first_line],
79            cursor_row: 0,
80            cursor_col: 0,
81            selection_anchor: None,
82            multiline: false,
83        }
84    }
85
86    /// Get the full text value
87    pub fn value(&self) -> String {
88        self.lines.join("\n")
89    }
90
91    /// Set the text value, resetting cursor to start
92    pub fn set_value(&mut self, text: &str) {
93        if self.multiline {
94            self.lines = text.lines().map(String::from).collect();
95            if self.lines.is_empty() {
96                self.lines.push(String::new());
97            }
98        } else {
99            self.lines = vec![text.lines().next().unwrap_or("").to_string()];
100        }
101        self.cursor_row = 0;
102        self.cursor_col = 0;
103        self.selection_anchor = None;
104    }
105
106    /// Get the current line
107    pub fn current_line(&self) -> &str {
108        self.lines
109            .get(self.cursor_row)
110            .map(|s| s.as_str())
111            .unwrap_or("")
112    }
113
114    /// Get number of lines
115    pub fn line_count(&self) -> usize {
116        self.lines.len()
117    }
118
119    // ========================================================================
120    // Cursor movement (clears selection)
121    // ========================================================================
122
123    /// Move cursor left
124    pub fn move_left(&mut self) {
125        self.clear_selection();
126        self.move_left_internal();
127    }
128
129    fn move_left_internal(&mut self) {
130        if self.cursor_col > 0 {
131            // Move to previous char boundary
132            let line = &self.lines[self.cursor_row];
133            let mut new_col = self.cursor_col - 1;
134            while new_col > 0 && !line.is_char_boundary(new_col) {
135                new_col -= 1;
136            }
137            self.cursor_col = new_col;
138        } else if self.cursor_row > 0 && self.multiline {
139            self.cursor_row -= 1;
140            self.cursor_col = self.lines[self.cursor_row].len();
141        }
142    }
143
144    /// Move cursor right
145    pub fn move_right(&mut self) {
146        self.clear_selection();
147        self.move_right_internal();
148    }
149
150    fn move_right_internal(&mut self) {
151        let line_len = self
152            .lines
153            .get(self.cursor_row)
154            .map(|l| l.len())
155            .unwrap_or(0);
156        if self.cursor_col < line_len {
157            // Move to next char boundary
158            let line = &self.lines[self.cursor_row];
159            let mut new_col = self.cursor_col + 1;
160            while new_col < line.len() && !line.is_char_boundary(new_col) {
161                new_col += 1;
162            }
163            self.cursor_col = new_col;
164        } else if self.cursor_row + 1 < self.lines.len() && self.multiline {
165            self.cursor_row += 1;
166            self.cursor_col = 0;
167        }
168    }
169
170    /// Move cursor up
171    pub fn move_up(&mut self) {
172        self.clear_selection();
173        self.move_up_internal();
174    }
175
176    fn move_up_internal(&mut self) {
177        if self.cursor_row > 0 {
178            self.cursor_row -= 1;
179            let line_len = self.lines[self.cursor_row].len();
180            self.cursor_col = self.cursor_col.min(line_len);
181        }
182    }
183
184    /// Move cursor down
185    pub fn move_down(&mut self) {
186        self.clear_selection();
187        self.move_down_internal();
188    }
189
190    fn move_down_internal(&mut self) {
191        if self.cursor_row + 1 < self.lines.len() {
192            self.cursor_row += 1;
193            let line_len = self.lines[self.cursor_row].len();
194            self.cursor_col = self.cursor_col.min(line_len);
195        }
196    }
197
198    /// Move to start of line
199    pub fn move_home(&mut self) {
200        self.clear_selection();
201        self.cursor_col = 0;
202    }
203
204    /// Move to end of line
205    pub fn move_end(&mut self) {
206        self.clear_selection();
207        self.cursor_col = self
208            .lines
209            .get(self.cursor_row)
210            .map(|l| l.len())
211            .unwrap_or(0);
212    }
213
214    /// Move to start of previous word
215    pub fn move_word_left(&mut self) {
216        self.clear_selection();
217        self.move_word_left_internal();
218    }
219
220    fn move_word_left_internal(&mut self) {
221        let line = &self.lines[self.cursor_row];
222        if self.cursor_col > 0 {
223            let new_col = find_word_start_bytes(line.as_bytes(), self.cursor_col);
224            if new_col < self.cursor_col {
225                self.cursor_col = new_col;
226                return;
227            }
228        }
229        // At start of line, move to end of previous line
230        if self.cursor_row > 0 && self.multiline {
231            self.cursor_row -= 1;
232            self.cursor_col = self.lines[self.cursor_row].len();
233        }
234    }
235
236    /// Move to start of next word
237    pub fn move_word_right(&mut self) {
238        self.clear_selection();
239        self.move_word_right_internal();
240    }
241
242    fn move_word_right_internal(&mut self) {
243        let line = &self.lines[self.cursor_row];
244        if self.cursor_col < line.len() {
245            let new_col = find_word_end_bytes(line.as_bytes(), self.cursor_col);
246            if new_col > self.cursor_col {
247                self.cursor_col = new_col;
248                return;
249            }
250        }
251        // At end of line, move to start of next line
252        if self.cursor_row + 1 < self.lines.len() && self.multiline {
253            self.cursor_row += 1;
254            self.cursor_col = 0;
255        }
256    }
257
258    // ========================================================================
259    // Selection support
260    // ========================================================================
261
262    /// Check if there's an active selection
263    pub fn has_selection(&self) -> bool {
264        if let Some((anchor_row, anchor_col)) = self.selection_anchor {
265            anchor_row != self.cursor_row || anchor_col != self.cursor_col
266        } else {
267            false
268        }
269    }
270
271    /// Get selection range as ((start_row, start_col), (end_row, end_col))
272    /// where start is before end in document order
273    pub fn selection_range(&self) -> Option<((usize, usize), (usize, usize))> {
274        let (anchor_row, anchor_col) = self.selection_anchor?;
275        if anchor_row == self.cursor_row && anchor_col == self.cursor_col {
276            return None;
277        }
278
279        let (start, end) = if anchor_row < self.cursor_row
280            || (anchor_row == self.cursor_row && anchor_col < self.cursor_col)
281        {
282            ((anchor_row, anchor_col), (self.cursor_row, self.cursor_col))
283        } else {
284            ((self.cursor_row, self.cursor_col), (anchor_row, anchor_col))
285        };
286        Some((start, end))
287    }
288
289    /// Get selected text
290    pub fn selected_text(&self) -> Option<String> {
291        let ((start_row, start_col), (end_row, end_col)) = self.selection_range()?;
292
293        if start_row == end_row {
294            let line = &self.lines[start_row];
295            let end_col = end_col.min(line.len());
296            let start_col = start_col.min(end_col);
297            Some(line[start_col..end_col].to_string())
298        } else {
299            let mut result = String::new();
300            // First line from start_col to end
301            let first_line = &self.lines[start_row];
302            result.push_str(&first_line[start_col.min(first_line.len())..]);
303            result.push('\n');
304            // Middle lines (full)
305            for row in (start_row + 1)..end_row {
306                result.push_str(&self.lines[row]);
307                result.push('\n');
308            }
309            // Last line from start to end_col
310            let last_line = &self.lines[end_row];
311            result.push_str(&last_line[..end_col.min(last_line.len())]);
312            Some(result)
313        }
314    }
315
316    /// Delete selection and return the deleted text
317    pub fn delete_selection(&mut self) -> Option<String> {
318        let ((start_row, start_col), (end_row, end_col)) = self.selection_range()?;
319        let deleted = self.selected_text()?;
320
321        if start_row == end_row {
322            let line = &mut self.lines[start_row];
323            let end_col = end_col.min(line.len());
324            let start_col = start_col.min(end_col);
325            line.drain(start_col..end_col);
326        } else {
327            let end_col = end_col.min(self.lines[end_row].len());
328            let after_end = self.lines[end_row][end_col..].to_string();
329            self.lines[start_row].truncate(start_col);
330            self.lines[start_row].push_str(&after_end);
331            // Remove the lines in between
332            for _ in (start_row + 1)..=end_row {
333                self.lines.remove(start_row + 1);
334            }
335        }
336
337        self.cursor_row = start_row;
338        self.cursor_col = start_col;
339        self.selection_anchor = None;
340        Some(deleted)
341    }
342
343    /// Clear selection without deleting text
344    pub fn clear_selection(&mut self) {
345        self.selection_anchor = None;
346    }
347
348    /// Start or extend selection
349    fn ensure_anchor(&mut self) {
350        if self.selection_anchor.is_none() {
351            self.selection_anchor = Some((self.cursor_row, self.cursor_col));
352        }
353    }
354
355    /// Move cursor left with selection (Shift+Left)
356    pub fn move_left_selecting(&mut self) {
357        self.ensure_anchor();
358        self.move_left_internal();
359    }
360
361    /// Move cursor right with selection (Shift+Right)
362    pub fn move_right_selecting(&mut self) {
363        self.ensure_anchor();
364        self.move_right_internal();
365    }
366
367    /// Move cursor up with selection (Shift+Up)
368    pub fn move_up_selecting(&mut self) {
369        self.ensure_anchor();
370        self.move_up_internal();
371    }
372
373    /// Move cursor down with selection (Shift+Down)
374    pub fn move_down_selecting(&mut self) {
375        self.ensure_anchor();
376        self.move_down_internal();
377    }
378
379    /// Move to start of line with selection (Shift+Home)
380    pub fn move_home_selecting(&mut self) {
381        self.ensure_anchor();
382        self.cursor_col = 0;
383    }
384
385    /// Move to end of line with selection (Shift+End)
386    pub fn move_end_selecting(&mut self) {
387        self.ensure_anchor();
388        self.cursor_col = self
389            .lines
390            .get(self.cursor_row)
391            .map(|l| l.len())
392            .unwrap_or(0);
393    }
394
395    /// Move word left with selection (Ctrl+Shift+Left)
396    pub fn move_word_left_selecting(&mut self) {
397        self.ensure_anchor();
398        self.move_word_left_internal();
399    }
400
401    /// Move word right with selection (Ctrl+Shift+Right)
402    pub fn move_word_right_selecting(&mut self) {
403        self.ensure_anchor();
404        self.move_word_right_internal();
405    }
406
407    /// Select all text (Ctrl+A)
408    pub fn select_all(&mut self) {
409        self.selection_anchor = Some((0, 0));
410        self.cursor_row = self.lines.len().saturating_sub(1);
411        self.cursor_col = self.lines.last().map(|l| l.len()).unwrap_or(0);
412    }
413
414    // ========================================================================
415    // Editing operations
416    // ========================================================================
417
418    /// Insert a character at cursor position
419    pub fn insert_char(&mut self, c: char) {
420        // Delete selection first if any
421        if self.has_selection() {
422            self.delete_selection();
423        }
424
425        if c == '\n' && self.multiline {
426            // Split line at cursor
427            let current_line = &self.lines[self.cursor_row];
428            let col = self.cursor_col.min(current_line.len());
429            let (before, after) = current_line.split_at(col);
430            let before = before.to_string();
431            let after = after.to_string();
432            self.lines[self.cursor_row] = before;
433            self.lines.insert(self.cursor_row + 1, after);
434            self.cursor_row += 1;
435            self.cursor_col = 0;
436        } else if c != '\n' && self.cursor_row < self.lines.len() {
437            let line = &mut self.lines[self.cursor_row];
438            let col = self.cursor_col.min(line.len());
439            line.insert(col, c);
440            self.cursor_col = col + c.len_utf8();
441        }
442        // Ignore newline in single-line mode
443    }
444
445    /// Insert a string at cursor position
446    pub fn insert_str(&mut self, text: &str) {
447        if self.has_selection() {
448            self.delete_selection();
449        }
450        for c in text.chars() {
451            // In single-line mode, skip newlines
452            if c == '\n' && !self.multiline {
453                continue;
454            }
455            self.insert_char(c);
456        }
457    }
458
459    /// Delete character before cursor (backspace)
460    pub fn backspace(&mut self) {
461        if self.has_selection() {
462            self.delete_selection();
463            return;
464        }
465
466        if self.cursor_col > 0 {
467            let line = &mut self.lines[self.cursor_row];
468            // Find previous char boundary
469            let mut del_start = self.cursor_col - 1;
470            while del_start > 0 && !line.is_char_boundary(del_start) {
471                del_start -= 1;
472            }
473            line.drain(del_start..self.cursor_col);
474            self.cursor_col = del_start;
475        } else if self.cursor_row > 0 && self.multiline {
476            // Join with previous line
477            let current_line = self.lines.remove(self.cursor_row);
478            self.cursor_row -= 1;
479            self.cursor_col = self.lines[self.cursor_row].len();
480            self.lines[self.cursor_row].push_str(&current_line);
481        }
482    }
483
484    /// Delete character at cursor (delete key)
485    pub fn delete(&mut self) {
486        if self.has_selection() {
487            self.delete_selection();
488            return;
489        }
490
491        let line_len = self
492            .lines
493            .get(self.cursor_row)
494            .map(|l| l.len())
495            .unwrap_or(0);
496        if self.cursor_col < line_len {
497            let line = &mut self.lines[self.cursor_row];
498            // Find next char boundary
499            let mut del_end = self.cursor_col + 1;
500            while del_end < line.len() && !line.is_char_boundary(del_end) {
501                del_end += 1;
502            }
503            line.drain(self.cursor_col..del_end);
504        } else if self.cursor_row + 1 < self.lines.len() && self.multiline {
505            // Join with next line
506            let next_line = self.lines.remove(self.cursor_row + 1);
507            self.lines[self.cursor_row].push_str(&next_line);
508        }
509    }
510
511    /// Delete from cursor to end of word (Ctrl+Delete)
512    pub fn delete_word_forward(&mut self) {
513        if self.has_selection() {
514            self.delete_selection();
515            return;
516        }
517
518        let line = &self.lines[self.cursor_row];
519        let word_end = find_word_end_bytes(line.as_bytes(), self.cursor_col);
520        if word_end > self.cursor_col {
521            let line = &mut self.lines[self.cursor_row];
522            line.drain(self.cursor_col..word_end);
523        } else if self.cursor_row + 1 < self.lines.len() && self.multiline {
524            // At end of line, join with next
525            let next_line = self.lines.remove(self.cursor_row + 1);
526            self.lines[self.cursor_row].push_str(&next_line);
527        }
528    }
529
530    /// Delete from start of word to cursor (Ctrl+Backspace)
531    pub fn delete_word_backward(&mut self) {
532        if self.has_selection() {
533            self.delete_selection();
534            return;
535        }
536
537        let line = &self.lines[self.cursor_row];
538        let word_start = find_word_start_bytes(line.as_bytes(), self.cursor_col);
539        if word_start < self.cursor_col {
540            let line = &mut self.lines[self.cursor_row];
541            line.drain(word_start..self.cursor_col);
542            self.cursor_col = word_start;
543        } else if self.cursor_row > 0 && self.multiline {
544            // At start of line, join with previous
545            let current_line = self.lines.remove(self.cursor_row);
546            self.cursor_row -= 1;
547            self.cursor_col = self.lines[self.cursor_row].len();
548            self.lines[self.cursor_row].push_str(&current_line);
549        }
550    }
551
552    /// Delete from cursor to end of line (Ctrl+K)
553    pub fn delete_to_end(&mut self) {
554        if self.has_selection() {
555            self.delete_selection();
556            return;
557        }
558
559        if let Some(line) = self.lines.get_mut(self.cursor_row) {
560            line.truncate(self.cursor_col);
561        }
562    }
563
564    /// Clear all text
565    pub fn clear(&mut self) {
566        self.lines = vec![String::new()];
567        self.cursor_row = 0;
568        self.cursor_col = 0;
569        self.selection_anchor = None;
570    }
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576
577    #[test]
578    fn test_single_line_basic() {
579        let mut edit = TextEdit::single_line();
580        edit.insert_str("hello world");
581        assert_eq!(edit.value(), "hello world");
582        assert_eq!(edit.cursor_col, 11);
583    }
584
585    #[test]
586    fn test_single_line_ignores_newlines() {
587        let mut edit = TextEdit::single_line();
588        edit.insert_str("hello\nworld");
589        assert_eq!(edit.value(), "helloworld");
590        assert_eq!(edit.line_count(), 1);
591    }
592
593    #[test]
594    fn test_multiline_basic() {
595        let mut edit = TextEdit::new();
596        edit.insert_str("hello\nworld");
597        assert_eq!(edit.value(), "hello\nworld");
598        assert_eq!(edit.line_count(), 2);
599        assert_eq!(edit.cursor_row, 1);
600        assert_eq!(edit.cursor_col, 5);
601    }
602
603    #[test]
604    fn test_selection_single_line() {
605        let mut edit = TextEdit::single_line_with_text("hello world");
606        edit.cursor_col = 6; // After "hello "
607
608        edit.move_right_selecting();
609        edit.move_right_selecting();
610        edit.move_right_selecting();
611        edit.move_right_selecting();
612        edit.move_right_selecting();
613
614        assert!(edit.has_selection());
615        assert_eq!(edit.selected_text(), Some("world".to_string()));
616    }
617
618    #[test]
619    fn test_selection_multiline() {
620        let mut edit = TextEdit::with_text("line1\nline2\nline3");
621        edit.cursor_row = 0;
622        edit.cursor_col = 3; // After "lin"
623
624        // Select to middle of line 2
625        edit.move_down_selecting();
626        edit.move_right_selecting();
627        edit.move_right_selecting();
628
629        assert!(edit.has_selection());
630        let selected = edit.selected_text().unwrap();
631        assert_eq!(selected, "e1\nline2");
632    }
633
634    #[test]
635    fn test_delete_selection() {
636        let mut edit = TextEdit::with_text("hello world");
637        edit.cursor_col = 0;
638
639        // Select "hello "
640        for _ in 0..6 {
641            edit.move_right_selecting();
642        }
643
644        let deleted = edit.delete_selection();
645        assert_eq!(deleted, Some("hello ".to_string()));
646        assert_eq!(edit.value(), "world");
647        assert_eq!(edit.cursor_col, 0);
648    }
649
650    #[test]
651    fn test_backspace_with_selection() {
652        let mut edit = TextEdit::with_text("hello world");
653        edit.select_all();
654        edit.backspace();
655        assert_eq!(edit.value(), "");
656    }
657
658    #[test]
659    fn test_insert_replaces_selection() {
660        let mut edit = TextEdit::with_text("hello world");
661        edit.select_all();
662        edit.insert_str("goodbye");
663        assert_eq!(edit.value(), "goodbye");
664    }
665
666    #[test]
667    fn test_word_navigation() {
668        let mut edit = TextEdit::single_line_with_text("one two three");
669        edit.cursor_col = 0;
670
671        edit.move_word_right();
672        assert_eq!(edit.cursor_col, 3); // After "one"
673
674        edit.move_word_right();
675        assert_eq!(edit.cursor_col, 7); // After "two"
676
677        edit.move_word_left();
678        assert_eq!(edit.cursor_col, 4); // Start of "two"
679    }
680}