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