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 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); 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 let right = nav.move_word_right(pos);
457 assert_eq!(right.grapheme, 7); let right = nav.move_word_right(right);
459 assert_eq!(right.grapheme, 12); let right = nav.move_word_right(right);
461 assert_eq!(right.grapheme, 15); let left = nav.move_word_left(right);
463 assert_eq!(left.grapheme, 12); }
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 #[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 #[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 #[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 #[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 #[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 #[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); let down = nav.move_down(pos);
672 assert_eq!(down.line, 1);
673 assert_eq!(down.grapheme, 2); 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); 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 #[test]
691 fn wide_char_visual_col() {
692 let r = rope("\u{4E16}\u{754C}"); 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"); let nav = CursorNavigator::new(&r);
707 let pos = nav.from_visual_col(0, 1);
709 assert_eq!(pos.grapheme, 0);
710 assert_eq!(pos.visual_col, 0);
711 let pos = nav.from_visual_col(0, 2);
713 assert_eq!(pos.grapheme, 1);
714 assert_eq!(pos.visual_col, 2);
715 let pos = nav.from_visual_col(0, 4);
717 assert_eq!(pos.grapheme, 2);
718 assert_eq!(pos.visual_col, 4);
719 }
720
721 #[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); }
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); }
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 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); }
770
771 #[test]
772 fn word_movement_at_document_boundaries() {
773 let r = rope("abc");
774 let nav = CursorNavigator::new(&r);
775 let start = nav.from_line_grapheme(0, 0);
777 let left = nav.move_word_left(start);
778 assert_eq!(left, start);
779 let end = nav.from_line_grapheme(0, 3);
781 let right = nav.move_word_right(end);
782 assert_eq!(right, end);
783 }
784
785 #[test]
788 fn byte_index_roundtrip_multibyte() {
789 let r = rope("a\u{1F600}b"); 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); 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 #[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 #[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 assert_eq!(move_word_left_in_line("hello", 0), 0);
852 assert_eq!(move_word_left_in_line("hello", 5), 0);
854 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 assert_eq!(move_word_right_in_line("hello", 5), 5);
863 assert_eq!(move_word_right_in_line("hello", 0), 5);
865 assert_eq!(move_word_right_in_line("", 0), 0);
867 }
868}