1#![forbid(unsafe_code)]
2
3use crate::rope::Rope;
9use crate::wrap::{display_width, graphemes};
10use std::borrow::Cow;
11use unicode_segmentation::UnicodeSegmentation;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub struct CursorPosition {
16 pub line: usize,
18 pub grapheme: usize,
20 pub visual_col: usize,
22}
23
24impl CursorPosition {
25 #[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#[derive(Debug, Clone, Copy)]
38pub struct CursorNavigator<'a> {
39 rope: &'a Rope,
40}
41
42impl<'a> CursorNavigator<'a> {
43 #[must_use]
45 pub const fn new(rope: &'a Rope) -> Self {
46 Self { rope }
47 }
48
49 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
179 pub fn document_start(&self) -> CursorPosition {
180 self.from_line_grapheme(0, 0)
181 }
182
183 #[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 #[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 #[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); 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 let right = nav.move_word_right(pos);
470 assert_eq!(right.grapheme, 7); let right = nav.move_word_right(right);
472 assert_eq!(right.grapheme, 12); let right = nav.move_word_right(right);
474 assert_eq!(right.grapheme, 15); let left = nav.move_word_left(right);
476 assert_eq!(left.grapheme, 12); }
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 #[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 #[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 #[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 #[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 #[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 #[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); let down = nav.move_down(pos);
685 assert_eq!(down.line, 1);
686 assert_eq!(down.grapheme, 2); 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); 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 #[test]
704 fn wide_char_visual_col() {
705 let r = rope("\u{4E16}\u{754C}"); 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"); let nav = CursorNavigator::new(&r);
720 let pos = nav.from_visual_col(0, 1);
722 assert_eq!(pos.grapheme, 0);
723 assert_eq!(pos.visual_col, 0);
724 let pos = nav.from_visual_col(0, 2);
726 assert_eq!(pos.grapheme, 1);
727 assert_eq!(pos.visual_col, 2);
728 let pos = nav.from_visual_col(0, 4);
730 assert_eq!(pos.grapheme, 2);
731 assert_eq!(pos.visual_col, 4);
732 }
733
734 #[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); }
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); }
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 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); }
783
784 #[test]
785 fn word_movement_at_document_boundaries() {
786 let r = rope("abc");
787 let nav = CursorNavigator::new(&r);
788 let start = nav.from_line_grapheme(0, 0);
790 let left = nav.move_word_left(start);
791 assert_eq!(left, start);
792 let end = nav.from_line_grapheme(0, 3);
794 let right = nav.move_word_right(end);
795 assert_eq!(right, end);
796 }
797
798 #[test]
801 fn byte_index_roundtrip_multibyte() {
802 let r = rope("a\u{1F600}b"); 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); 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 #[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 #[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 assert_eq!(move_word_left_in_line("hello", 0), 0);
865 assert_eq!(move_word_left_in_line("hello", 5), 0);
867 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 assert_eq!(move_word_right_in_line("hello", 5), 5);
876 assert_eq!(move_word_right_in_line("hello", 0), 5);
878 assert_eq!(move_word_right_in_line("", 0), 0);
880 }
881}