Skip to main content

ftui_text/
cursor.rs

1#![forbid(unsafe_code)]
2
3//! Cursor utilities for text editing widgets.
4//!
5//! Provides grapheme-aware cursor movement and mapping between logical
6//! positions (line + grapheme) and visual columns (cell width).
7
8use crate::rope::Rope;
9use crate::wrap::{display_width, graphemes};
10use std::borrow::Cow;
11use unicode_segmentation::UnicodeSegmentation;
12
13/// Logical + visual cursor position.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub struct CursorPosition {
16    /// Line index (0-based).
17    pub line: usize,
18    /// Grapheme index within the line (0-based).
19    pub grapheme: usize,
20    /// Visual column in cells (0-based).
21    pub visual_col: usize,
22}
23
24impl CursorPosition {
25    /// Create a cursor position with explicit fields.
26    #[must_use]
27    pub const fn new(line: usize, grapheme: usize, visual_col: usize) -> Self {
28        Self {
29            line,
30            grapheme,
31            visual_col,
32        }
33    }
34}
35
36/// Cursor navigation helper for rope-backed text.
37#[derive(Debug, Clone, Copy)]
38pub struct CursorNavigator<'a> {
39    rope: &'a Rope,
40}
41
42impl<'a> CursorNavigator<'a> {
43    /// Create a new navigator for the given rope.
44    #[must_use]
45    pub const fn new(rope: &'a Rope) -> Self {
46        Self { rope }
47    }
48
49    /// Clamp an arbitrary position to valid ranges.
50    #[must_use]
51    pub fn clamp(&self, pos: CursorPosition) -> CursorPosition {
52        let line = clamp_line_index(self.rope, pos.line);
53        let line_text = line_text(self.rope, line);
54        let line_text = strip_trailing_newline(&line_text);
55        let grapheme = pos.grapheme.min(grapheme_count(line_text));
56        let visual_col = visual_col_for_grapheme(line_text, grapheme);
57        CursorPosition::new(line, grapheme, visual_col)
58    }
59
60    /// Build a position from line + grapheme index.
61    #[must_use]
62    pub fn from_line_grapheme(&self, line: usize, grapheme: usize) -> CursorPosition {
63        let line = clamp_line_index(self.rope, line);
64        let line_text = line_text(self.rope, line);
65        let line_text = strip_trailing_newline(&line_text);
66        let grapheme = grapheme.min(grapheme_count(line_text));
67        let visual_col = visual_col_for_grapheme(line_text, grapheme);
68        CursorPosition::new(line, grapheme, visual_col)
69    }
70
71    /// Build a position from line + visual column.
72    #[must_use]
73    pub fn from_visual_col(&self, line: usize, visual_col: usize) -> CursorPosition {
74        let line = clamp_line_index(self.rope, line);
75        let line_text = line_text(self.rope, line);
76        let line_text = strip_trailing_newline(&line_text);
77        let grapheme = grapheme_index_at_visual_col(line_text, visual_col);
78        let visual_col = visual_col_for_grapheme(line_text, grapheme);
79        CursorPosition::new(line, grapheme, visual_col)
80    }
81
82    /// Convert a cursor position to a byte index into the rope.
83    #[must_use]
84    pub fn to_byte_index(&self, pos: CursorPosition) -> usize {
85        let pos = self.clamp(pos);
86        let line_start_char = self.rope.line_to_char(pos.line);
87        let line_start_byte = self.rope.char_to_byte(line_start_char);
88        let line_text = line_text(self.rope, pos.line);
89        let line_text = strip_trailing_newline(&line_text);
90        let byte_offset = grapheme_byte_offset(line_text, pos.grapheme);
91        line_start_byte.saturating_add(byte_offset)
92    }
93
94    /// Convert a byte index into a cursor position.
95    #[must_use]
96    pub fn from_byte_index(&self, byte_idx: usize) -> CursorPosition {
97        let (line, col_chars) = self.rope.byte_to_line_col(byte_idx);
98        let line = clamp_line_index(self.rope, line);
99        let line_text = line_text(self.rope, line);
100        let line_text = strip_trailing_newline(&line_text);
101        let grapheme = grapheme_index_from_char_offset(line_text, col_chars);
102        self.from_line_grapheme(line, grapheme)
103    }
104
105    /// Move cursor left by one grapheme (across line boundaries).
106    #[must_use]
107    pub fn move_left(&self, pos: CursorPosition) -> CursorPosition {
108        let pos = self.clamp(pos);
109        if pos.grapheme > 0 {
110            return self.from_line_grapheme(pos.line, pos.grapheme - 1);
111        }
112        if pos.line == 0 {
113            return pos;
114        }
115        let prev_line = pos.line - 1;
116        let prev_text = line_text(self.rope, prev_line);
117        let prev_text = strip_trailing_newline(&prev_text);
118        let prev_end = grapheme_count(prev_text);
119        self.from_line_grapheme(prev_line, prev_end)
120    }
121
122    /// Move cursor right by one grapheme (across line boundaries).
123    #[must_use]
124    pub fn move_right(&self, pos: CursorPosition) -> CursorPosition {
125        let pos = self.clamp(pos);
126        let line_text = line_text(self.rope, pos.line);
127        let line_text = strip_trailing_newline(&line_text);
128        let line_end = grapheme_count(line_text);
129        if pos.grapheme < line_end {
130            return self.from_line_grapheme(pos.line, pos.grapheme + 1);
131        }
132        let last_line = last_line_index(self.rope);
133        if pos.line >= last_line {
134            return pos;
135        }
136        self.from_line_grapheme(pos.line + 1, 0)
137    }
138
139    /// Move cursor up one line, preserving visual column.
140    #[must_use]
141    pub fn move_up(&self, pos: CursorPosition) -> CursorPosition {
142        let pos = self.clamp(pos);
143        if pos.line == 0 {
144            return pos;
145        }
146        self.from_visual_col(pos.line - 1, pos.visual_col)
147    }
148
149    /// Move cursor down one line, preserving visual column.
150    #[must_use]
151    pub fn move_down(&self, pos: CursorPosition) -> CursorPosition {
152        let pos = self.clamp(pos);
153        let last_line = last_line_index(self.rope);
154        if pos.line >= last_line {
155            return pos;
156        }
157        self.from_visual_col(pos.line + 1, pos.visual_col)
158    }
159
160    /// Move cursor to start of line.
161    #[must_use]
162    pub fn line_start(&self, pos: CursorPosition) -> CursorPosition {
163        let pos = self.clamp(pos);
164        self.from_line_grapheme(pos.line, 0)
165    }
166
167    /// Move cursor to end of line.
168    #[must_use]
169    pub fn line_end(&self, pos: CursorPosition) -> CursorPosition {
170        let pos = self.clamp(pos);
171        let line_text = line_text(self.rope, pos.line);
172        let line_text = strip_trailing_newline(&line_text);
173        let end = grapheme_count(line_text);
174        self.from_line_grapheme(pos.line, end)
175    }
176
177    /// Move cursor to start of document.
178    #[must_use]
179    pub fn document_start(&self) -> CursorPosition {
180        self.from_line_grapheme(0, 0)
181    }
182
183    /// Move cursor to end of document.
184    #[must_use]
185    pub fn document_end(&self) -> CursorPosition {
186        let last_line = last_line_index(self.rope);
187        let line_text = line_text(self.rope, last_line);
188        let line_text = strip_trailing_newline(&line_text);
189        let end = grapheme_count(line_text);
190        self.from_line_grapheme(last_line, end)
191    }
192
193    /// Move cursor left by one word boundary.
194    #[must_use]
195    pub fn move_word_left(&self, pos: CursorPosition) -> CursorPosition {
196        let pos = self.clamp(pos);
197        if pos.line == 0 && pos.grapheme == 0 {
198            return pos;
199        }
200        if pos.grapheme == 0 {
201            let prev_line = pos.line - 1;
202            let prev_text = line_text(self.rope, prev_line);
203            let prev_text = strip_trailing_newline(&prev_text);
204            let end = grapheme_count(prev_text);
205            let next = move_word_left_in_line(prev_text, end);
206            return self.from_line_grapheme(prev_line, next);
207        }
208        let line_text = line_text(self.rope, pos.line);
209        let line_text = strip_trailing_newline(&line_text);
210        let next = move_word_left_in_line(line_text, pos.grapheme);
211        self.from_line_grapheme(pos.line, next)
212    }
213
214    /// Move cursor right by one word boundary.
215    #[must_use]
216    pub fn move_word_right(&self, pos: CursorPosition) -> CursorPosition {
217        let pos = self.clamp(pos);
218        let line_text = line_text(self.rope, pos.line);
219        let line_text = strip_trailing_newline(&line_text);
220        let end = grapheme_count(line_text);
221        if pos.grapheme >= end {
222            let last_line = last_line_index(self.rope);
223            if pos.line >= last_line {
224                return pos;
225            }
226            return self.from_line_grapheme(pos.line + 1, 0);
227        }
228        let next = move_word_right_in_line(line_text, pos.grapheme);
229        self.from_line_grapheme(pos.line, next)
230    }
231}
232
233fn clamp_line_index(rope: &Rope, line: usize) -> usize {
234    let last = last_line_index(rope);
235    if line > last { last } else { line }
236}
237
238fn last_line_index(rope: &Rope) -> usize {
239    let lines = rope.len_lines();
240    if lines == 0 { 0 } else { lines - 1 }
241}
242
243fn line_text<'a>(rope: &'a Rope, line: usize) -> Cow<'a, str> {
244    rope.line(line).unwrap_or(Cow::Borrowed(""))
245}
246
247fn strip_trailing_newline(text: &str) -> &str {
248    text.strip_suffix('\n').unwrap_or(text)
249}
250
251fn grapheme_count(text: &str) -> usize {
252    graphemes(text).count()
253}
254
255fn visual_col_for_grapheme(text: &str, grapheme_idx: usize) -> usize {
256    graphemes(text).take(grapheme_idx).map(display_width).sum()
257}
258
259fn grapheme_index_at_visual_col(text: &str, visual_col: usize) -> usize {
260    let mut col = 0usize;
261    let mut idx = 0usize;
262    for g in graphemes(text) {
263        let w = display_width(g);
264        if col.saturating_add(w) > visual_col {
265            break;
266        }
267        col = col.saturating_add(w);
268        idx = idx.saturating_add(1);
269    }
270    idx
271}
272
273fn grapheme_byte_offset(text: &str, grapheme_idx: usize) -> usize {
274    text.grapheme_indices(true)
275        .nth(grapheme_idx)
276        .map(|(i, _)| i)
277        .unwrap_or(text.len())
278}
279
280fn grapheme_index_from_char_offset(text: &str, char_offset: usize) -> usize {
281    let mut char_count = 0usize;
282    let mut g_idx = 0usize;
283    for g in graphemes(text) {
284        let g_chars = g.chars().count();
285        if char_count.saturating_add(g_chars) > char_offset {
286            return g_idx;
287        }
288        char_count = char_count.saturating_add(g_chars);
289        g_idx = g_idx.saturating_add(1);
290    }
291    g_idx
292}
293
294#[derive(Debug, Clone, Copy, PartialEq, Eq)]
295enum GraphemeClass {
296    Space,
297    Word,
298    Punct,
299}
300
301fn grapheme_class(g: &str) -> GraphemeClass {
302    if g.chars().all(char::is_whitespace) {
303        GraphemeClass::Space
304    } else if g.chars().any(char::is_alphanumeric) {
305        GraphemeClass::Word
306    } else {
307        GraphemeClass::Punct
308    }
309}
310
311fn move_word_left_in_line(text: &str, grapheme_idx: usize) -> usize {
312    let graphemes: Vec<&str> = graphemes(text).collect();
313    let mut pos = grapheme_idx.min(graphemes.len());
314    if pos == 0 {
315        return 0;
316    }
317    while pos > 0 && grapheme_class(graphemes[pos - 1]) == GraphemeClass::Space {
318        pos = pos.saturating_sub(1);
319    }
320    if pos == 0 {
321        return 0;
322    }
323    let target = grapheme_class(graphemes[pos - 1]);
324    while pos > 0 && grapheme_class(graphemes[pos - 1]) == target {
325        pos = pos.saturating_sub(1);
326    }
327    pos
328}
329
330fn move_word_right_in_line(text: &str, grapheme_idx: usize) -> usize {
331    let mut iter = graphemes(text).peekable();
332    let mut pos = 0usize;
333
334    while pos < grapheme_idx {
335        if iter.next().is_none() {
336            return pos;
337        }
338        pos = pos.saturating_add(1);
339    }
340
341    let Some(current) = iter.peek() else {
342        return pos;
343    };
344
345    if grapheme_class(current) == GraphemeClass::Space {
346        while let Some(g) = iter.peek() {
347            if grapheme_class(g) != GraphemeClass::Space {
348                break;
349            }
350            iter.next();
351            pos = pos.saturating_add(1);
352        }
353        return pos;
354    }
355
356    let target = grapheme_class(current);
357    while let Some(g) = iter.peek() {
358        if grapheme_class(g) != target {
359            break;
360        }
361        iter.next();
362        pos = pos.saturating_add(1);
363    }
364
365    while let Some(g) = iter.peek() {
366        if grapheme_class(g) != GraphemeClass::Space {
367            break;
368        }
369        iter.next();
370        pos = pos.saturating_add(1);
371    }
372
373    pos
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    fn rope(text: &str) -> Rope {
381        Rope::from_text(text)
382    }
383
384    #[test]
385    fn left_right_grapheme_moves() {
386        let r = rope("ab");
387        let nav = CursorNavigator::new(&r);
388        let mut pos = nav.from_line_grapheme(0, 0);
389        pos = nav.move_right(pos);
390        assert_eq!(pos.grapheme, 1);
391        pos = nav.move_right(pos);
392        assert_eq!(pos.grapheme, 2);
393        pos = nav.move_left(pos);
394        assert_eq!(pos.grapheme, 1);
395    }
396
397    #[test]
398    fn combining_mark_is_single_grapheme() {
399        let r = rope("e\u{0301}x");
400        let nav = CursorNavigator::new(&r);
401        let pos = nav.from_line_grapheme(0, 1);
402        assert_eq!(pos.visual_col, 1);
403        let next = nav.move_right(pos);
404        assert_eq!(next.grapheme, 2);
405    }
406
407    #[test]
408    fn emoji_zwj_grapheme_width() {
409        let r = rope("\u{1F469}\u{200D}\u{1F680}x");
410        let nav = CursorNavigator::new(&r);
411        let pos = nav.from_line_grapheme(0, 1);
412        assert_eq!(pos.visual_col, 2);
413        let next = nav.move_right(pos);
414        assert_eq!(next.grapheme, 2);
415    }
416
417    #[test]
418    fn tab_counts_as_one_cell() {
419        let r = rope("a\tb");
420        let nav = CursorNavigator::new(&r);
421        let pos = nav.from_line_grapheme(0, 2);
422        assert_eq!(pos.visual_col, 2);
423        let mid = nav.from_visual_col(0, 1);
424        assert_eq!(mid.grapheme, 1);
425        assert_eq!(mid.visual_col, 1);
426    }
427
428    #[test]
429    fn visual_col_to_grapheme_clamps_inside_wide() {
430        let r = rope("ab\u{754C}");
431        let nav = CursorNavigator::new(&r);
432        let pos = nav.from_visual_col(0, 3);
433        assert_eq!(pos.grapheme, 2);
434        assert_eq!(pos.visual_col, 2);
435    }
436
437    #[test]
438    fn move_up_down_preserves_visual_col() {
439        let r = rope("abcd\nx\u{754C}");
440        let nav = CursorNavigator::new(&r);
441        let pos = nav.from_line_grapheme(0, 3); // visual_col = 3
442        let down = nav.move_down(pos);
443        assert_eq!(down.line, 1);
444        assert_eq!(down.grapheme, 2);
445        assert_eq!(down.visual_col, 3);
446        let up = nav.move_up(down);
447        assert_eq!(up.line, 0);
448    }
449
450    #[test]
451    fn word_movement_respects_classes() {
452        let r = rope("hello  world!!!");
453        let nav = CursorNavigator::new(&r);
454        let pos = nav.from_line_grapheme(0, 0);
455        // move_word_right skips the word class then any trailing whitespace
456        let right = nav.move_word_right(pos);
457        assert_eq!(right.grapheme, 7); // past "hello" + spaces
458        let right = nav.move_word_right(right);
459        assert_eq!(right.grapheme, 12); // past "world" (no trailing space before punct)
460        let right = nav.move_word_right(right);
461        assert_eq!(right.grapheme, 15); // past "!!!"
462        let left = nav.move_word_left(right);
463        assert_eq!(left.grapheme, 12); // back to start of "!!!"
464    }
465
466    #[test]
467    fn byte_index_roundtrip() {
468        let r = rope("a\nbc");
469        let nav = CursorNavigator::new(&r);
470        let pos = nav.from_line_grapheme(1, 1);
471        let byte = nav.to_byte_index(pos);
472        let back = nav.from_byte_index(byte);
473        assert_eq!(back.line, 1);
474        assert_eq!(back.grapheme, 1);
475    }
476
477    // ====== Empty text ======
478
479    #[test]
480    fn empty_text_navigation() {
481        let r = rope("");
482        let nav = CursorNavigator::new(&r);
483        let pos = nav.from_line_grapheme(0, 0);
484        assert_eq!(pos.line, 0);
485        assert_eq!(pos.grapheme, 0);
486        assert_eq!(pos.visual_col, 0);
487    }
488
489    #[test]
490    fn empty_text_move_left_is_noop() {
491        let r = rope("");
492        let nav = CursorNavigator::new(&r);
493        let pos = nav.from_line_grapheme(0, 0);
494        let moved = nav.move_left(pos);
495        assert_eq!(moved, pos);
496    }
497
498    #[test]
499    fn empty_text_move_right_is_noop() {
500        let r = rope("");
501        let nav = CursorNavigator::new(&r);
502        let pos = nav.from_line_grapheme(0, 0);
503        let moved = nav.move_right(pos);
504        assert_eq!(moved, pos);
505    }
506
507    #[test]
508    fn empty_text_document_start_end() {
509        let r = rope("");
510        let nav = CursorNavigator::new(&r);
511        let start = nav.document_start();
512        let end = nav.document_end();
513        assert_eq!(start, end);
514        assert_eq!(start.line, 0);
515        assert_eq!(start.grapheme, 0);
516    }
517
518    // ====== Clamping ======
519
520    #[test]
521    fn clamp_out_of_bounds_line() {
522        let r = rope("abc");
523        let nav = CursorNavigator::new(&r);
524        let pos = CursorPosition::new(100, 0, 0);
525        let clamped = nav.clamp(pos);
526        assert_eq!(clamped.line, 0);
527    }
528
529    #[test]
530    fn clamp_out_of_bounds_grapheme() {
531        let r = rope("abc");
532        let nav = CursorNavigator::new(&r);
533        let pos = CursorPosition::new(0, 100, 0);
534        let clamped = nav.clamp(pos);
535        assert_eq!(clamped.grapheme, 3);
536        assert_eq!(clamped.visual_col, 3);
537    }
538
539    #[test]
540    fn clamp_multiline_out_of_bounds() {
541        let r = rope("abc\ndef");
542        let nav = CursorNavigator::new(&r);
543        let pos = CursorPosition::new(5, 50, 0);
544        let clamped = nav.clamp(pos);
545        assert_eq!(clamped.line, 1);
546        assert_eq!(clamped.grapheme, 3);
547    }
548
549    // ====== Line start/end ======
550
551    #[test]
552    fn line_start_moves_to_column_zero() {
553        let r = rope("hello world");
554        let nav = CursorNavigator::new(&r);
555        let pos = nav.from_line_grapheme(0, 5);
556        let start = nav.line_start(pos);
557        assert_eq!(start.grapheme, 0);
558        assert_eq!(start.visual_col, 0);
559    }
560
561    #[test]
562    fn line_end_moves_to_last_grapheme() {
563        let r = rope("hello");
564        let nav = CursorNavigator::new(&r);
565        let pos = nav.from_line_grapheme(0, 0);
566        let end = nav.line_end(pos);
567        assert_eq!(end.grapheme, 5);
568        assert_eq!(end.visual_col, 5);
569    }
570
571    #[test]
572    fn line_start_end_multiline() {
573        let r = rope("abc\nde");
574        let nav = CursorNavigator::new(&r);
575        let pos = nav.from_line_grapheme(1, 1);
576        let start = nav.line_start(pos);
577        assert_eq!(start.line, 1);
578        assert_eq!(start.grapheme, 0);
579        let end = nav.line_end(pos);
580        assert_eq!(end.line, 1);
581        assert_eq!(end.grapheme, 2);
582    }
583
584    // ====== Document start/end ======
585
586    #[test]
587    fn document_start_is_0_0() {
588        let r = rope("abc\ndef\nghi");
589        let nav = CursorNavigator::new(&r);
590        let start = nav.document_start();
591        assert_eq!(start.line, 0);
592        assert_eq!(start.grapheme, 0);
593        assert_eq!(start.visual_col, 0);
594    }
595
596    #[test]
597    fn document_end_is_last_line_last_grapheme() {
598        let r = rope("abc\ndef\nghi");
599        let nav = CursorNavigator::new(&r);
600        let end = nav.document_end();
601        assert_eq!(end.line, 2);
602        assert_eq!(end.grapheme, 3);
603        assert_eq!(end.visual_col, 3);
604    }
605
606    // ====== Cross-line movement ======
607
608    #[test]
609    fn move_left_wraps_to_previous_line() {
610        let r = rope("abc\ndef");
611        let nav = CursorNavigator::new(&r);
612        let pos = nav.from_line_grapheme(1, 0);
613        let moved = nav.move_left(pos);
614        assert_eq!(moved.line, 0);
615        assert_eq!(moved.grapheme, 3);
616    }
617
618    #[test]
619    fn move_right_wraps_to_next_line() {
620        let r = rope("abc\ndef");
621        let nav = CursorNavigator::new(&r);
622        let pos = nav.from_line_grapheme(0, 3);
623        let moved = nav.move_right(pos);
624        assert_eq!(moved.line, 1);
625        assert_eq!(moved.grapheme, 0);
626    }
627
628    #[test]
629    fn move_left_at_document_start_is_noop() {
630        let r = rope("abc");
631        let nav = CursorNavigator::new(&r);
632        let pos = nav.from_line_grapheme(0, 0);
633        let moved = nav.move_left(pos);
634        assert_eq!(moved, pos);
635    }
636
637    #[test]
638    fn move_right_at_document_end_is_noop() {
639        let r = rope("abc");
640        let nav = CursorNavigator::new(&r);
641        let pos = nav.from_line_grapheme(0, 3);
642        let moved = nav.move_right(pos);
643        assert_eq!(moved, pos);
644    }
645
646    // ====== Up/down movement ======
647
648    #[test]
649    fn move_up_at_first_line_is_noop() {
650        let r = rope("abc\ndef");
651        let nav = CursorNavigator::new(&r);
652        let pos = nav.from_line_grapheme(0, 1);
653        let moved = nav.move_up(pos);
654        assert_eq!(moved, pos);
655    }
656
657    #[test]
658    fn move_down_at_last_line_is_noop() {
659        let r = rope("abc\ndef");
660        let nav = CursorNavigator::new(&r);
661        let pos = nav.from_line_grapheme(1, 1);
662        let moved = nav.move_down(pos);
663        assert_eq!(moved, pos);
664    }
665
666    #[test]
667    fn move_down_shorter_line_clamps_grapheme() {
668        let r = rope("abcdef\nxy");
669        let nav = CursorNavigator::new(&r);
670        let pos = nav.from_line_grapheme(0, 5); // visual_col=5
671        let down = nav.move_down(pos);
672        assert_eq!(down.line, 1);
673        assert_eq!(down.grapheme, 2); // "xy" only has 2 graphemes
674        assert_eq!(down.visual_col, 2);
675    }
676
677    #[test]
678    fn move_up_shorter_line_clamps_grapheme() {
679        let r = rope("xy\nabcdef");
680        let nav = CursorNavigator::new(&r);
681        let pos = nav.from_line_grapheme(1, 5); // visual_col=5
682        let up = nav.move_up(pos);
683        assert_eq!(up.line, 0);
684        assert_eq!(up.grapheme, 2);
685        assert_eq!(up.visual_col, 2);
686    }
687
688    // ====== Wide character visual column handling ======
689
690    #[test]
691    fn wide_char_visual_col() {
692        // CJK characters are 2 cells wide
693        let r = rope("\u{4E16}\u{754C}"); // "δΈ–η•Œ"
694        let nav = CursorNavigator::new(&r);
695        let pos0 = nav.from_line_grapheme(0, 0);
696        assert_eq!(pos0.visual_col, 0);
697        let pos1 = nav.from_line_grapheme(0, 1);
698        assert_eq!(pos1.visual_col, 2);
699        let pos2 = nav.from_line_grapheme(0, 2);
700        assert_eq!(pos2.visual_col, 4);
701    }
702
703    #[test]
704    fn from_visual_col_with_wide_chars() {
705        let r = rope("\u{4E16}\u{754C}x"); // "δΈ–η•Œx"
706        let nav = CursorNavigator::new(&r);
707        // visual_col=1 falls inside first wide char -> snap to grapheme 0
708        let pos = nav.from_visual_col(0, 1);
709        assert_eq!(pos.grapheme, 0);
710        assert_eq!(pos.visual_col, 0);
711        // visual_col=2 starts at second char
712        let pos = nav.from_visual_col(0, 2);
713        assert_eq!(pos.grapheme, 1);
714        assert_eq!(pos.visual_col, 2);
715        // visual_col=4 is 'x'
716        let pos = nav.from_visual_col(0, 4);
717        assert_eq!(pos.grapheme, 2);
718        assert_eq!(pos.visual_col, 4);
719    }
720
721    // ====== Word movement ======
722
723    #[test]
724    fn word_right_from_start() {
725        let r = rope("hello world");
726        let nav = CursorNavigator::new(&r);
727        let pos = nav.from_line_grapheme(0, 0);
728        let moved = nav.move_word_right(pos);
729        assert_eq!(moved.grapheme, 6); // start of "world"
730    }
731
732    #[test]
733    fn word_left_from_end() {
734        let r = rope("hello world");
735        let nav = CursorNavigator::new(&r);
736        let pos = nav.from_line_grapheme(0, 11);
737        let moved = nav.move_word_left(pos);
738        assert_eq!(moved.grapheme, 6); // start of "world"
739    }
740
741    #[test]
742    fn word_right_at_line_end_wraps() {
743        let r = rope("hello\nworld");
744        let nav = CursorNavigator::new(&r);
745        let pos = nav.from_line_grapheme(0, 5);
746        let moved = nav.move_word_right(pos);
747        assert_eq!(moved.line, 1);
748        assert_eq!(moved.grapheme, 0);
749    }
750
751    #[test]
752    fn word_left_at_line_start_wraps() {
753        let r = rope("hello\nworld");
754        let nav = CursorNavigator::new(&r);
755        let pos = nav.from_line_grapheme(1, 0);
756        let moved = nav.move_word_left(pos);
757        assert_eq!(moved.line, 0);
758        // Should go to previous line end, finding word boundary
759        assert!(moved.grapheme <= 5);
760    }
761
762    #[test]
763    fn word_right_skips_punctuation() {
764        let r = rope("a!!b");
765        let nav = CursorNavigator::new(&r);
766        let pos = nav.from_line_grapheme(0, 1);
767        let moved = nav.move_word_right(pos);
768        assert_eq!(moved.grapheme, 3); // skips "!!" (punctuation class)
769    }
770
771    #[test]
772    fn word_movement_at_document_boundaries() {
773        let r = rope("abc");
774        let nav = CursorNavigator::new(&r);
775        // word left at start is noop
776        let start = nav.from_line_grapheme(0, 0);
777        let left = nav.move_word_left(start);
778        assert_eq!(left, start);
779        // word right at end is noop
780        let end = nav.from_line_grapheme(0, 3);
781        let right = nav.move_word_right(end);
782        assert_eq!(right, end);
783    }
784
785    // ====== Byte index roundtrips ======
786
787    #[test]
788    fn byte_index_roundtrip_multibyte() {
789        let r = rope("a\u{1F600}b"); // a πŸ˜€ b
790        let nav = CursorNavigator::new(&r);
791        for g in 0..=3 {
792            let pos = nav.from_line_grapheme(0, g);
793            let byte = nav.to_byte_index(pos);
794            let back = nav.from_byte_index(byte);
795            assert_eq!(
796                back.grapheme, pos.grapheme,
797                "roundtrip failed for grapheme {g}"
798            );
799        }
800    }
801
802    #[test]
803    fn byte_index_roundtrip_multiline_unicode() {
804        let r = rope("ab\n\u{4E16}\u{754C}");
805        let nav = CursorNavigator::new(&r);
806        let pos = nav.from_line_grapheme(1, 1); // η•Œ
807        let byte = nav.to_byte_index(pos);
808        let back = nav.from_byte_index(byte);
809        assert_eq!(back.line, 1);
810        assert_eq!(back.grapheme, 1);
811    }
812
813    // ====== from_visual_col edge cases ======
814
815    #[test]
816    fn from_visual_col_beyond_line_clamps() {
817        let r = rope("abc");
818        let nav = CursorNavigator::new(&r);
819        let pos = nav.from_visual_col(0, 100);
820        assert_eq!(pos.grapheme, 3);
821        assert_eq!(pos.visual_col, 3);
822    }
823
824    #[test]
825    fn from_visual_col_zero_on_empty_line() {
826        let r = rope("abc\n\ndef");
827        let nav = CursorNavigator::new(&r);
828        let pos = nav.from_visual_col(1, 5);
829        assert_eq!(pos.grapheme, 0);
830        assert_eq!(pos.visual_col, 0);
831    }
832
833    // ====== Internal helper tests ======
834
835    #[test]
836    fn grapheme_class_classification() {
837        use super::GraphemeClass;
838        use super::grapheme_class;
839        assert_eq!(grapheme_class(" "), GraphemeClass::Space);
840        assert_eq!(grapheme_class("\t"), GraphemeClass::Space);
841        assert_eq!(grapheme_class("a"), GraphemeClass::Word);
842        assert_eq!(grapheme_class("5"), GraphemeClass::Word);
843        assert_eq!(grapheme_class("!"), GraphemeClass::Punct);
844        assert_eq!(grapheme_class("."), GraphemeClass::Punct);
845    }
846
847    #[test]
848    fn move_word_left_in_line_edge_cases() {
849        use super::move_word_left_in_line;
850        // Already at start
851        assert_eq!(move_word_left_in_line("hello", 0), 0);
852        // Single word
853        assert_eq!(move_word_left_in_line("hello", 5), 0);
854        // Empty string
855        assert_eq!(move_word_left_in_line("", 0), 0);
856    }
857
858    #[test]
859    fn move_word_right_in_line_edge_cases() {
860        use super::move_word_right_in_line;
861        // Already at end
862        assert_eq!(move_word_right_in_line("hello", 5), 5);
863        // Single word from start
864        assert_eq!(move_word_right_in_line("hello", 0), 5);
865        // Empty string
866        assert_eq!(move_word_right_in_line("", 0), 0);
867    }
868}