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    let stripped = text.strip_suffix('\n').unwrap_or(text);
249    stripped.strip_suffix('\r').unwrap_or(stripped)
250}
251
252fn grapheme_count(text: &str) -> usize {
253    graphemes(text).count()
254}
255
256fn visual_col_for_grapheme(text: &str, grapheme_idx: usize) -> usize {
257    graphemes(text).take(grapheme_idx).map(display_width).sum()
258}
259
260fn grapheme_index_at_visual_col(text: &str, visual_col: usize) -> usize {
261    let mut col = 0usize;
262    let mut idx = 0usize;
263    for g in graphemes(text) {
264        let w = display_width(g);
265        if col.saturating_add(w) > visual_col {
266            break;
267        }
268        col = col.saturating_add(w);
269        idx = idx.saturating_add(1);
270    }
271    idx
272}
273
274fn grapheme_byte_offset(text: &str, grapheme_idx: usize) -> usize {
275    text.grapheme_indices(true)
276        .nth(grapheme_idx)
277        .map(|(i, _)| i)
278        .unwrap_or(text.len())
279}
280
281fn grapheme_index_from_char_offset(text: &str, char_offset: usize) -> usize {
282    let mut char_count = 0usize;
283    let mut g_idx = 0usize;
284    for g in graphemes(text) {
285        let g_chars = g.chars().count();
286        if char_count.saturating_add(g_chars) > char_offset {
287            return g_idx;
288        }
289        char_count = char_count.saturating_add(g_chars);
290        g_idx = g_idx.saturating_add(1);
291    }
292    g_idx
293}
294
295#[derive(Debug, Clone, Copy, PartialEq, Eq)]
296enum GraphemeClass {
297    Space,
298    Word,
299    Punct,
300}
301
302fn grapheme_class(g: &str) -> GraphemeClass {
303    if g.chars().all(char::is_whitespace) {
304        GraphemeClass::Space
305    } else if g.chars().any(char::is_alphanumeric) {
306        GraphemeClass::Word
307    } else {
308        GraphemeClass::Punct
309    }
310}
311
312fn move_word_left_in_line(text: &str, grapheme_idx: usize) -> usize {
313    if grapheme_idx == 0 {
314        return 0;
315    }
316
317    let byte_offset = grapheme_byte_offset(text, grapheme_idx);
318    let before_cursor = &text[..byte_offset];
319    let mut pos = grapheme_idx;
320
321    let mut iter = before_cursor.graphemes(true).rev();
322
323    while let Some(g) = iter.next() {
324        if grapheme_class(g) == GraphemeClass::Space {
325            pos = pos.saturating_sub(1);
326        } else {
327            pos = pos.saturating_sub(1);
328            let target = grapheme_class(g);
329            for g_next in iter {
330                if grapheme_class(g_next) == target {
331                    pos = pos.saturating_sub(1);
332                } else {
333                    break;
334                }
335            }
336            break;
337        }
338    }
339
340    pos
341}
342
343fn move_word_right_in_line(text: &str, grapheme_idx: usize) -> usize {
344    let mut iter = graphemes(text).peekable();
345    let mut pos = 0usize;
346
347    while pos < grapheme_idx {
348        if iter.next().is_none() {
349            return pos;
350        }
351        pos = pos.saturating_add(1);
352    }
353
354    let Some(current) = iter.peek() else {
355        return pos;
356    };
357
358    if grapheme_class(current) == GraphemeClass::Space {
359        while let Some(g) = iter.peek() {
360            if grapheme_class(g) != GraphemeClass::Space {
361                break;
362            }
363            iter.next();
364            pos = pos.saturating_add(1);
365        }
366        return pos;
367    }
368
369    let target = grapheme_class(current);
370    while let Some(g) = iter.peek() {
371        if grapheme_class(g) != target {
372            break;
373        }
374        iter.next();
375        pos = pos.saturating_add(1);
376    }
377
378    while let Some(g) = iter.peek() {
379        if grapheme_class(g) != GraphemeClass::Space {
380            break;
381        }
382        iter.next();
383        pos = pos.saturating_add(1);
384    }
385
386    pos
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    fn rope(text: &str) -> Rope {
394        Rope::from_text(text)
395    }
396
397    #[test]
398    fn left_right_grapheme_moves() {
399        let r = rope("ab");
400        let nav = CursorNavigator::new(&r);
401        let mut pos = nav.from_line_grapheme(0, 0);
402        pos = nav.move_right(pos);
403        assert_eq!(pos.grapheme, 1);
404        pos = nav.move_right(pos);
405        assert_eq!(pos.grapheme, 2);
406        pos = nav.move_left(pos);
407        assert_eq!(pos.grapheme, 1);
408    }
409
410    #[test]
411    fn combining_mark_is_single_grapheme() {
412        let r = rope("e\u{0301}x");
413        let nav = CursorNavigator::new(&r);
414        let pos = nav.from_line_grapheme(0, 1);
415        assert_eq!(pos.visual_col, 1);
416        let next = nav.move_right(pos);
417        assert_eq!(next.grapheme, 2);
418    }
419
420    #[test]
421    fn emoji_zwj_grapheme_width() {
422        let r = rope("\u{1F469}\u{200D}\u{1F680}x");
423        let nav = CursorNavigator::new(&r);
424        let pos = nav.from_line_grapheme(0, 1);
425        assert_eq!(pos.visual_col, 2);
426        let next = nav.move_right(pos);
427        assert_eq!(next.grapheme, 2);
428    }
429
430    #[test]
431    fn tab_counts_as_one_cell() {
432        let r = rope("a\tb");
433        let nav = CursorNavigator::new(&r);
434        let pos = nav.from_line_grapheme(0, 2);
435        assert_eq!(pos.visual_col, 2);
436        let mid = nav.from_visual_col(0, 1);
437        assert_eq!(mid.grapheme, 1);
438        assert_eq!(mid.visual_col, 1);
439    }
440
441    #[test]
442    fn visual_col_to_grapheme_clamps_inside_wide() {
443        let r = rope("ab\u{754C}");
444        let nav = CursorNavigator::new(&r);
445        let pos = nav.from_visual_col(0, 3);
446        assert_eq!(pos.grapheme, 2);
447        assert_eq!(pos.visual_col, 2);
448    }
449
450    #[test]
451    fn move_up_down_preserves_visual_col() {
452        let r = rope("abcd\nx\u{754C}");
453        let nav = CursorNavigator::new(&r);
454        let pos = nav.from_line_grapheme(0, 3); // visual_col = 3
455        let down = nav.move_down(pos);
456        assert_eq!(down.line, 1);
457        assert_eq!(down.grapheme, 2);
458        assert_eq!(down.visual_col, 3);
459        let up = nav.move_up(down);
460        assert_eq!(up.line, 0);
461    }
462
463    #[test]
464    fn word_movement_respects_classes() {
465        let r = rope("hello  world!!!");
466        let nav = CursorNavigator::new(&r);
467        let pos = nav.from_line_grapheme(0, 0);
468        // move_word_right skips the word class then any trailing whitespace
469        let right = nav.move_word_right(pos);
470        assert_eq!(right.grapheme, 7); // past "hello" + spaces
471        let right = nav.move_word_right(right);
472        assert_eq!(right.grapheme, 12); // past "world" (no trailing space before punct)
473        let right = nav.move_word_right(right);
474        assert_eq!(right.grapheme, 15); // past "!!!"
475        let left = nav.move_word_left(right);
476        assert_eq!(left.grapheme, 12); // back to start of "!!!"
477    }
478
479    #[test]
480    fn byte_index_roundtrip() {
481        let r = rope("a\nbc");
482        let nav = CursorNavigator::new(&r);
483        let pos = nav.from_line_grapheme(1, 1);
484        let byte = nav.to_byte_index(pos);
485        let back = nav.from_byte_index(byte);
486        assert_eq!(back.line, 1);
487        assert_eq!(back.grapheme, 1);
488    }
489
490    // ====== Empty text ======
491
492    #[test]
493    fn empty_text_navigation() {
494        let r = rope("");
495        let nav = CursorNavigator::new(&r);
496        let pos = nav.from_line_grapheme(0, 0);
497        assert_eq!(pos.line, 0);
498        assert_eq!(pos.grapheme, 0);
499        assert_eq!(pos.visual_col, 0);
500    }
501
502    #[test]
503    fn empty_text_move_left_is_noop() {
504        let r = rope("");
505        let nav = CursorNavigator::new(&r);
506        let pos = nav.from_line_grapheme(0, 0);
507        let moved = nav.move_left(pos);
508        assert_eq!(moved, pos);
509    }
510
511    #[test]
512    fn empty_text_move_right_is_noop() {
513        let r = rope("");
514        let nav = CursorNavigator::new(&r);
515        let pos = nav.from_line_grapheme(0, 0);
516        let moved = nav.move_right(pos);
517        assert_eq!(moved, pos);
518    }
519
520    #[test]
521    fn empty_text_document_start_end() {
522        let r = rope("");
523        let nav = CursorNavigator::new(&r);
524        let start = nav.document_start();
525        let end = nav.document_end();
526        assert_eq!(start, end);
527        assert_eq!(start.line, 0);
528        assert_eq!(start.grapheme, 0);
529    }
530
531    // ====== Clamping ======
532
533    #[test]
534    fn clamp_out_of_bounds_line() {
535        let r = rope("abc");
536        let nav = CursorNavigator::new(&r);
537        let pos = CursorPosition::new(100, 0, 0);
538        let clamped = nav.clamp(pos);
539        assert_eq!(clamped.line, 0);
540    }
541
542    #[test]
543    fn clamp_out_of_bounds_grapheme() {
544        let r = rope("abc");
545        let nav = CursorNavigator::new(&r);
546        let pos = CursorPosition::new(0, 100, 0);
547        let clamped = nav.clamp(pos);
548        assert_eq!(clamped.grapheme, 3);
549        assert_eq!(clamped.visual_col, 3);
550    }
551
552    #[test]
553    fn clamp_multiline_out_of_bounds() {
554        let r = rope("abc\ndef");
555        let nav = CursorNavigator::new(&r);
556        let pos = CursorPosition::new(5, 50, 0);
557        let clamped = nav.clamp(pos);
558        assert_eq!(clamped.line, 1);
559        assert_eq!(clamped.grapheme, 3);
560    }
561
562    // ====== Line start/end ======
563
564    #[test]
565    fn line_start_moves_to_column_zero() {
566        let r = rope("hello world");
567        let nav = CursorNavigator::new(&r);
568        let pos = nav.from_line_grapheme(0, 5);
569        let start = nav.line_start(pos);
570        assert_eq!(start.grapheme, 0);
571        assert_eq!(start.visual_col, 0);
572    }
573
574    #[test]
575    fn line_end_moves_to_last_grapheme() {
576        let r = rope("hello");
577        let nav = CursorNavigator::new(&r);
578        let pos = nav.from_line_grapheme(0, 0);
579        let end = nav.line_end(pos);
580        assert_eq!(end.grapheme, 5);
581        assert_eq!(end.visual_col, 5);
582    }
583
584    #[test]
585    fn line_start_end_multiline() {
586        let r = rope("abc\nde");
587        let nav = CursorNavigator::new(&r);
588        let pos = nav.from_line_grapheme(1, 1);
589        let start = nav.line_start(pos);
590        assert_eq!(start.line, 1);
591        assert_eq!(start.grapheme, 0);
592        let end = nav.line_end(pos);
593        assert_eq!(end.line, 1);
594        assert_eq!(end.grapheme, 2);
595    }
596
597    // ====== Document start/end ======
598
599    #[test]
600    fn document_start_is_0_0() {
601        let r = rope("abc\ndef\nghi");
602        let nav = CursorNavigator::new(&r);
603        let start = nav.document_start();
604        assert_eq!(start.line, 0);
605        assert_eq!(start.grapheme, 0);
606        assert_eq!(start.visual_col, 0);
607    }
608
609    #[test]
610    fn document_end_is_last_line_last_grapheme() {
611        let r = rope("abc\ndef\nghi");
612        let nav = CursorNavigator::new(&r);
613        let end = nav.document_end();
614        assert_eq!(end.line, 2);
615        assert_eq!(end.grapheme, 3);
616        assert_eq!(end.visual_col, 3);
617    }
618
619    // ====== Cross-line movement ======
620
621    #[test]
622    fn move_left_wraps_to_previous_line() {
623        let r = rope("abc\ndef");
624        let nav = CursorNavigator::new(&r);
625        let pos = nav.from_line_grapheme(1, 0);
626        let moved = nav.move_left(pos);
627        assert_eq!(moved.line, 0);
628        assert_eq!(moved.grapheme, 3);
629    }
630
631    #[test]
632    fn move_right_wraps_to_next_line() {
633        let r = rope("abc\ndef");
634        let nav = CursorNavigator::new(&r);
635        let pos = nav.from_line_grapheme(0, 3);
636        let moved = nav.move_right(pos);
637        assert_eq!(moved.line, 1);
638        assert_eq!(moved.grapheme, 0);
639    }
640
641    #[test]
642    fn move_left_at_document_start_is_noop() {
643        let r = rope("abc");
644        let nav = CursorNavigator::new(&r);
645        let pos = nav.from_line_grapheme(0, 0);
646        let moved = nav.move_left(pos);
647        assert_eq!(moved, pos);
648    }
649
650    #[test]
651    fn move_right_at_document_end_is_noop() {
652        let r = rope("abc");
653        let nav = CursorNavigator::new(&r);
654        let pos = nav.from_line_grapheme(0, 3);
655        let moved = nav.move_right(pos);
656        assert_eq!(moved, pos);
657    }
658
659    // ====== Up/down movement ======
660
661    #[test]
662    fn move_up_at_first_line_is_noop() {
663        let r = rope("abc\ndef");
664        let nav = CursorNavigator::new(&r);
665        let pos = nav.from_line_grapheme(0, 1);
666        let moved = nav.move_up(pos);
667        assert_eq!(moved, pos);
668    }
669
670    #[test]
671    fn move_down_at_last_line_is_noop() {
672        let r = rope("abc\ndef");
673        let nav = CursorNavigator::new(&r);
674        let pos = nav.from_line_grapheme(1, 1);
675        let moved = nav.move_down(pos);
676        assert_eq!(moved, pos);
677    }
678
679    #[test]
680    fn move_down_shorter_line_clamps_grapheme() {
681        let r = rope("abcdef\nxy");
682        let nav = CursorNavigator::new(&r);
683        let pos = nav.from_line_grapheme(0, 5); // visual_col=5
684        let down = nav.move_down(pos);
685        assert_eq!(down.line, 1);
686        assert_eq!(down.grapheme, 2); // "xy" only has 2 graphemes
687        assert_eq!(down.visual_col, 2);
688    }
689
690    #[test]
691    fn move_up_shorter_line_clamps_grapheme() {
692        let r = rope("xy\nabcdef");
693        let nav = CursorNavigator::new(&r);
694        let pos = nav.from_line_grapheme(1, 5); // visual_col=5
695        let up = nav.move_up(pos);
696        assert_eq!(up.line, 0);
697        assert_eq!(up.grapheme, 2);
698        assert_eq!(up.visual_col, 2);
699    }
700
701    // ====== Wide character visual column handling ======
702
703    #[test]
704    fn wide_char_visual_col() {
705        // CJK characters are 2 cells wide
706        let r = rope("\u{4E16}\u{754C}"); // "δΈ–η•Œ"
707        let nav = CursorNavigator::new(&r);
708        let pos0 = nav.from_line_grapheme(0, 0);
709        assert_eq!(pos0.visual_col, 0);
710        let pos1 = nav.from_line_grapheme(0, 1);
711        assert_eq!(pos1.visual_col, 2);
712        let pos2 = nav.from_line_grapheme(0, 2);
713        assert_eq!(pos2.visual_col, 4);
714    }
715
716    #[test]
717    fn from_visual_col_with_wide_chars() {
718        let r = rope("\u{4E16}\u{754C}x"); // "δΈ–η•Œx"
719        let nav = CursorNavigator::new(&r);
720        // visual_col=1 falls inside first wide char -> snap to grapheme 0
721        let pos = nav.from_visual_col(0, 1);
722        assert_eq!(pos.grapheme, 0);
723        assert_eq!(pos.visual_col, 0);
724        // visual_col=2 starts at second char
725        let pos = nav.from_visual_col(0, 2);
726        assert_eq!(pos.grapheme, 1);
727        assert_eq!(pos.visual_col, 2);
728        // visual_col=4 is 'x'
729        let pos = nav.from_visual_col(0, 4);
730        assert_eq!(pos.grapheme, 2);
731        assert_eq!(pos.visual_col, 4);
732    }
733
734    // ====== Word movement ======
735
736    #[test]
737    fn word_right_from_start() {
738        let r = rope("hello world");
739        let nav = CursorNavigator::new(&r);
740        let pos = nav.from_line_grapheme(0, 0);
741        let moved = nav.move_word_right(pos);
742        assert_eq!(moved.grapheme, 6); // start of "world"
743    }
744
745    #[test]
746    fn word_left_from_end() {
747        let r = rope("hello world");
748        let nav = CursorNavigator::new(&r);
749        let pos = nav.from_line_grapheme(0, 11);
750        let moved = nav.move_word_left(pos);
751        assert_eq!(moved.grapheme, 6); // start of "world"
752    }
753
754    #[test]
755    fn word_right_at_line_end_wraps() {
756        let r = rope("hello\nworld");
757        let nav = CursorNavigator::new(&r);
758        let pos = nav.from_line_grapheme(0, 5);
759        let moved = nav.move_word_right(pos);
760        assert_eq!(moved.line, 1);
761        assert_eq!(moved.grapheme, 0);
762    }
763
764    #[test]
765    fn word_left_at_line_start_wraps() {
766        let r = rope("hello\nworld");
767        let nav = CursorNavigator::new(&r);
768        let pos = nav.from_line_grapheme(1, 0);
769        let moved = nav.move_word_left(pos);
770        assert_eq!(moved.line, 0);
771        // Should go to previous line end, finding word boundary
772        assert!(moved.grapheme <= 5);
773    }
774
775    #[test]
776    fn word_right_skips_punctuation() {
777        let r = rope("a!!b");
778        let nav = CursorNavigator::new(&r);
779        let pos = nav.from_line_grapheme(0, 1);
780        let moved = nav.move_word_right(pos);
781        assert_eq!(moved.grapheme, 3); // skips "!!" (punctuation class)
782    }
783
784    #[test]
785    fn word_movement_at_document_boundaries() {
786        let r = rope("abc");
787        let nav = CursorNavigator::new(&r);
788        // word left at start is noop
789        let start = nav.from_line_grapheme(0, 0);
790        let left = nav.move_word_left(start);
791        assert_eq!(left, start);
792        // word right at end is noop
793        let end = nav.from_line_grapheme(0, 3);
794        let right = nav.move_word_right(end);
795        assert_eq!(right, end);
796    }
797
798    // ====== Byte index roundtrips ======
799
800    #[test]
801    fn byte_index_roundtrip_multibyte() {
802        let r = rope("a\u{1F600}b"); // a πŸ˜€ b
803        let nav = CursorNavigator::new(&r);
804        for g in 0..=3 {
805            let pos = nav.from_line_grapheme(0, g);
806            let byte = nav.to_byte_index(pos);
807            let back = nav.from_byte_index(byte);
808            assert_eq!(
809                back.grapheme, pos.grapheme,
810                "roundtrip failed for grapheme {g}"
811            );
812        }
813    }
814
815    #[test]
816    fn byte_index_roundtrip_multiline_unicode() {
817        let r = rope("ab\n\u{4E16}\u{754C}");
818        let nav = CursorNavigator::new(&r);
819        let pos = nav.from_line_grapheme(1, 1); // η•Œ
820        let byte = nav.to_byte_index(pos);
821        let back = nav.from_byte_index(byte);
822        assert_eq!(back.line, 1);
823        assert_eq!(back.grapheme, 1);
824    }
825
826    // ====== from_visual_col edge cases ======
827
828    #[test]
829    fn from_visual_col_beyond_line_clamps() {
830        let r = rope("abc");
831        let nav = CursorNavigator::new(&r);
832        let pos = nav.from_visual_col(0, 100);
833        assert_eq!(pos.grapheme, 3);
834        assert_eq!(pos.visual_col, 3);
835    }
836
837    #[test]
838    fn from_visual_col_zero_on_empty_line() {
839        let r = rope("abc\n\ndef");
840        let nav = CursorNavigator::new(&r);
841        let pos = nav.from_visual_col(1, 5);
842        assert_eq!(pos.grapheme, 0);
843        assert_eq!(pos.visual_col, 0);
844    }
845
846    // ====== Internal helper tests ======
847
848    #[test]
849    fn grapheme_class_classification() {
850        use super::GraphemeClass;
851        use super::grapheme_class;
852        assert_eq!(grapheme_class(" "), GraphemeClass::Space);
853        assert_eq!(grapheme_class("\t"), GraphemeClass::Space);
854        assert_eq!(grapheme_class("a"), GraphemeClass::Word);
855        assert_eq!(grapheme_class("5"), GraphemeClass::Word);
856        assert_eq!(grapheme_class("!"), GraphemeClass::Punct);
857        assert_eq!(grapheme_class("."), GraphemeClass::Punct);
858    }
859
860    #[test]
861    fn move_word_left_in_line_edge_cases() {
862        use super::move_word_left_in_line;
863        // Already at start
864        assert_eq!(move_word_left_in_line("hello", 0), 0);
865        // Single word
866        assert_eq!(move_word_left_in_line("hello", 5), 0);
867        // Empty string
868        assert_eq!(move_word_left_in_line("", 0), 0);
869    }
870
871    #[test]
872    fn move_word_right_in_line_edge_cases() {
873        use super::move_word_right_in_line;
874        // Already at end
875        assert_eq!(move_word_right_in_line("hello", 5), 5);
876        // Single word from start
877        assert_eq!(move_word_right_in_line("hello", 0), 5);
878        // Empty string
879        assert_eq!(move_word_right_in_line("", 0), 0);
880    }
881}