Skip to main content

rgx/input/
editor.rs

1use std::collections::VecDeque;
2
3use unicode_width::UnicodeWidthStr;
4
5const MAX_UNDO_STACK: usize = 500;
6
7#[derive(Debug, Clone)]
8pub struct Editor {
9    content: String,
10    cursor: usize,
11    scroll_offset: usize,
12    vertical_scroll: usize,
13    undo_stack: VecDeque<(String, usize)>,
14    redo_stack: VecDeque<(String, usize)>,
15}
16
17impl Editor {
18    pub fn new() -> Self {
19        Self {
20            content: String::new(),
21            cursor: 0,
22            scroll_offset: 0,
23            vertical_scroll: 0,
24            undo_stack: VecDeque::new(),
25            redo_stack: VecDeque::new(),
26        }
27    }
28
29    pub fn with_content(content: String) -> Self {
30        let cursor = content.len();
31        Self {
32            content,
33            cursor,
34            scroll_offset: 0,
35            vertical_scroll: 0,
36            undo_stack: VecDeque::new(),
37            redo_stack: VecDeque::new(),
38        }
39    }
40
41    pub fn content(&self) -> &str {
42        &self.content
43    }
44
45    pub fn cursor(&self) -> usize {
46        self.cursor
47    }
48
49    pub fn scroll_offset(&self) -> usize {
50        self.scroll_offset
51    }
52
53    pub fn vertical_scroll(&self) -> usize {
54        self.vertical_scroll
55    }
56
57    /// Returns (line, col) of the cursor where col is the display width within the line.
58    pub fn cursor_line_col(&self) -> (usize, usize) {
59        let before = &self.content[..self.cursor];
60        let line = before.matches('\n').count();
61        let line_start = before.rfind('\n').map(|p| p + 1).unwrap_or(0);
62        let col = UnicodeWidthStr::width(&self.content[line_start..self.cursor]);
63        (line, col)
64    }
65
66    pub fn line_count(&self) -> usize {
67        self.content.matches('\n').count() + 1
68    }
69
70    /// Byte offset of the start of line `n` (0-indexed).
71    fn line_start(&self, n: usize) -> usize {
72        if n == 0 {
73            return 0;
74        }
75        let mut count = 0;
76        for (i, c) in self.content.char_indices() {
77            if c == '\n' {
78                count += 1;
79                if count == n {
80                    return i + 1;
81                }
82            }
83        }
84        self.content.len()
85    }
86
87    /// Byte offset of the end of line `n` (before the newline, or end of string).
88    fn line_end(&self, n: usize) -> usize {
89        let start = self.line_start(n);
90        self.content[start..]
91            .find('\n')
92            .map_or(self.content.len(), |pos| start + pos)
93    }
94
95    /// Content of line `n`.
96    fn line_content(&self, n: usize) -> &str {
97        &self.content[self.line_start(n)..self.line_end(n)]
98    }
99
100    /// Visual cursor column within the current line.
101    pub fn visual_cursor(&self) -> usize {
102        let (_, col) = self.cursor_line_col();
103        col.saturating_sub(self.scroll_offset)
104    }
105
106    fn push_undo_snapshot(&mut self) {
107        self.undo_stack
108            .push_back((self.content.clone(), self.cursor));
109        if self.undo_stack.len() > MAX_UNDO_STACK {
110            self.undo_stack.pop_front();
111        }
112        self.redo_stack.clear();
113    }
114
115    pub fn undo(&mut self) -> bool {
116        if let Some((content, cursor)) = self.undo_stack.pop_back() {
117            self.redo_stack
118                .push_back((self.content.clone(), self.cursor));
119            self.content = content;
120            self.cursor = cursor;
121            true
122        } else {
123            false
124        }
125    }
126
127    pub fn redo(&mut self) -> bool {
128        if let Some((content, cursor)) = self.redo_stack.pop_back() {
129            self.undo_stack
130                .push_back((self.content.clone(), self.cursor));
131            self.content = content;
132            self.cursor = cursor;
133            true
134        } else {
135            false
136        }
137    }
138
139    pub fn insert_char(&mut self, c: char) {
140        self.push_undo_snapshot();
141        self.content.insert(self.cursor, c);
142        self.cursor += c.len_utf8();
143    }
144
145    pub fn insert_newline(&mut self) {
146        self.push_undo_snapshot();
147        self.content.insert(self.cursor, '\n');
148        self.cursor += 1;
149    }
150
151    pub fn delete_back(&mut self) {
152        if self.cursor > 0 {
153            self.push_undo_snapshot();
154            let prev = self.prev_char_boundary();
155            self.content.drain(prev..self.cursor);
156            self.cursor = prev;
157        }
158    }
159
160    pub fn delete_forward(&mut self) {
161        if self.cursor < self.content.len() {
162            self.push_undo_snapshot();
163            let next = self.next_char_boundary();
164            self.content.drain(self.cursor..next);
165        }
166    }
167
168    pub fn move_left(&mut self) {
169        if self.cursor > 0 {
170            self.cursor = self.prev_char_boundary();
171        }
172    }
173
174    /// Move cursor left by one char, but not past the start of the current line.
175    /// Used by vim Esc (EnterNormalMode) which should not cross line boundaries.
176    pub fn move_left_in_line(&mut self) {
177        if self.cursor > 0 && self.content.as_bytes()[self.cursor - 1] != b'\n' {
178            self.cursor = self.prev_char_boundary();
179        }
180    }
181
182    pub fn move_right(&mut self) {
183        if self.cursor < self.content.len() {
184            self.cursor = self.next_char_boundary();
185        }
186    }
187
188    /// Move cursor left by one word (to previous word boundary).
189    pub fn move_word_left(&mut self) {
190        if self.cursor == 0 {
191            return;
192        }
193        let before = &self.content[..self.cursor];
194        let mut chars = before.char_indices().rev();
195        // Skip any non-word chars immediately before cursor
196        let mut last_idx = self.cursor;
197        for (i, c) in &mut chars {
198            if c.is_alphanumeric() || c == '_' {
199                last_idx = i;
200                break;
201            }
202            last_idx = i;
203        }
204        // Skip word chars to find the start of the word
205        if last_idx < self.cursor {
206            let before_word = &self.content[..last_idx];
207            for (i, c) in before_word.char_indices().rev() {
208                if !(c.is_alphanumeric() || c == '_') {
209                    self.cursor = i + c.len_utf8();
210                    return;
211                }
212            }
213            // Reached start of string
214            self.cursor = 0;
215        } else {
216            self.cursor = 0;
217        }
218    }
219
220    /// Move cursor right by one word (to next word boundary).
221    pub fn move_word_right(&mut self) {
222        if self.cursor >= self.content.len() {
223            return;
224        }
225        let after = &self.content[self.cursor..];
226        let mut chars = after.char_indices();
227        // Skip any word chars from current position
228        let mut advanced = false;
229        for (i, c) in &mut chars {
230            if !(c.is_alphanumeric() || c == '_') {
231                if advanced {
232                    self.cursor += i;
233                    // Skip non-word chars to reach next word
234                    let remaining = &self.content[self.cursor..];
235                    for (j, c2) in remaining.char_indices() {
236                        if c2.is_alphanumeric() || c2 == '_' {
237                            self.cursor += j;
238                            return;
239                        }
240                    }
241                    self.cursor = self.content.len();
242                    return;
243                }
244                // We started on non-word chars, skip them
245                let remaining = &self.content[self.cursor + i + c.len_utf8()..];
246                for (j, c2) in remaining.char_indices() {
247                    if c2.is_alphanumeric() || c2 == '_' {
248                        self.cursor = self.cursor + i + c.len_utf8() + j;
249                        return;
250                    }
251                }
252                self.cursor = self.content.len();
253                return;
254            }
255            advanced = true;
256        }
257        self.cursor = self.content.len();
258    }
259
260    pub fn move_up(&mut self) {
261        let (line, col) = self.cursor_line_col();
262        if line > 0 {
263            let target_line = line - 1;
264            let target_start = self.line_start(target_line);
265            let target_content = self.line_content(target_line);
266            self.cursor = target_start + byte_offset_at_width(target_content, col);
267        }
268    }
269
270    pub fn move_down(&mut self) {
271        let (line, col) = self.cursor_line_col();
272        if line + 1 < self.line_count() {
273            let target_line = line + 1;
274            let target_start = self.line_start(target_line);
275            let target_content = self.line_content(target_line);
276            self.cursor = target_start + byte_offset_at_width(target_content, col);
277        }
278    }
279
280    /// Move to start of current line.
281    pub fn move_home(&mut self) {
282        let (line, _) = self.cursor_line_col();
283        self.cursor = self.line_start(line);
284        self.scroll_offset = 0;
285    }
286
287    /// Move to end of current line.
288    pub fn move_end(&mut self) {
289        let (line, _) = self.cursor_line_col();
290        self.cursor = self.line_end(line);
291    }
292
293    /// Delete character under cursor (vim `x`). Does nothing at end of content.
294    pub fn delete_char_at_cursor(&mut self) {
295        self.delete_forward();
296    }
297
298    /// Delete the current line (vim `dd`).
299    pub fn delete_line(&mut self) {
300        self.push_undo_snapshot();
301        let (line, _) = self.cursor_line_col();
302        let start = self.line_start(line);
303        let end = self.line_end(line);
304        let line_count = self.line_count();
305
306        if line_count == 1 {
307            self.content.clear();
308            self.cursor = 0;
309        } else if line + 1 < line_count {
310            // Not the last line — delete line including trailing newline
311            self.content.drain(start..=end);
312            self.cursor = start;
313        } else {
314            // Last line — delete leading newline + line content
315            self.content.drain(start - 1..end);
316            let prev = line.saturating_sub(1);
317            self.cursor = self.line_start(prev);
318        }
319    }
320
321    /// Clear the current line's content but keep the line (vim `cc`).
322    pub fn clear_line(&mut self) {
323        self.push_undo_snapshot();
324        let (line, _) = self.cursor_line_col();
325        let start = self.line_start(line);
326        let end = self.line_end(line);
327        self.content.drain(start..end);
328        self.cursor = start;
329    }
330
331    /// Insert a string at cursor (single undo snapshot). Used for paste.
332    pub fn insert_str(&mut self, s: &str) {
333        if s.is_empty() {
334            return;
335        }
336        self.push_undo_snapshot();
337        self.content.insert_str(self.cursor, s);
338        self.cursor += s.len();
339    }
340
341    /// Insert a new line below current and move cursor there (vim `o`).
342    pub fn open_line_below(&mut self) {
343        self.push_undo_snapshot();
344        let (line, _) = self.cursor_line_col();
345        let end = self.line_end(line);
346        self.content.insert(end, '\n');
347        self.cursor = end + 1;
348    }
349
350    /// Insert a new line above current and move cursor there (vim `O`).
351    pub fn open_line_above(&mut self) {
352        self.push_undo_snapshot();
353        let (line, _) = self.cursor_line_col();
354        let start = self.line_start(line);
355        self.content.insert(start, '\n');
356        self.cursor = start;
357    }
358
359    /// Move cursor to first non-whitespace character on current line (vim `^`).
360    pub fn move_to_first_non_blank(&mut self) {
361        let (line, _) = self.cursor_line_col();
362        let start = self.line_start(line);
363        let line_text = self.line_content(line);
364        let offset = line_text
365            .char_indices()
366            .find(|(_, c)| !c.is_whitespace())
367            .map(|(i, _)| i)
368            .unwrap_or(0);
369        self.cursor = start + offset;
370    }
371
372    /// Move cursor to start of first line (vim `gg`).
373    pub fn move_to_first_line(&mut self) {
374        self.cursor = 0;
375    }
376
377    /// Move cursor to start of last line (vim `G`).
378    pub fn move_to_last_line(&mut self) {
379        let last = self.line_count().saturating_sub(1);
380        self.cursor = self.line_start(last);
381    }
382
383    /// Move cursor forward to end of current/next word (vim `e`).
384    pub fn move_word_forward_end(&mut self) {
385        if self.cursor >= self.content.len() {
386            return;
387        }
388        let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
389        let after = &self.content[self.cursor..];
390        let mut chars = after.char_indices().peekable();
391
392        // Always advance at least one character
393        if chars.next().is_none() {
394            return;
395        }
396
397        // Skip whitespace
398        while let Some(&(_, c)) = chars.peek() {
399            if !c.is_whitespace() {
400                break;
401            }
402            chars.next();
403        }
404
405        // Find end of the word
406        if let Some(&(first_offset, first)) = chars.peek() {
407            let first_is_word = is_word_char(first);
408            let mut last_offset = first_offset;
409            chars.next();
410
411            for (i, c) in chars {
412                if is_word_char(c) != first_is_word || c.is_whitespace() {
413                    break;
414                }
415                last_offset = i;
416            }
417            self.cursor += last_offset;
418        }
419    }
420
421    /// Update horizontal scroll for the current line.
422    pub fn update_scroll(&mut self, visible_width: usize) {
423        let (_, col) = self.cursor_line_col();
424        if col < self.scroll_offset {
425            self.scroll_offset = col;
426        } else if col >= self.scroll_offset + visible_width {
427            self.scroll_offset = col - visible_width + 1;
428        }
429    }
430
431    /// Update vertical scroll to keep cursor visible within `visible_height` lines.
432    pub fn update_vertical_scroll(&mut self, visible_height: usize) {
433        let (line, _) = self.cursor_line_col();
434        if line < self.vertical_scroll {
435            self.vertical_scroll = line;
436        } else if line >= self.vertical_scroll + visible_height {
437            self.vertical_scroll = line - visible_height + 1;
438        }
439    }
440
441    /// Set cursor by display column (single-line editors / mouse click).
442    pub fn set_cursor_by_col(&mut self, col: usize) {
443        self.cursor = byte_offset_at_width(&self.content, col);
444    }
445
446    /// Set cursor by (line, col) position (multi-line editors / mouse click).
447    pub fn set_cursor_by_position(&mut self, line: usize, col: usize) {
448        let target_line = line.min(self.line_count().saturating_sub(1));
449        let start = self.line_start(target_line);
450        let line_text = self.line_content(target_line);
451        self.cursor = start + byte_offset_at_width(line_text, col);
452    }
453
454    fn prev_char_boundary(&self) -> usize {
455        let mut pos = self.cursor - 1;
456        while !self.content.is_char_boundary(pos) {
457            pos -= 1;
458        }
459        pos
460    }
461
462    fn next_char_boundary(&self) -> usize {
463        let mut pos = self.cursor + 1;
464        while pos < self.content.len() && !self.content.is_char_boundary(pos) {
465            pos += 1;
466        }
467        pos
468    }
469}
470
471/// Convert a target display column width to a byte offset within a line string.
472fn byte_offset_at_width(line: &str, target_width: usize) -> usize {
473    let mut width = 0;
474    for (i, c) in line.char_indices() {
475        if width >= target_width {
476            return i;
477        }
478        width += unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
479    }
480    line.len()
481}
482
483impl Default for Editor {
484    fn default() -> Self {
485        Self::new()
486    }
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492
493    #[test]
494    fn test_insert_and_content() {
495        let mut editor = Editor::new();
496        editor.insert_char('h');
497        editor.insert_char('i');
498        assert_eq!(editor.content(), "hi");
499        assert_eq!(editor.cursor(), 2);
500    }
501
502    #[test]
503    fn test_delete_back() {
504        let mut editor = Editor::with_content("hello".to_string());
505        editor.delete_back();
506        assert_eq!(editor.content(), "hell");
507    }
508
509    #[test]
510    fn test_cursor_movement() {
511        let mut editor = Editor::with_content("hello".to_string());
512        editor.move_left();
513        assert_eq!(editor.cursor(), 4);
514        editor.move_home();
515        assert_eq!(editor.cursor(), 0);
516        editor.move_end();
517        assert_eq!(editor.cursor(), 5);
518    }
519
520    #[test]
521    fn test_insert_newline() {
522        let mut editor = Editor::new();
523        editor.insert_char('a');
524        editor.insert_newline();
525        editor.insert_char('b');
526        assert_eq!(editor.content(), "a\nb");
527        assert_eq!(editor.cursor(), 3);
528    }
529
530    #[test]
531    fn test_cursor_line_col() {
532        let editor = Editor::with_content("abc\ndef\nghi".to_string());
533        // cursor is at end: line 2, col 3
534        assert_eq!(editor.cursor_line_col(), (2, 3));
535    }
536
537    #[test]
538    fn test_move_up_down() {
539        let mut editor = Editor::with_content("abc\ndef\nghi".to_string());
540        // cursor at end of "ghi" (line 2, col 3)
541        editor.move_up();
542        assert_eq!(editor.cursor_line_col(), (1, 3));
543        assert_eq!(&editor.content()[..editor.cursor()], "abc\ndef");
544        editor.move_up();
545        assert_eq!(editor.cursor_line_col(), (0, 3));
546        assert_eq!(&editor.content()[..editor.cursor()], "abc");
547        // move_up at top does nothing
548        editor.move_up();
549        assert_eq!(editor.cursor_line_col(), (0, 3));
550        // move back down
551        editor.move_down();
552        assert_eq!(editor.cursor_line_col(), (1, 3));
553    }
554
555    #[test]
556    fn test_move_up_clamps_column() {
557        let mut editor = Editor::with_content("abcdef\nab\nxyz".to_string());
558        // cursor at end: line 2, col 3
559        editor.move_up();
560        // line 1 is "ab" (col 2) — should clamp to end of line
561        assert_eq!(editor.cursor_line_col(), (1, 2));
562        editor.move_up();
563        // line 0 is "abcdef" — col 2
564        assert_eq!(editor.cursor_line_col(), (0, 2));
565    }
566
567    #[test]
568    fn test_line_helpers() {
569        let editor = Editor::with_content("abc\ndef\nghi".to_string());
570        assert_eq!(editor.line_count(), 3);
571        assert_eq!(editor.line_content(0), "abc");
572        assert_eq!(editor.line_content(1), "def");
573        assert_eq!(editor.line_content(2), "ghi");
574    }
575
576    #[test]
577    fn test_home_end_multiline() {
578        let mut editor = Editor::with_content("abc\ndef".to_string());
579        // cursor at end of "def" (line 1)
580        editor.move_home();
581        // should go to start of line 1
582        assert_eq!(editor.cursor(), 4); // "abc\n" = 4 bytes
583        assert_eq!(editor.cursor_line_col(), (1, 0));
584        editor.move_end();
585        assert_eq!(editor.cursor(), 7); // "abc\ndef" = 7 bytes
586        assert_eq!(editor.cursor_line_col(), (1, 3));
587    }
588
589    #[test]
590    fn test_vertical_scroll() {
591        let mut editor = Editor::with_content("a\nb\nc\nd\ne".to_string());
592        editor.update_vertical_scroll(3);
593        // cursor at line 4, visible_height 3 => scroll to 2
594        assert_eq!(editor.vertical_scroll(), 2);
595    }
596
597    #[test]
598    fn test_undo_insert() {
599        let mut editor = Editor::new();
600        editor.insert_char('a');
601        editor.insert_char('b');
602        assert_eq!(editor.content(), "ab");
603        editor.undo();
604        assert_eq!(editor.content(), "a");
605        editor.undo();
606        assert_eq!(editor.content(), "");
607        // Undo on empty stack returns false
608        assert!(!editor.undo());
609    }
610
611    #[test]
612    fn test_undo_delete() {
613        let mut editor = Editor::with_content("abc".to_string());
614        editor.delete_back();
615        assert_eq!(editor.content(), "ab");
616        editor.undo();
617        assert_eq!(editor.content(), "abc");
618    }
619
620    #[test]
621    fn test_redo() {
622        let mut editor = Editor::new();
623        editor.insert_char('a');
624        editor.insert_char('b');
625        editor.undo();
626        assert_eq!(editor.content(), "a");
627        editor.redo();
628        assert_eq!(editor.content(), "ab");
629        // Redo on empty stack returns false
630        assert!(!editor.redo());
631    }
632
633    #[test]
634    fn test_redo_cleared_on_new_edit() {
635        let mut editor = Editor::new();
636        editor.insert_char('a');
637        editor.insert_char('b');
638        editor.undo();
639        // Now type something different — redo stack should clear
640        editor.insert_char('c');
641        assert_eq!(editor.content(), "ac");
642        assert!(!editor.redo());
643    }
644
645    #[test]
646    fn test_set_cursor_by_col() {
647        let mut editor = Editor::with_content("hello".to_string());
648        editor.set_cursor_by_col(3);
649        assert_eq!(editor.cursor(), 3);
650    }
651
652    #[test]
653    fn test_set_cursor_by_position() {
654        let mut editor = Editor::with_content("abc\ndef\nghi".to_string());
655        editor.set_cursor_by_position(1, 2);
656        assert_eq!(editor.cursor_line_col(), (1, 2));
657    }
658
659    #[test]
660    fn test_move_word_right() {
661        let mut editor = Editor::with_content("hello world foo".to_string());
662        editor.cursor = 0;
663        editor.move_word_right();
664        // Should skip past "hello" and stop at start of "world"
665        assert_eq!(editor.cursor(), 6);
666        editor.move_word_right();
667        assert_eq!(editor.cursor(), 12);
668        editor.move_word_right();
669        assert_eq!(editor.cursor(), 15); // end
670    }
671
672    #[test]
673    fn test_move_word_left() {
674        let mut editor = Editor::with_content("hello world foo".to_string());
675        // cursor at end
676        editor.move_word_left();
677        assert_eq!(editor.cursor(), 12); // start of "foo"
678        editor.move_word_left();
679        assert_eq!(editor.cursor(), 6); // start of "world"
680        editor.move_word_left();
681        assert_eq!(editor.cursor(), 0); // start of "hello"
682    }
683
684    #[test]
685    fn test_delete_back_across_newline() {
686        let mut editor = Editor::with_content("abc\ndef".to_string());
687        // cursor at start of "def" (byte 4)
688        editor.cursor = 4;
689        editor.delete_back();
690        assert_eq!(editor.content(), "abcdef");
691        assert_eq!(editor.cursor(), 3);
692    }
693
694    #[test]
695    fn test_delete_char_at_cursor() {
696        let mut editor = Editor::with_content("hello".to_string());
697        editor.cursor = 0;
698        editor.delete_char_at_cursor();
699        assert_eq!(editor.content(), "ello");
700        assert_eq!(editor.cursor(), 0);
701    }
702
703    #[test]
704    fn test_delete_char_at_cursor_end() {
705        let mut editor = Editor::with_content("hello".to_string());
706        editor.delete_char_at_cursor();
707        assert_eq!(editor.content(), "hello");
708    }
709
710    #[test]
711    fn test_delete_line() {
712        let mut editor = Editor::with_content("abc\ndef\nghi".to_string());
713        editor.cursor = 5;
714        editor.delete_line();
715        assert_eq!(editor.content(), "abc\nghi");
716        assert_eq!(editor.cursor(), 4);
717    }
718
719    #[test]
720    fn test_delete_line_last() {
721        let mut editor = Editor::with_content("abc\ndef".to_string());
722        editor.cursor = 5;
723        editor.delete_line();
724        assert_eq!(editor.content(), "abc");
725        assert_eq!(editor.cursor(), 0);
726    }
727
728    #[test]
729    fn test_delete_line_single() {
730        let mut editor = Editor::with_content("hello".to_string());
731        editor.cursor = 2;
732        editor.delete_line();
733        assert_eq!(editor.content(), "");
734        assert_eq!(editor.cursor(), 0);
735    }
736
737    #[test]
738    fn test_clear_line() {
739        let mut editor = Editor::with_content("abc\ndef\nghi".to_string());
740        editor.cursor = 5;
741        editor.clear_line();
742        assert_eq!(editor.content(), "abc\n\nghi");
743        assert_eq!(editor.cursor(), 4);
744    }
745
746    #[test]
747    fn test_clear_line_single() {
748        let mut editor = Editor::with_content("hello".to_string());
749        editor.cursor = 2;
750        editor.clear_line();
751        assert_eq!(editor.content(), "");
752        assert_eq!(editor.cursor(), 0);
753    }
754
755    #[test]
756    fn test_insert_str() {
757        let mut editor = Editor::with_content("hd".to_string());
758        editor.cursor = 1;
759        editor.insert_str("ello worl");
760        assert_eq!(editor.content(), "hello world");
761        assert_eq!(editor.cursor(), 10);
762    }
763
764    #[test]
765    fn test_insert_str_undo() {
766        let mut editor = Editor::with_content("ad".to_string());
767        editor.cursor = 1;
768        editor.insert_str("bc");
769        assert_eq!(editor.content(), "abcd");
770        editor.undo();
771        assert_eq!(editor.content(), "ad");
772    }
773
774    #[test]
775    fn test_open_line_below() {
776        let mut editor = Editor::with_content("abc\ndef".to_string());
777        editor.cursor = 1;
778        editor.open_line_below();
779        assert_eq!(editor.content(), "abc\n\ndef");
780        assert_eq!(editor.cursor(), 4);
781    }
782
783    #[test]
784    fn test_open_line_above() {
785        let mut editor = Editor::with_content("abc\ndef".to_string());
786        editor.cursor = 5;
787        editor.open_line_above();
788        assert_eq!(editor.content(), "abc\n\ndef");
789        assert_eq!(editor.cursor(), 4);
790    }
791
792    #[test]
793    fn test_move_to_first_non_blank() {
794        let mut editor = Editor::with_content("   hello".to_string());
795        editor.cursor = 7;
796        editor.move_to_first_non_blank();
797        assert_eq!(editor.cursor(), 3);
798    }
799
800    #[test]
801    fn test_move_to_first_line() {
802        let mut editor = Editor::with_content("abc\ndef\nghi".to_string());
803        editor.move_to_first_line();
804        assert_eq!(editor.cursor(), 0);
805    }
806
807    #[test]
808    fn test_move_to_last_line() {
809        let mut editor = Editor::with_content("abc\ndef\nghi".to_string());
810        editor.cursor = 0;
811        editor.move_to_last_line();
812        let (line, _) = editor.cursor_line_col();
813        assert_eq!(line, 2);
814    }
815
816    #[test]
817    fn test_move_word_forward_end() {
818        let mut editor = Editor::with_content("hello world foo".to_string());
819        editor.cursor = 0;
820        editor.move_word_forward_end();
821        assert_eq!(editor.cursor(), 4);
822        editor.move_word_forward_end();
823        assert_eq!(editor.cursor(), 10);
824    }
825
826    #[test]
827    fn test_move_left_in_line_normal() {
828        let mut editor = Editor::with_content("hello".to_string());
829        // cursor at end (byte 5)
830        editor.move_left_in_line();
831        assert_eq!(editor.cursor(), 4);
832    }
833
834    #[test]
835    fn test_move_left_in_line_at_line_start() {
836        let mut editor = Editor::with_content("abc\ndef".to_string());
837        // cursor at start of "def" (byte 4, right after '\n')
838        editor.cursor = 4;
839        editor.move_left_in_line();
840        // Should NOT cross the newline
841        assert_eq!(editor.cursor(), 4);
842    }
843
844    #[test]
845    fn test_move_left_in_line_at_content_start() {
846        let mut editor = Editor::with_content("hello".to_string());
847        editor.cursor = 0;
848        editor.move_left_in_line();
849        assert_eq!(editor.cursor(), 0);
850    }
851}