Skip to main content

rgx/input/
editor.rs

1use unicode_width::UnicodeWidthStr;
2
3#[derive(Debug, Clone)]
4pub struct Editor {
5    content: String,
6    cursor: usize,
7    scroll_offset: usize,
8    vertical_scroll: usize,
9    undo_stack: Vec<(String, usize)>,
10    redo_stack: Vec<(String, usize)>,
11}
12
13impl Editor {
14    pub fn new() -> Self {
15        Self {
16            content: String::new(),
17            cursor: 0,
18            scroll_offset: 0,
19            vertical_scroll: 0,
20            undo_stack: Vec::new(),
21            redo_stack: Vec::new(),
22        }
23    }
24
25    pub fn with_content(content: String) -> Self {
26        let cursor = content.len();
27        Self {
28            content,
29            cursor,
30            scroll_offset: 0,
31            vertical_scroll: 0,
32            undo_stack: Vec::new(),
33            redo_stack: Vec::new(),
34        }
35    }
36
37    pub fn content(&self) -> &str {
38        &self.content
39    }
40
41    pub fn cursor(&self) -> usize {
42        self.cursor
43    }
44
45    pub fn scroll_offset(&self) -> usize {
46        self.scroll_offset
47    }
48
49    pub fn vertical_scroll(&self) -> usize {
50        self.vertical_scroll
51    }
52
53    /// Returns (line, col) of the cursor where col is the display width within the line.
54    pub fn cursor_line_col(&self) -> (usize, usize) {
55        let before = &self.content[..self.cursor];
56        let line = before.matches('\n').count();
57        let line_start = before.rfind('\n').map(|p| p + 1).unwrap_or(0);
58        let col = UnicodeWidthStr::width(&self.content[line_start..self.cursor]);
59        (line, col)
60    }
61
62    pub fn line_count(&self) -> usize {
63        self.content.matches('\n').count() + 1
64    }
65
66    /// Byte offset of the start of line `n` (0-indexed).
67    fn line_start(&self, n: usize) -> usize {
68        if n == 0 {
69            return 0;
70        }
71        let mut count = 0;
72        for (i, c) in self.content.char_indices() {
73            if c == '\n' {
74                count += 1;
75                if count == n {
76                    return i + 1;
77                }
78            }
79        }
80        self.content.len()
81    }
82
83    /// Byte offset of the end of line `n` (before the newline, or end of string).
84    fn line_end(&self, n: usize) -> usize {
85        let start = self.line_start(n);
86        match self.content[start..].find('\n') {
87            Some(pos) => start + pos,
88            None => self.content.len(),
89        }
90    }
91
92    /// Content of line `n`.
93    fn line_content(&self, n: usize) -> &str {
94        &self.content[self.line_start(n)..self.line_end(n)]
95    }
96
97    /// Visual cursor column within the current line.
98    pub fn visual_cursor(&self) -> usize {
99        let (_, col) = self.cursor_line_col();
100        col.saturating_sub(self.scroll_offset)
101    }
102
103    fn push_undo_snapshot(&mut self) {
104        self.undo_stack.push((self.content.clone(), self.cursor));
105        if self.undo_stack.len() > 500 {
106            self.undo_stack.remove(0);
107        }
108        self.redo_stack.clear();
109    }
110
111    pub fn undo(&mut self) -> bool {
112        if let Some((content, cursor)) = self.undo_stack.pop() {
113            self.redo_stack.push((self.content.clone(), self.cursor));
114            self.content = content;
115            self.cursor = cursor;
116            true
117        } else {
118            false
119        }
120    }
121
122    pub fn redo(&mut self) -> bool {
123        if let Some((content, cursor)) = self.redo_stack.pop() {
124            self.undo_stack.push((self.content.clone(), self.cursor));
125            self.content = content;
126            self.cursor = cursor;
127            true
128        } else {
129            false
130        }
131    }
132
133    pub fn insert_char(&mut self, c: char) {
134        self.push_undo_snapshot();
135        self.content.insert(self.cursor, c);
136        self.cursor += c.len_utf8();
137    }
138
139    pub fn insert_newline(&mut self) {
140        self.push_undo_snapshot();
141        self.content.insert(self.cursor, '\n');
142        self.cursor += 1;
143    }
144
145    pub fn delete_back(&mut self) {
146        if self.cursor > 0 {
147            self.push_undo_snapshot();
148            let prev = self.prev_char_boundary();
149            self.content.drain(prev..self.cursor);
150            self.cursor = prev;
151        }
152    }
153
154    pub fn delete_forward(&mut self) {
155        if self.cursor < self.content.len() {
156            self.push_undo_snapshot();
157            let next = self.next_char_boundary();
158            self.content.drain(self.cursor..next);
159        }
160    }
161
162    pub fn move_left(&mut self) {
163        if self.cursor > 0 {
164            self.cursor = self.prev_char_boundary();
165        }
166    }
167
168    pub fn move_right(&mut self) {
169        if self.cursor < self.content.len() {
170            self.cursor = self.next_char_boundary();
171        }
172    }
173
174    /// Move cursor left by one word (to previous word boundary).
175    pub fn move_word_left(&mut self) {
176        if self.cursor == 0 {
177            return;
178        }
179        let before = &self.content[..self.cursor];
180        let mut chars = before.char_indices().rev();
181        // Skip any non-word chars immediately before cursor
182        let mut last_idx = self.cursor;
183        for (i, c) in &mut chars {
184            if c.is_alphanumeric() || c == '_' {
185                last_idx = i;
186                break;
187            }
188            last_idx = i;
189        }
190        // Skip word chars to find the start of the word
191        if last_idx < self.cursor {
192            let before_word = &self.content[..last_idx];
193            for (i, c) in before_word.char_indices().rev() {
194                if !(c.is_alphanumeric() || c == '_') {
195                    self.cursor = i + c.len_utf8();
196                    return;
197                }
198            }
199            // Reached start of string
200            self.cursor = 0;
201        } else {
202            self.cursor = 0;
203        }
204    }
205
206    /// Move cursor right by one word (to next word boundary).
207    pub fn move_word_right(&mut self) {
208        if self.cursor >= self.content.len() {
209            return;
210        }
211        let after = &self.content[self.cursor..];
212        let mut chars = after.char_indices();
213        // Skip any word chars from current position
214        let mut advanced = false;
215        for (i, c) in &mut chars {
216            if !(c.is_alphanumeric() || c == '_') {
217                if advanced {
218                    self.cursor += i;
219                    // Skip non-word chars to reach next word
220                    let remaining = &self.content[self.cursor..];
221                    for (j, c2) in remaining.char_indices() {
222                        if c2.is_alphanumeric() || c2 == '_' {
223                            self.cursor += j;
224                            return;
225                        }
226                    }
227                    self.cursor = self.content.len();
228                    return;
229                }
230                // We started on non-word chars, skip them
231                let remaining = &self.content[self.cursor + i + c.len_utf8()..];
232                for (j, c2) in remaining.char_indices() {
233                    if c2.is_alphanumeric() || c2 == '_' {
234                        self.cursor = self.cursor + i + c.len_utf8() + j;
235                        return;
236                    }
237                }
238                self.cursor = self.content.len();
239                return;
240            }
241            advanced = true;
242        }
243        self.cursor = self.content.len();
244    }
245
246    pub fn move_up(&mut self) {
247        let (line, col) = self.cursor_line_col();
248        if line > 0 {
249            let target_line = line - 1;
250            let target_start = self.line_start(target_line);
251            let target_content = self.line_content(target_line);
252            self.cursor = target_start + byte_offset_at_width(target_content, col);
253        }
254    }
255
256    pub fn move_down(&mut self) {
257        let (line, col) = self.cursor_line_col();
258        if line + 1 < self.line_count() {
259            let target_line = line + 1;
260            let target_start = self.line_start(target_line);
261            let target_content = self.line_content(target_line);
262            self.cursor = target_start + byte_offset_at_width(target_content, col);
263        }
264    }
265
266    /// Move to start of current line.
267    pub fn move_home(&mut self) {
268        let (line, _) = self.cursor_line_col();
269        self.cursor = self.line_start(line);
270        self.scroll_offset = 0;
271    }
272
273    /// Move to end of current line.
274    pub fn move_end(&mut self) {
275        let (line, _) = self.cursor_line_col();
276        self.cursor = self.line_end(line);
277    }
278
279    /// Update horizontal scroll for the current line.
280    pub fn update_scroll(&mut self, visible_width: usize) {
281        let (_, col) = self.cursor_line_col();
282        if col < self.scroll_offset {
283            self.scroll_offset = col;
284        } else if col >= self.scroll_offset + visible_width {
285            self.scroll_offset = col - visible_width + 1;
286        }
287    }
288
289    /// Update vertical scroll to keep cursor visible within `visible_height` lines.
290    pub fn update_vertical_scroll(&mut self, visible_height: usize) {
291        let (line, _) = self.cursor_line_col();
292        if line < self.vertical_scroll {
293            self.vertical_scroll = line;
294        } else if line >= self.vertical_scroll + visible_height {
295            self.vertical_scroll = line - visible_height + 1;
296        }
297    }
298
299    /// Set cursor by display column (single-line editors / mouse click).
300    pub fn set_cursor_by_col(&mut self, col: usize) {
301        self.cursor = byte_offset_at_width(&self.content, col);
302    }
303
304    /// Set cursor by (line, col) position (multi-line editors / mouse click).
305    pub fn set_cursor_by_position(&mut self, line: usize, col: usize) {
306        let target_line = line.min(self.line_count().saturating_sub(1));
307        let start = self.line_start(target_line);
308        let line_text = self.line_content(target_line);
309        self.cursor = start + byte_offset_at_width(line_text, col);
310    }
311
312    fn prev_char_boundary(&self) -> usize {
313        let mut pos = self.cursor - 1;
314        while !self.content.is_char_boundary(pos) {
315            pos -= 1;
316        }
317        pos
318    }
319
320    fn next_char_boundary(&self) -> usize {
321        let mut pos = self.cursor + 1;
322        while pos < self.content.len() && !self.content.is_char_boundary(pos) {
323            pos += 1;
324        }
325        pos
326    }
327}
328
329/// Convert a target display column width to a byte offset within a line string.
330fn byte_offset_at_width(line: &str, target_width: usize) -> usize {
331    let mut width = 0;
332    for (i, c) in line.char_indices() {
333        if width >= target_width {
334            return i;
335        }
336        width += unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
337    }
338    line.len()
339}
340
341impl Default for Editor {
342    fn default() -> Self {
343        Self::new()
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    #[test]
352    fn test_insert_and_content() {
353        let mut editor = Editor::new();
354        editor.insert_char('h');
355        editor.insert_char('i');
356        assert_eq!(editor.content(), "hi");
357        assert_eq!(editor.cursor(), 2);
358    }
359
360    #[test]
361    fn test_delete_back() {
362        let mut editor = Editor::with_content("hello".to_string());
363        editor.delete_back();
364        assert_eq!(editor.content(), "hell");
365    }
366
367    #[test]
368    fn test_cursor_movement() {
369        let mut editor = Editor::with_content("hello".to_string());
370        editor.move_left();
371        assert_eq!(editor.cursor(), 4);
372        editor.move_home();
373        assert_eq!(editor.cursor(), 0);
374        editor.move_end();
375        assert_eq!(editor.cursor(), 5);
376    }
377
378    #[test]
379    fn test_insert_newline() {
380        let mut editor = Editor::new();
381        editor.insert_char('a');
382        editor.insert_newline();
383        editor.insert_char('b');
384        assert_eq!(editor.content(), "a\nb");
385        assert_eq!(editor.cursor(), 3);
386    }
387
388    #[test]
389    fn test_cursor_line_col() {
390        let editor = Editor::with_content("abc\ndef\nghi".to_string());
391        // cursor is at end: line 2, col 3
392        assert_eq!(editor.cursor_line_col(), (2, 3));
393    }
394
395    #[test]
396    fn test_move_up_down() {
397        let mut editor = Editor::with_content("abc\ndef\nghi".to_string());
398        // cursor at end of "ghi" (line 2, col 3)
399        editor.move_up();
400        assert_eq!(editor.cursor_line_col(), (1, 3));
401        assert_eq!(&editor.content()[..editor.cursor()], "abc\ndef");
402        editor.move_up();
403        assert_eq!(editor.cursor_line_col(), (0, 3));
404        assert_eq!(&editor.content()[..editor.cursor()], "abc");
405        // move_up at top does nothing
406        editor.move_up();
407        assert_eq!(editor.cursor_line_col(), (0, 3));
408        // move back down
409        editor.move_down();
410        assert_eq!(editor.cursor_line_col(), (1, 3));
411    }
412
413    #[test]
414    fn test_move_up_clamps_column() {
415        let mut editor = Editor::with_content("abcdef\nab\nxyz".to_string());
416        // cursor at end: line 2, col 3
417        editor.move_up();
418        // line 1 is "ab" (col 2) — should clamp to end of line
419        assert_eq!(editor.cursor_line_col(), (1, 2));
420        editor.move_up();
421        // line 0 is "abcdef" — col 2
422        assert_eq!(editor.cursor_line_col(), (0, 2));
423    }
424
425    #[test]
426    fn test_line_helpers() {
427        let editor = Editor::with_content("abc\ndef\nghi".to_string());
428        assert_eq!(editor.line_count(), 3);
429        assert_eq!(editor.line_content(0), "abc");
430        assert_eq!(editor.line_content(1), "def");
431        assert_eq!(editor.line_content(2), "ghi");
432    }
433
434    #[test]
435    fn test_home_end_multiline() {
436        let mut editor = Editor::with_content("abc\ndef".to_string());
437        // cursor at end of "def" (line 1)
438        editor.move_home();
439        // should go to start of line 1
440        assert_eq!(editor.cursor(), 4); // "abc\n" = 4 bytes
441        assert_eq!(editor.cursor_line_col(), (1, 0));
442        editor.move_end();
443        assert_eq!(editor.cursor(), 7); // "abc\ndef" = 7 bytes
444        assert_eq!(editor.cursor_line_col(), (1, 3));
445    }
446
447    #[test]
448    fn test_vertical_scroll() {
449        let mut editor = Editor::with_content("a\nb\nc\nd\ne".to_string());
450        editor.update_vertical_scroll(3);
451        // cursor at line 4, visible_height 3 => scroll to 2
452        assert_eq!(editor.vertical_scroll(), 2);
453    }
454
455    #[test]
456    fn test_undo_insert() {
457        let mut editor = Editor::new();
458        editor.insert_char('a');
459        editor.insert_char('b');
460        assert_eq!(editor.content(), "ab");
461        editor.undo();
462        assert_eq!(editor.content(), "a");
463        editor.undo();
464        assert_eq!(editor.content(), "");
465        // Undo on empty stack returns false
466        assert!(!editor.undo());
467    }
468
469    #[test]
470    fn test_undo_delete() {
471        let mut editor = Editor::with_content("abc".to_string());
472        editor.delete_back();
473        assert_eq!(editor.content(), "ab");
474        editor.undo();
475        assert_eq!(editor.content(), "abc");
476    }
477
478    #[test]
479    fn test_redo() {
480        let mut editor = Editor::new();
481        editor.insert_char('a');
482        editor.insert_char('b');
483        editor.undo();
484        assert_eq!(editor.content(), "a");
485        editor.redo();
486        assert_eq!(editor.content(), "ab");
487        // Redo on empty stack returns false
488        assert!(!editor.redo());
489    }
490
491    #[test]
492    fn test_redo_cleared_on_new_edit() {
493        let mut editor = Editor::new();
494        editor.insert_char('a');
495        editor.insert_char('b');
496        editor.undo();
497        // Now type something different — redo stack should clear
498        editor.insert_char('c');
499        assert_eq!(editor.content(), "ac");
500        assert!(!editor.redo());
501    }
502
503    #[test]
504    fn test_set_cursor_by_col() {
505        let mut editor = Editor::with_content("hello".to_string());
506        editor.set_cursor_by_col(3);
507        assert_eq!(editor.cursor(), 3);
508    }
509
510    #[test]
511    fn test_set_cursor_by_position() {
512        let mut editor = Editor::with_content("abc\ndef\nghi".to_string());
513        editor.set_cursor_by_position(1, 2);
514        assert_eq!(editor.cursor_line_col(), (1, 2));
515    }
516
517    #[test]
518    fn test_move_word_right() {
519        let mut editor = Editor::with_content("hello world foo".to_string());
520        editor.cursor = 0;
521        editor.move_word_right();
522        // Should skip past "hello" and stop at start of "world"
523        assert_eq!(editor.cursor(), 6);
524        editor.move_word_right();
525        assert_eq!(editor.cursor(), 12);
526        editor.move_word_right();
527        assert_eq!(editor.cursor(), 15); // end
528    }
529
530    #[test]
531    fn test_move_word_left() {
532        let mut editor = Editor::with_content("hello world foo".to_string());
533        // cursor at end
534        editor.move_word_left();
535        assert_eq!(editor.cursor(), 12); // start of "foo"
536        editor.move_word_left();
537        assert_eq!(editor.cursor(), 6); // start of "world"
538        editor.move_word_left();
539        assert_eq!(editor.cursor(), 0); // start of "hello"
540    }
541
542    #[test]
543    fn test_delete_back_across_newline() {
544        let mut editor = Editor::with_content("abc\ndef".to_string());
545        // cursor at start of "def" (byte 4)
546        editor.cursor = 4;
547        editor.delete_back();
548        assert_eq!(editor.content(), "abcdef");
549        assert_eq!(editor.cursor(), 3);
550    }
551}