Skip to main content

markless/editor/
buffer.rs

1use std::hash::{DefaultHasher, Hash, Hasher};
2
3use ropey::Rope;
4
5/// Cursor position in the editor buffer.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub struct Cursor {
8    /// Zero-based line index.
9    pub line: usize,
10    /// Zero-based column (byte offset within the line).
11    pub col: usize,
12    /// Remembered column for vertical movement (sticky column).
13    col_memory: usize,
14}
15
16impl Cursor {
17    /// Create a cursor at line 0, column 0.
18    pub const fn new() -> Self {
19        Self {
20            line: 0,
21            col: 0,
22            col_memory: 0,
23        }
24    }
25
26    /// Create a cursor at a specific position.
27    pub const fn at(line: usize, col: usize) -> Self {
28        Self {
29            line,
30            col,
31            col_memory: col,
32        }
33    }
34
35    /// Update column and reset column memory to match.
36    const fn set_col(&mut self, col: usize) {
37        self.col = col;
38        self.col_memory = col;
39    }
40}
41
42impl Default for Cursor {
43    fn default() -> Self {
44        Self::new()
45    }
46}
47
48/// Direction for cursor movement.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum Direction {
51    Up,
52    Down,
53    Left,
54    Right,
55}
56
57/// A text buffer backed by a rope data structure.
58///
59/// Provides efficient insertion, deletion, and line-based operations
60/// for editing text files. The cursor tracks the current editing position.
61pub struct EditorBuffer {
62    rope: Rope,
63    cursor: Cursor,
64    /// Hash of the text content at the last save point (or initial load).
65    clean_hash: u64,
66}
67
68impl EditorBuffer {
69    /// Create a new buffer from a string.
70    pub fn from_text(text: &str) -> Self {
71        let rope = Rope::from_str(text);
72        let clean_hash = Self::rope_hash(&rope);
73        Self {
74            rope,
75            cursor: Cursor::new(),
76            clean_hash,
77        }
78    }
79
80    /// Create an empty buffer.
81    pub fn empty() -> Self {
82        Self::from_text("")
83    }
84
85    /// The current cursor position.
86    pub const fn cursor(&self) -> Cursor {
87        self.cursor
88    }
89
90    /// Whether the buffer content differs from the last save point.
91    pub fn is_dirty(&self) -> bool {
92        Self::rope_hash(&self.rope) != self.clean_hash
93    }
94
95    /// Mark the buffer as clean (e.g., after saving).
96    pub fn mark_clean(&mut self) {
97        self.clean_hash = Self::rope_hash(&self.rope);
98    }
99
100    /// Total number of lines in the buffer.
101    pub fn line_count(&self) -> usize {
102        self.rope.len_lines()
103    }
104
105    /// Get the content of a line (without trailing newline).
106    pub fn line_at(&self, line_idx: usize) -> Option<String> {
107        if line_idx >= self.rope.len_lines() {
108            return None;
109        }
110        let line = self.rope.line(line_idx);
111        let s = line.to_string();
112        // Strip trailing newline if present
113        Some(s.trim_end_matches('\n').trim_end_matches('\r').to_string())
114    }
115
116    /// Length of a line in bytes (without trailing newline).
117    pub fn line_len(&self, line_idx: usize) -> usize {
118        self.line_at(line_idx).map_or(0, |s| s.len())
119    }
120
121    /// The full text content of the buffer.
122    pub fn text(&self) -> String {
123        self.rope.to_string()
124    }
125
126    /// Insert a character at the cursor position.
127    pub fn insert_char(&mut self, ch: char) {
128        let char_idx = self.cursor_char_idx();
129        self.rope.insert_char(char_idx, ch);
130        self.cursor.set_col(self.cursor.col + ch.len_utf8());
131    }
132
133    /// Insert a string at the cursor position.
134    pub fn insert_str(&mut self, s: &str) {
135        if s.is_empty() {
136            return;
137        }
138        let char_idx = self.cursor_char_idx();
139        self.rope.insert(char_idx, s);
140
141        // Move cursor to end of inserted text
142        let lines: Vec<&str> = s.split('\n').collect();
143        if lines.len() > 1 {
144            self.cursor.line += lines.len() - 1;
145            self.cursor.set_col(lines.last().map_or(0, |l| l.len()));
146        } else {
147            self.cursor.set_col(self.cursor.col + s.len());
148        }
149    }
150
151    /// Split the current line at the cursor (Enter key).
152    pub fn split_line(&mut self) {
153        let char_idx = self.cursor_char_idx();
154        self.rope.insert_char(char_idx, '\n');
155        self.cursor.line += 1;
156        self.cursor.set_col(0);
157    }
158
159    /// Delete the character before the cursor (Backspace).
160    ///
161    /// Returns `true` if a character was deleted.
162    pub fn delete_back(&mut self) -> bool {
163        if self.cursor.col == 0 && self.cursor.line == 0 {
164            return false;
165        }
166
167        if self.cursor.col == 0 {
168            // Join with previous line
169            let prev_line_len = self.line_len(self.cursor.line - 1);
170            let char_idx = self.cursor_char_idx();
171            // Delete the newline at end of previous line
172            self.rope.remove(char_idx - 1..char_idx);
173            self.cursor.line -= 1;
174            self.cursor.set_col(prev_line_len);
175        } else {
176            // Delete character before cursor
177            let char_idx = self.cursor_char_idx();
178            // Find the byte length of the character before cursor
179            let line = self.rope.line(self.cursor.line);
180            let line_str = line.to_string();
181            let before = line_str.get(..self.cursor.col).unwrap_or(&line_str);
182            let prev_char_len = before.chars().next_back().map_or(1, char::len_utf8);
183            self.rope.remove(char_idx - 1..char_idx);
184            self.cursor.set_col(self.cursor.col - prev_char_len);
185        }
186
187        true
188    }
189
190    /// Delete the character at the cursor (Delete key).
191    ///
192    /// Returns `true` if a character was deleted.
193    pub fn delete_forward(&mut self) -> bool {
194        let line_len = self.line_len(self.cursor.line);
195
196        if self.cursor.col >= line_len && self.cursor.line + 1 >= self.line_count() {
197            return false;
198        }
199
200        let char_idx = self.cursor_char_idx();
201        self.rope.remove(char_idx..=char_idx);
202
203        true
204    }
205
206    /// Move the cursor in the given direction.
207    pub fn move_cursor(&mut self, direction: Direction) {
208        match direction {
209            Direction::Left => self.move_left(),
210            Direction::Right => self.move_right(),
211            Direction::Up => self.move_up(),
212            Direction::Down => self.move_down(),
213        }
214    }
215
216    /// Move cursor to the beginning of the line (Home).
217    pub const fn move_home(&mut self) {
218        self.cursor.set_col(0);
219    }
220
221    /// Move cursor to the end of the line (End).
222    pub fn move_end(&mut self) {
223        let len = self.line_len(self.cursor.line);
224        self.cursor.set_col(len);
225    }
226
227    /// Move cursor one word to the left (Ctrl+Left).
228    pub fn move_word_left(&mut self) {
229        if self.cursor.col == 0 {
230            if self.cursor.line > 0 {
231                self.cursor.line -= 1;
232                self.cursor.set_col(self.line_len(self.cursor.line));
233            }
234            return;
235        }
236
237        let line = self.line_at(self.cursor.line).unwrap_or_default();
238        let before = line.get(..self.cursor.col).unwrap_or(&line);
239        let trimmed = before.trim_end();
240
241        if trimmed.is_empty() {
242            self.cursor.set_col(0);
243            return;
244        }
245
246        // Find start of previous word
247        let pos = trimmed
248            .rfind(|c: char| !c.is_alphanumeric() && c != '_')
249            .map_or(0, |i| i + 1);
250        self.cursor.set_col(pos);
251    }
252
253    /// Move cursor one word to the right (Ctrl+Right).
254    pub fn move_word_right(&mut self) {
255        let line_len = self.line_len(self.cursor.line);
256
257        if self.cursor.col >= line_len {
258            if self.cursor.line + 1 < self.line_count() {
259                self.cursor.line += 1;
260                self.cursor.set_col(0);
261            }
262            return;
263        }
264
265        let line = self.line_at(self.cursor.line).unwrap_or_default();
266        let after = line.get(self.cursor.col..).unwrap_or_default();
267
268        // Skip current word characters
269        let word_end = after
270            .find(|c: char| !c.is_alphanumeric() && c != '_')
271            .unwrap_or(after.len());
272
273        // Skip whitespace/punctuation after word
274        let rest = after.get(word_end..).unwrap_or_default();
275        let space_end = rest
276            .find(|c: char| c.is_alphanumeric() || c == '_')
277            .unwrap_or(rest.len());
278
279        self.cursor.set_col(self.cursor.col + word_end + space_end);
280    }
281
282    /// Move cursor to a specific line and column.
283    pub fn move_to(&mut self, line: usize, col: usize) {
284        let max_line = self.line_count().saturating_sub(1);
285        self.cursor.line = line.min(max_line);
286        let max_col = self.line_len(self.cursor.line);
287        self.cursor.set_col(col.min(max_col));
288    }
289
290    /// Move cursor to the start of the buffer (Ctrl+Home).
291    pub const fn move_to_start(&mut self) {
292        self.cursor.line = 0;
293        self.cursor.set_col(0);
294    }
295
296    /// Move cursor to the end of the buffer (Ctrl+End).
297    pub fn move_to_end(&mut self) {
298        let last_line = self.line_count().saturating_sub(1);
299        self.cursor.line = last_line;
300        self.cursor.set_col(self.line_len(last_line));
301    }
302
303    // --- Private helpers ---
304
305    /// Hash the rope content for dirty-checking.
306    fn rope_hash(rope: &Rope) -> u64 {
307        let mut hasher = DefaultHasher::new();
308        for chunk in rope.chunks() {
309            chunk.hash(&mut hasher);
310        }
311        hasher.finish()
312    }
313
314    /// Convert cursor position to a ropey char index.
315    fn cursor_char_idx(&self) -> usize {
316        let line_start = self.rope.line_to_char(self.cursor.line);
317        let line = self.rope.line(self.cursor.line);
318        let line_str: String = line.chars().collect();
319        // Convert byte offset to char offset within the line
320        let byte_col = self.cursor.col.min(line_str.len());
321        let char_offset = line_str
322            .get(..byte_col)
323            .unwrap_or(&line_str)
324            .chars()
325            .count();
326        line_start + char_offset
327    }
328
329    fn move_left(&mut self) {
330        if self.cursor.col > 0 {
331            let line = self.line_at(self.cursor.line).unwrap_or_default();
332            let before = line.get(..self.cursor.col).unwrap_or(&line);
333            let prev_char_len = before.chars().next_back().map_or(1, char::len_utf8);
334            self.cursor.set_col(self.cursor.col - prev_char_len);
335        } else if self.cursor.line > 0 {
336            self.cursor.line -= 1;
337            self.cursor.set_col(self.line_len(self.cursor.line));
338        }
339    }
340
341    fn move_right(&mut self) {
342        let line_len = self.line_len(self.cursor.line);
343        if self.cursor.col < line_len {
344            let line = self.line_at(self.cursor.line).unwrap_or_default();
345            let next_char_len = line
346                .get(self.cursor.col..)
347                .unwrap_or_default()
348                .chars()
349                .next()
350                .map_or(1, char::len_utf8);
351            self.cursor.set_col(self.cursor.col + next_char_len);
352        } else if self.cursor.line + 1 < self.line_count() {
353            self.cursor.line += 1;
354            self.cursor.set_col(0);
355        }
356    }
357
358    fn move_up(&mut self) {
359        if self.cursor.line > 0 {
360            self.cursor.line -= 1;
361            let max_col = self.line_len(self.cursor.line);
362            self.cursor.col = self.cursor.col_memory.min(max_col);
363        }
364    }
365
366    fn move_down(&mut self) {
367        if self.cursor.line + 1 < self.line_count() {
368            self.cursor.line += 1;
369            let max_col = self.line_len(self.cursor.line);
370            self.cursor.col = self.cursor.col_memory.min(max_col);
371        }
372    }
373}
374
375impl std::fmt::Debug for EditorBuffer {
376    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
377        f.debug_struct("EditorBuffer")
378            .field(
379                "rope",
380                &format_args!("Rope({} lines)", self.rope.len_lines()),
381            )
382            .field("cursor", &self.cursor)
383            .field("dirty", &self.is_dirty())
384            .finish_non_exhaustive()
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    // --- Construction and basic queries ---
393
394    #[test]
395    fn test_empty_buffer_has_one_line() {
396        let buf = EditorBuffer::empty();
397        assert_eq!(buf.line_count(), 1);
398        assert_eq!(buf.line_at(0), Some(String::new()));
399    }
400
401    #[test]
402    fn test_from_text_preserves_content() {
403        let buf = EditorBuffer::from_text("hello\nworld");
404        assert_eq!(buf.line_count(), 2);
405        assert_eq!(buf.line_at(0), Some("hello".to_string()));
406        assert_eq!(buf.line_at(1), Some("world".to_string()));
407    }
408
409    #[test]
410    fn test_from_text_trailing_newline() {
411        let buf = EditorBuffer::from_text("hello\n");
412        assert_eq!(buf.line_count(), 2);
413        assert_eq!(buf.line_at(0), Some("hello".to_string()));
414        assert_eq!(buf.line_at(1), Some(String::new()));
415    }
416
417    #[test]
418    fn test_line_at_out_of_bounds_returns_none() {
419        let buf = EditorBuffer::from_text("hello");
420        assert_eq!(buf.line_at(1), None);
421    }
422
423    #[test]
424    fn test_line_len() {
425        let buf = EditorBuffer::from_text("hello\nhi");
426        assert_eq!(buf.line_len(0), 5);
427        assert_eq!(buf.line_len(1), 2);
428    }
429
430    #[test]
431    fn test_text_roundtrip() {
432        let content = "line one\nline two\nline three";
433        let buf = EditorBuffer::from_text(content);
434        assert_eq!(buf.text(), content);
435    }
436
437    // --- Cursor initial state ---
438
439    #[test]
440    fn test_cursor_starts_at_origin() {
441        let buf = EditorBuffer::from_text("hello\nworld");
442        assert_eq!(buf.cursor(), Cursor::at(0, 0));
443    }
444
445    // --- Dirty tracking ---
446
447    #[test]
448    fn test_new_buffer_is_clean() {
449        let buf = EditorBuffer::from_text("hello");
450        assert!(!buf.is_dirty());
451    }
452
453    #[test]
454    fn test_insert_marks_dirty() {
455        let mut buf = EditorBuffer::from_text("hello");
456        buf.insert_char('!');
457        assert!(buf.is_dirty());
458    }
459
460    #[test]
461    fn test_mark_clean_resets_dirty() {
462        let mut buf = EditorBuffer::from_text("hello");
463        buf.insert_char('!');
464        buf.mark_clean();
465        assert!(!buf.is_dirty());
466    }
467
468    #[test]
469    fn test_undo_to_original_is_not_dirty() {
470        let mut buf = EditorBuffer::from_text("hello");
471        buf.insert_char('!');
472        assert!(buf.is_dirty());
473        buf.delete_back();
474        assert!(
475            !buf.is_dirty(),
476            "buffer should be clean after undoing to original content"
477        );
478    }
479
480    #[test]
481    fn test_mark_clean_updates_baseline() {
482        let mut buf = EditorBuffer::from_text("hello");
483        buf.insert_char('!');
484        buf.mark_clean();
485        // After saving, the new content is the baseline
486        buf.insert_char('?');
487        assert!(buf.is_dirty());
488        buf.delete_back();
489        assert!(!buf.is_dirty());
490    }
491
492    // --- Character insertion ---
493
494    #[test]
495    fn test_insert_char_at_start() {
496        let mut buf = EditorBuffer::from_text("hello");
497        buf.insert_char('H');
498        assert_eq!(buf.line_at(0), Some("Hhello".to_string()));
499        assert_eq!(buf.cursor(), Cursor::at(0, 1));
500    }
501
502    #[test]
503    fn test_insert_char_at_end() {
504        let mut buf = EditorBuffer::from_text("hello");
505        buf.move_end();
506        buf.insert_char('!');
507        assert_eq!(buf.line_at(0), Some("hello!".to_string()));
508        assert_eq!(buf.cursor(), Cursor::at(0, 6));
509    }
510
511    #[test]
512    fn test_insert_char_in_middle() {
513        let mut buf = EditorBuffer::from_text("hllo");
514        buf.move_cursor(Direction::Right); // after 'h'
515        buf.insert_char('e');
516        assert_eq!(buf.line_at(0), Some("hello".to_string()));
517        assert_eq!(buf.cursor(), Cursor::at(0, 2));
518    }
519
520    #[test]
521    fn test_insert_multibyte_char() {
522        let mut buf = EditorBuffer::from_text("hello");
523        buf.move_end();
524        buf.insert_char('é');
525        assert_eq!(buf.line_at(0), Some("helloé".to_string()));
526    }
527
528    // --- String insertion ---
529
530    #[test]
531    fn test_insert_str_single_line() {
532        let mut buf = EditorBuffer::from_text("hd");
533        buf.move_cursor(Direction::Right);
534        buf.insert_str("ello worl");
535        assert_eq!(buf.line_at(0), Some("hello world".to_string()));
536    }
537
538    #[test]
539    fn test_insert_str_empty_is_noop() {
540        let mut buf = EditorBuffer::from_text("hello");
541        buf.insert_str("");
542        assert!(!buf.is_dirty());
543        assert_eq!(buf.text(), "hello");
544    }
545
546    // --- Line splitting (Enter) ---
547
548    #[test]
549    fn test_split_line_at_end() {
550        let mut buf = EditorBuffer::from_text("hello");
551        buf.move_end();
552        buf.split_line();
553        assert_eq!(buf.line_count(), 2);
554        assert_eq!(buf.line_at(0), Some("hello".to_string()));
555        assert_eq!(buf.line_at(1), Some(String::new()));
556        assert_eq!(buf.cursor(), Cursor::at(1, 0));
557    }
558
559    #[test]
560    fn test_split_line_at_start() {
561        let mut buf = EditorBuffer::from_text("hello");
562        buf.split_line();
563        assert_eq!(buf.line_count(), 2);
564        assert_eq!(buf.line_at(0), Some(String::new()));
565        assert_eq!(buf.line_at(1), Some("hello".to_string()));
566        assert_eq!(buf.cursor(), Cursor::at(1, 0));
567    }
568
569    #[test]
570    fn test_split_line_in_middle() {
571        let mut buf = EditorBuffer::from_text("hello world");
572        buf.move_to(0, 5);
573        buf.split_line();
574        assert_eq!(buf.line_at(0), Some("hello".to_string()));
575        assert_eq!(buf.line_at(1), Some(" world".to_string()));
576        assert_eq!(buf.cursor(), Cursor::at(1, 0));
577    }
578
579    // --- Backspace deletion ---
580
581    #[test]
582    fn test_delete_back_at_start_is_noop() {
583        let mut buf = EditorBuffer::from_text("hello");
584        assert!(!buf.delete_back());
585        assert_eq!(buf.text(), "hello");
586    }
587
588    #[test]
589    fn test_delete_back_removes_char() {
590        let mut buf = EditorBuffer::from_text("hello");
591        buf.move_to(0, 5);
592        buf.delete_back();
593        assert_eq!(buf.line_at(0), Some("hell".to_string()));
594        assert_eq!(buf.cursor(), Cursor::at(0, 4));
595    }
596
597    #[test]
598    fn test_delete_back_joins_lines() {
599        let mut buf = EditorBuffer::from_text("hello\nworld");
600        buf.move_to(1, 0);
601        buf.delete_back();
602        assert_eq!(buf.line_count(), 1);
603        assert_eq!(buf.line_at(0), Some("helloworld".to_string()));
604        assert_eq!(buf.cursor(), Cursor::at(0, 5));
605    }
606
607    // --- Forward deletion (Delete key) ---
608
609    #[test]
610    fn test_delete_forward_at_end_is_noop() {
611        let mut buf = EditorBuffer::from_text("hello");
612        buf.move_end();
613        assert!(!buf.delete_forward());
614    }
615
616    #[test]
617    fn test_delete_forward_removes_char() {
618        let mut buf = EditorBuffer::from_text("hello");
619        buf.delete_forward();
620        assert_eq!(buf.line_at(0), Some("ello".to_string()));
621        assert_eq!(buf.cursor(), Cursor::at(0, 0));
622    }
623
624    #[test]
625    fn test_delete_forward_joins_lines() {
626        let mut buf = EditorBuffer::from_text("hello\nworld");
627        buf.move_to(0, 5);
628        buf.delete_forward();
629        assert_eq!(buf.line_count(), 1);
630        assert_eq!(buf.line_at(0), Some("helloworld".to_string()));
631        assert_eq!(buf.cursor(), Cursor::at(0, 5));
632    }
633
634    // --- Cursor movement: left/right ---
635
636    #[test]
637    fn test_move_left_at_start_is_noop() {
638        let mut buf = EditorBuffer::from_text("hello");
639        buf.move_cursor(Direction::Left);
640        assert_eq!(buf.cursor(), Cursor::at(0, 0));
641    }
642
643    #[test]
644    fn test_move_left_decreases_col() {
645        let mut buf = EditorBuffer::from_text("hello");
646        buf.move_to(0, 3);
647        buf.move_cursor(Direction::Left);
648        assert_eq!(buf.cursor(), Cursor::at(0, 2));
649    }
650
651    #[test]
652    fn test_move_left_wraps_to_prev_line() {
653        let mut buf = EditorBuffer::from_text("hello\nworld");
654        buf.move_to(1, 0);
655        buf.move_cursor(Direction::Left);
656        assert_eq!(buf.cursor(), Cursor::at(0, 5));
657    }
658
659    #[test]
660    fn test_move_right_at_end_is_noop() {
661        let mut buf = EditorBuffer::from_text("hello");
662        buf.move_end();
663        buf.move_cursor(Direction::Right);
664        assert_eq!(buf.cursor(), Cursor::at(0, 5));
665    }
666
667    #[test]
668    fn test_move_right_increases_col() {
669        let mut buf = EditorBuffer::from_text("hello");
670        buf.move_cursor(Direction::Right);
671        assert_eq!(buf.cursor(), Cursor::at(0, 1));
672    }
673
674    #[test]
675    fn test_move_right_wraps_to_next_line() {
676        let mut buf = EditorBuffer::from_text("hello\nworld");
677        buf.move_to(0, 5);
678        buf.move_cursor(Direction::Right);
679        assert_eq!(buf.cursor(), Cursor::at(1, 0));
680    }
681
682    // --- Cursor movement: up/down ---
683
684    #[test]
685    fn test_move_up_at_first_line_is_noop() {
686        let mut buf = EditorBuffer::from_text("hello\nworld");
687        buf.move_cursor(Direction::Up);
688        assert_eq!(buf.cursor(), Cursor::at(0, 0));
689    }
690
691    #[test]
692    fn test_move_down_at_last_line_is_noop() {
693        let mut buf = EditorBuffer::from_text("hello\nworld");
694        buf.move_to(1, 0);
695        buf.move_cursor(Direction::Down);
696        assert_eq!(buf.cursor(), Cursor::at(1, 0));
697    }
698
699    #[test]
700    fn test_move_up_preserves_column() {
701        let mut buf = EditorBuffer::from_text("hello\nworld");
702        buf.move_to(1, 3);
703        buf.move_cursor(Direction::Up);
704        assert_eq!(buf.cursor(), Cursor::at(0, 3));
705    }
706
707    #[test]
708    fn test_move_down_preserves_column() {
709        let mut buf = EditorBuffer::from_text("hello\nworld");
710        buf.move_to(0, 3);
711        buf.move_cursor(Direction::Down);
712        assert_eq!(buf.cursor(), Cursor::at(1, 3));
713    }
714
715    #[test]
716    fn test_move_up_clamps_to_shorter_line() {
717        let mut buf = EditorBuffer::from_text("hi\nhello");
718        buf.move_to(1, 4);
719        buf.move_cursor(Direction::Up);
720        // col is clamped to 2 ("hi" length) but col_memory stays at 4
721        assert_eq!(buf.cursor().line, 0);
722        assert_eq!(buf.cursor().col, 2);
723    }
724
725    #[test]
726    fn test_move_down_clamps_to_shorter_line() {
727        let mut buf = EditorBuffer::from_text("hello\nhi");
728        buf.move_to(0, 4);
729        buf.move_cursor(Direction::Down);
730        // col is clamped to 2 ("hi" length) but col_memory stays at 4
731        assert_eq!(buf.cursor().line, 1);
732        assert_eq!(buf.cursor().col, 2);
733    }
734
735    // --- Column memory (sticky column) ---
736
737    #[test]
738    fn test_column_memory_across_short_line() {
739        let mut buf = EditorBuffer::from_text("hello\nhi\nworld");
740        buf.move_to(0, 4);
741        buf.move_cursor(Direction::Down); // "hi" → col 2
742        assert_eq!(buf.cursor().line, 1);
743        assert_eq!(buf.cursor().col, 2);
744        buf.move_cursor(Direction::Down); // "world" → col 4 (restored from memory)
745        assert_eq!(buf.cursor().line, 2);
746        assert_eq!(buf.cursor().col, 4);
747    }
748
749    // --- Home / End ---
750
751    #[test]
752    fn test_move_home() {
753        let mut buf = EditorBuffer::from_text("hello");
754        buf.move_to(0, 3);
755        buf.move_home();
756        assert_eq!(buf.cursor(), Cursor::at(0, 0));
757    }
758
759    #[test]
760    fn test_move_end() {
761        let mut buf = EditorBuffer::from_text("hello");
762        buf.move_end();
763        assert_eq!(buf.cursor(), Cursor::at(0, 5));
764    }
765
766    // --- Word movement ---
767
768    #[test]
769    fn test_move_word_left_from_middle_of_word() {
770        let mut buf = EditorBuffer::from_text("hello world");
771        buf.move_to(0, 8); // in "world"
772        buf.move_word_left();
773        assert_eq!(buf.cursor().col, 6); // start of "world"
774    }
775
776    #[test]
777    fn test_move_word_left_from_start_of_word() {
778        let mut buf = EditorBuffer::from_text("hello world");
779        buf.move_to(0, 6); // start of "world"
780        buf.move_word_left();
781        assert_eq!(buf.cursor().col, 0); // start of "hello"
782    }
783
784    #[test]
785    fn test_move_word_left_at_start_of_line_wraps() {
786        let mut buf = EditorBuffer::from_text("hello\nworld");
787        buf.move_to(1, 0);
788        buf.move_word_left();
789        assert_eq!(buf.cursor(), Cursor::at(0, 5)); // end of prev line
790    }
791
792    #[test]
793    fn test_move_word_right_from_start() {
794        let mut buf = EditorBuffer::from_text("hello world");
795        buf.move_word_right();
796        assert_eq!(buf.cursor().col, 6); // start of "world"
797    }
798
799    #[test]
800    fn test_move_word_right_at_end_of_line_wraps() {
801        let mut buf = EditorBuffer::from_text("hello\nworld");
802        buf.move_to(0, 5);
803        buf.move_word_right();
804        assert_eq!(buf.cursor(), Cursor::at(1, 0));
805    }
806
807    // --- move_to ---
808
809    #[test]
810    fn test_move_to_clamps_line() {
811        let mut buf = EditorBuffer::from_text("hello");
812        buf.move_to(100, 0);
813        assert_eq!(buf.cursor().line, 0);
814    }
815
816    #[test]
817    fn test_move_to_clamps_col() {
818        let mut buf = EditorBuffer::from_text("hello");
819        buf.move_to(0, 100);
820        assert_eq!(buf.cursor().col, 5);
821    }
822
823    // --- move_to_start / move_to_end ---
824
825    #[test]
826    fn test_move_to_start() {
827        let mut buf = EditorBuffer::from_text("hello\nworld");
828        buf.move_to(1, 3);
829        buf.move_to_start();
830        assert_eq!(buf.cursor(), Cursor::at(0, 0));
831    }
832
833    #[test]
834    fn test_move_to_end() {
835        let mut buf = EditorBuffer::from_text("hello\nworld");
836        buf.move_to_end();
837        assert_eq!(buf.cursor(), Cursor::at(1, 5));
838    }
839
840    // --- Multi-byte character handling ---
841
842    #[test]
843    fn test_insert_and_navigate_multibyte() {
844        let mut buf = EditorBuffer::from_text("café");
845        buf.move_end();
846        assert_eq!(buf.cursor().col, 5); // 'é' is 2 bytes
847        buf.move_cursor(Direction::Left);
848        assert_eq!(buf.cursor().col, 3); // before 'é'
849    }
850
851    #[test]
852    fn test_delete_back_multibyte() {
853        let mut buf = EditorBuffer::from_text("café");
854        buf.move_end();
855        buf.delete_back();
856        assert_eq!(buf.line_at(0), Some("caf".to_string()));
857    }
858
859    // --- Complex editing sequences ---
860
861    #[test]
862    fn test_type_then_backspace_then_type() {
863        let mut buf = EditorBuffer::from_text("");
864        buf.insert_char('h');
865        buf.insert_char('e');
866        buf.insert_char('l');
867        buf.delete_back();
868        buf.insert_char('l');
869        buf.insert_char('p');
870        assert_eq!(buf.line_at(0), Some("help".to_string()));
871    }
872
873    #[test]
874    fn test_split_and_rejoin() {
875        let mut buf = EditorBuffer::from_text("helloworld");
876        buf.move_to(0, 5);
877        buf.split_line();
878        assert_eq!(buf.line_count(), 2);
879        assert_eq!(buf.line_at(0), Some("hello".to_string()));
880        assert_eq!(buf.line_at(1), Some("world".to_string()));
881
882        buf.delete_back();
883        assert_eq!(buf.line_count(), 1);
884        assert_eq!(buf.line_at(0), Some("helloworld".to_string()));
885    }
886}