1use unicode_segmentation::UnicodeSegmentation;
10use unicode_width::UnicodeWidthStr;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub struct GraphemeIndex(pub usize);
18
19impl GraphemeIndex {
20 pub fn new(index: usize) -> Self {
21 Self(index)
22 }
23
24 pub fn as_usize(&self) -> usize {
25 self.0
26 }
27}
28
29impl From<usize> for GraphemeIndex {
30 fn from(index: usize) -> Self {
31 Self(index)
32 }
33}
34
35impl From<GraphemeIndex> for usize {
36 fn from(index: GraphemeIndex) -> usize {
37 index.0
38 }
39}
40
41pub fn display_width(s: &str) -> usize {
49 UnicodeWidthStr::width(s)
50}
51
52pub fn grapheme_width(grapheme: &str) -> usize {
54 UnicodeWidthStr::width(grapheme)
55}
56
57pub fn grapheme_count(s: &str) -> usize {
59 s.graphemes(true).count()
60}
61
62pub fn graphemes(s: &str) -> impl Iterator<Item = &str> {
64 s.graphemes(true)
65}
66
67#[derive(Debug, Clone)]
71pub struct VisualLine<'a> {
72 pub text: &'a str,
74 pub grapheme_start: usize,
76 pub grapheme_end: usize,
78 pub width: usize,
80}
81
82pub fn soft_wrap_line(text: &str, width: usize) -> Vec<VisualLine<'_>> {
98 if width == 0 {
99 return vec![];
100 }
101
102 let text_width = display_width(text);
104 if text_width <= width {
105 return vec![VisualLine {
106 text,
107 grapheme_start: 0,
108 grapheme_end: grapheme_count(text),
109 width: text_width,
110 }];
111 }
112
113 let mut lines = Vec::new();
115 let mut current_start_byte = 0;
116 let mut current_start_grapheme = 0;
117 let mut current_width = 0;
118 let mut grapheme_idx = 0;
119
120 for (byte_idx, grapheme) in text.grapheme_indices(true) {
121 let g_width = grapheme_width(grapheme);
122
123 if current_width + g_width > width {
125 if current_start_byte < byte_idx {
127 let line_text = &text[current_start_byte..byte_idx];
128 lines.push(VisualLine {
129 text: line_text,
130 grapheme_start: current_start_grapheme,
131 grapheme_end: grapheme_idx,
132 width: current_width,
133 });
134 }
135 current_start_byte = byte_idx;
137 current_start_grapheme = grapheme_idx;
138 current_width = g_width;
139 } else {
140 current_width += g_width;
141 }
142
143 grapheme_idx += 1;
144 }
145
146 if current_start_byte < text.len() {
148 let line_text = &text[current_start_byte..];
149 lines.push(VisualLine {
150 text: line_text,
151 grapheme_start: current_start_grapheme,
152 grapheme_end: grapheme_idx,
153 width: display_width(line_text),
154 });
155 }
156
157 if lines.is_empty() {
159 lines.push(VisualLine {
160 text: "",
161 grapheme_start: 0,
162 grapheme_end: 0,
163 width: 0,
164 });
165 }
166
167 lines
168}
169
170pub fn soft_wrap(text: &str, width: usize) -> Vec<VisualLine<'_>> {
176 if width == 0 {
177 return vec![];
178 }
179
180 let mut result = Vec::new();
181 let mut line_grapheme_offset = 0;
182
183 for line in text.split('\n') {
184 let wrapped = soft_wrap_line(line, width);
185 for mut visual_line in wrapped {
186 visual_line.grapheme_start += line_grapheme_offset;
188 visual_line.grapheme_end += line_grapheme_offset;
189 result.push(visual_line);
190 }
191 line_grapheme_offset += grapheme_count(line) + 1;
193 }
194
195 if result.is_empty() {
196 result.push(VisualLine {
197 text: "",
198 grapheme_start: 0,
199 grapheme_end: 0,
200 width: 0,
201 });
202 }
203
204 result
205}
206
207pub fn cursor_to_screen(text: &str, grapheme_idx: usize, width: usize) -> (u16, u16) {
220 if width == 0 {
221 return (0, 0);
222 }
223
224 let mut row = 0u16;
225 let mut grapheme_offset = 0;
226
227 for line in text.split('\n') {
228 let line_graphemes = grapheme_count(line);
229
230 if grapheme_idx <= grapheme_offset + line_graphemes {
232 let cursor_in_line = grapheme_idx - grapheme_offset;
234 let wrapped = soft_wrap_line(line, width);
235
236 for visual_line in &wrapped {
237 if cursor_in_line < visual_line.grapheme_end {
238 let col_grapheme = cursor_in_line - visual_line.grapheme_start;
240 let col = graphemes(visual_line.text)
242 .take(col_grapheme)
243 .map(grapheme_width)
244 .sum::<usize>() as u16;
245 return (col, row);
246 }
247 row += 1;
248 }
249 if let Some(last) = wrapped.last() {
251 let col = last.width as u16;
252 return (col, row.saturating_sub(1));
253 }
254 }
255
256 grapheme_offset += line_graphemes + 1; row += soft_wrap_line(line, width).len() as u16;
258 }
259
260 (0, row.saturating_sub(1))
262}
263
264pub fn screen_to_cursor(text: &str, col: u16, row: u16, width: usize) -> usize {
277 if width == 0 {
278 return 0;
279 }
280
281 let mut current_row = 0u16;
282 let mut grapheme_offset = 0;
283
284 for line in text.split('\n') {
285 let wrapped = soft_wrap_line(line, width);
286
287 for visual_line in &wrapped {
288 if current_row == row {
289 let mut current_col = 0usize;
291
292 for (grapheme_in_line, grapheme) in graphemes(visual_line.text).enumerate() {
293 let g_width = grapheme_width(grapheme);
294 if current_col + g_width > col as usize {
295 return grapheme_offset + visual_line.grapheme_start + grapheme_in_line;
297 }
298 current_col += g_width;
299 }
300
301 return grapheme_offset + visual_line.grapheme_end;
303 }
304 current_row += 1;
305 }
306
307 grapheme_offset += grapheme_count(line) + 1; }
309
310 grapheme_count(text)
312}
313
314pub fn grapheme_to_byte_offset(text: &str, grapheme_idx: usize) -> Option<usize> {
318 text.grapheme_indices(true)
319 .nth(grapheme_idx)
320 .map(|(byte_idx, _)| byte_idx)
321 .or_else(|| {
322 if grapheme_idx == grapheme_count(text) {
324 Some(text.len())
325 } else {
326 None
327 }
328 })
329}
330
331pub fn byte_to_grapheme_offset(text: &str, byte_offset: usize) -> usize {
335 text.grapheme_indices(true)
336 .take_while(|(idx, _)| *idx < byte_offset)
337 .count()
338}
339
340pub fn insert_at_grapheme(text: &str, grapheme_idx: usize, insert: &str) -> String {
342 let byte_offset = grapheme_to_byte_offset(text, grapheme_idx).unwrap_or(text.len());
343 let mut result = String::with_capacity(text.len() + insert.len());
344 result.push_str(&text[..byte_offset]);
345 result.push_str(insert);
346 result.push_str(&text[byte_offset..]);
347 result
348}
349
350pub fn remove_at_grapheme(text: &str, grapheme_idx: usize) -> Option<String> {
354 let mut graphemes: Vec<&str> = text.graphemes(true).collect();
355 if grapheme_idx >= graphemes.len() {
356 return None;
357 }
358 graphemes.remove(grapheme_idx);
359 Some(graphemes.concat())
360}
361
362pub fn wrapped_height(text: &str, width: usize) -> usize {
364 if width == 0 {
365 return 0;
366 }
367 soft_wrap(text, width).len()
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 #[test]
375 fn test_display_width_ascii() {
376 assert_eq!(display_width("hello"), 5);
377 assert_eq!(display_width(""), 0);
378 }
379
380 #[test]
381 fn test_display_width_cjk() {
382 assert_eq!(display_width("中"), 2);
383 assert_eq!(display_width("中文"), 4);
384 assert_eq!(display_width("hello中文"), 9); }
386
387 #[test]
388 fn test_grapheme_count() {
389 assert_eq!(grapheme_count("hello"), 5);
390 assert_eq!(grapheme_count("é"), 1); assert_eq!(grapheme_count("e\u{0301}"), 1); }
393
394 #[test]
395 fn test_soft_wrap_fits() {
396 let lines = soft_wrap_line("hello", 10);
397 assert_eq!(lines.len(), 1);
398 assert_eq!(lines[0].text, "hello");
399 }
400
401 #[test]
402 fn test_soft_wrap_character_wrap() {
403 let lines = soft_wrap_line("hello world", 8);
405 assert_eq!(lines.len(), 2);
406 assert_eq!(lines[0].text, "hello wo"); assert_eq!(lines[1].text, "rld"); }
409
410 #[test]
411 fn test_soft_wrap_force_break() {
412 let lines = soft_wrap_line("abcdefghij", 4);
413 assert_eq!(lines.len(), 3);
414 assert_eq!(lines[0].text, "abcd");
415 assert_eq!(lines[1].text, "efgh");
416 assert_eq!(lines[2].text, "ij");
417 }
418
419 #[test]
420 fn test_cursor_to_screen_simple() {
421 let text = "hello";
422 assert_eq!(cursor_to_screen(text, 0, 80), (0, 0));
423 assert_eq!(cursor_to_screen(text, 2, 80), (2, 0));
424 assert_eq!(cursor_to_screen(text, 5, 80), (5, 0));
425 }
426
427 #[test]
428 fn test_cursor_to_screen_multiline() {
429 let text = "hello\nworld";
430 assert_eq!(cursor_to_screen(text, 0, 80), (0, 0));
431 assert_eq!(cursor_to_screen(text, 5, 80), (5, 0)); assert_eq!(cursor_to_screen(text, 6, 80), (0, 1)); assert_eq!(cursor_to_screen(text, 8, 80), (2, 1)); }
435
436 #[test]
437 fn test_insert_at_grapheme() {
438 assert_eq!(insert_at_grapheme("hello", 0, "X"), "Xhello");
439 assert_eq!(insert_at_grapheme("hello", 2, "X"), "heXllo");
440 assert_eq!(insert_at_grapheme("hello", 5, "X"), "helloX");
441 }
442
443 #[test]
444 fn test_remove_at_grapheme() {
445 assert_eq!(remove_at_grapheme("hello", 0), Some("ello".to_string()));
446 assert_eq!(remove_at_grapheme("hello", 2), Some("helo".to_string()));
447 assert_eq!(remove_at_grapheme("hello", 4), Some("hell".to_string()));
448 assert_eq!(remove_at_grapheme("hello", 5), None);
449 }
450
451 #[test]
456 fn test_soft_wrap_returns_references_not_copies() {
457 let original = "here is my text before I resize the screen";
460 let wrapped = soft_wrap_line(original, 20);
461
462 for line in &wrapped {
464 assert!(original.contains(line.text));
466 }
467
468 let reconstructed: String = wrapped.iter().map(|l| l.text).collect::<Vec<_>>().concat();
471 assert_eq!(reconstructed, original);
472 }
473
474 #[test]
475 fn test_soft_wrap_no_newlines_inserted() {
476 let content = "here is my text before I resize the screen it is lovely";
479
480 let wrapped_narrow = soft_wrap_line(content, 20);
482 assert!(wrapped_narrow.len() > 1, "Should wrap at width 20");
483
484 let wrapped_wide = soft_wrap_line(content, 80);
486 assert_eq!(wrapped_wide.len(), 1, "Should not wrap at width 80");
487
488 assert!(!content.contains('\n'), "Content must not contain newlines");
490
491 for line in &wrapped_narrow {
493 assert!(
494 !line.text.contains('\n'),
495 "Visual line must not contain newlines"
496 );
497 }
498 }
499
500 #[test]
501 fn test_resize_reflows_correctly() {
502 let content = "here is my text before I resize the screen";
507
508 let at_20 = soft_wrap_line(content, 20);
510
511 let at_60 = soft_wrap_line(content, 60);
513
514 let at_80 = soft_wrap_line(content, 80);
516
517 assert!(
519 at_20.len() > at_60.len(),
520 "Narrower width should have more visual lines"
521 );
522 assert!(
523 at_60.len() >= at_80.len(),
524 "Wider width should have fewer visual lines"
525 );
526
527 assert!(!content.contains('\n'));
530 }
531
532 #[test]
533 fn test_content_integrity_after_simulated_typing() {
534 let mut content = String::new();
539
540 for i in 0..80 {
542 content.push(char::from_u32('a' as u32 + (i % 26)).unwrap());
543 }
544
545 assert_eq!(content.len(), 80);
547 assert_eq!(content.chars().filter(|&c| c == '\n').count(), 0);
548
549 let visual_lines = soft_wrap_line(&content, 20);
551 assert_eq!(
552 visual_lines.len(),
553 4,
554 "80 chars / 20 width = 4 visual lines"
555 );
556
557 assert!(
559 !content.contains('\n'),
560 "Content must never be modified by wrapping"
561 );
562 }
563
564 #[test]
565 fn test_cjk_content_wraps_by_display_width() {
566 let content = "中文中文中文"; let wrapped = soft_wrap_line(content, 6);
572 assert_eq!(wrapped.len(), 2);
573 assert_eq!(display_width(wrapped[0].text), 6);
574 assert_eq!(display_width(wrapped[1].text), 6);
575 }
576
577 #[test]
578 fn test_grapheme_cluster_not_split() {
579 let content = "hello 👨👩👧👦 world"; let wrapped = soft_wrap_line(content, 10);
584
585 let emoji_line = wrapped.iter().find(|l| l.text.contains('👨')).unwrap();
587
588 assert!(emoji_line.text.contains("👨👩👧👦"));
590 }
591
592 #[test]
597 fn test_character_wrap_typing_at_edge() {
598 let before = "here we are on a thin scre";
601 let after = "here we are on a thin scree";
602
603 let wrapped_before = soft_wrap_line(before, 26);
604 let wrapped_after = soft_wrap_line(after, 26);
605
606 assert_eq!(wrapped_before.len(), 1);
608 assert_eq!(wrapped_before[0].text, before);
609
610 assert_eq!(wrapped_after.len(), 2);
612 assert_eq!(wrapped_after[0].text, "here we are on a thin scre");
613 assert_eq!(wrapped_after[1].text, "e");
614 }
615
616 #[test]
617 fn test_character_wrap_typing_continues_on_second_line() {
618 let content = "here we are on a thin screen";
620 let wrapped = soft_wrap_line(content, 26);
621
622 assert_eq!(wrapped.len(), 2);
623 assert_eq!(wrapped[0].text, "here we are on a thin scre");
624 assert_eq!(wrapped[1].text, "en");
625 }
626
627 #[test]
628 fn test_resize_wider_reflows_back() {
629 let content = "here is my text that wraps";
631
632 let at_20 = soft_wrap_line(content, 20);
633 let at_40 = soft_wrap_line(content, 40);
634
635 assert_eq!(at_20.len(), 2);
637 assert_eq!(at_20[0].text, "here is my text that");
638 assert_eq!(at_20[1].text, " wraps");
639
640 assert_eq!(at_40.len(), 1);
642 assert_eq!(at_40[0].text, content);
643
644 let reconstructed_20: String = at_20.iter().map(|l| l.text).collect();
646 let reconstructed_40: String = at_40.iter().map(|l| l.text).collect();
647 assert_eq!(reconstructed_20, content);
648 assert_eq!(reconstructed_40, content);
649 }
650
651 #[test]
652 fn test_resize_narrower_reflows_more() {
653 let content = "abcdefghijklmnopqrstuvwxyz";
654
655 let at_26 = soft_wrap_line(content, 26);
656 let at_13 = soft_wrap_line(content, 13);
657 let at_5 = soft_wrap_line(content, 5);
658
659 assert_eq!(at_26.len(), 1);
661
662 assert_eq!(at_13.len(), 2);
664 assert_eq!(at_13[0].text, "abcdefghijklm");
665 assert_eq!(at_13[1].text, "nopqrstuvwxyz");
666
667 assert_eq!(at_5.len(), 6);
669 assert_eq!(at_5[0].text, "abcde");
670 assert_eq!(at_5[5].text, "z");
671 }
672
673 #[test]
674 fn test_exact_width_no_wrap() {
675 let content = "12345";
677 let wrapped = soft_wrap_line(content, 5);
678
679 assert_eq!(wrapped.len(), 1);
680 assert_eq!(wrapped[0].text, "12345");
681 }
682
683 #[test]
684 fn test_one_over_width_wraps() {
685 let content = "123456";
687 let wrapped = soft_wrap_line(content, 5);
688
689 assert_eq!(wrapped.len(), 2);
690 assert_eq!(wrapped[0].text, "12345");
691 assert_eq!(wrapped[1].text, "6");
692 }
693
694 #[test]
695 fn test_spaces_preserved_in_wrap() {
696 let content = "ab cd ef";
698 let wrapped = soft_wrap_line(content, 4);
699
700 assert_eq!(wrapped.len(), 2);
702 assert_eq!(wrapped[0].text, "ab c");
703 assert_eq!(wrapped[1].text, "d ef");
704
705 let reconstructed: String = wrapped.iter().map(|l| l.text).collect();
707 assert_eq!(reconstructed, content);
708 }
709
710 #[test]
711 fn test_grapheme_indices_correct_after_wrap() {
712 let content = "abcdefghij";
713 let wrapped = soft_wrap_line(content, 4);
714
715 assert_eq!(wrapped[0].grapheme_start, 0);
717 assert_eq!(wrapped[0].grapheme_end, 4);
718
719 assert_eq!(wrapped[1].grapheme_start, 4);
721 assert_eq!(wrapped[1].grapheme_end, 8);
722
723 assert_eq!(wrapped[2].grapheme_start, 8);
725 assert_eq!(wrapped[2].grapheme_end, 10);
726 }
727
728 #[test]
729 fn test_cursor_position_after_wrap() {
730 let content = "abcdefgh";
733 let (col, row) = cursor_to_screen(content, 6, 5);
734
735 assert_eq!(row, 1, "Cursor should be on second visual line");
736 assert_eq!(col, 1, "Cursor should be at column 1 (after 'f')");
737 }
738
739 #[test]
740 fn test_cursor_at_wrap_boundary() {
741 let content = "abcdefgh";
743
744 let (col, row) = cursor_to_screen(content, 5, 5);
746 assert_eq!(row, 1, "Cursor should be on second line");
747 assert_eq!(col, 0, "Cursor should be at start of second line");
748 }
749
750 #[test]
751 fn test_multiple_resize_cycles() {
752 let content = "the quick brown fox jumps over lazy dog";
755
756 for width in [40, 20, 40, 10, 40, 15, 40] {
757 let wrapped = soft_wrap_line(content, width);
758 let reconstructed: String = wrapped.iter().map(|l| l.text).collect();
759 assert_eq!(
760 reconstructed, content,
761 "Content corrupted at width {}",
762 width
763 );
764 }
765 }
766
767 #[test]
768 fn test_emoji_at_wrap_boundary() {
769 let base = "a b c d e dakl asdl d fox fox badger😊😊😊 😊😊";
772 let base_width = display_width(base);
773
774 let wrapped_exact = soft_wrap_line(base, base_width);
776 assert_eq!(
777 wrapped_exact.len(),
778 1,
779 "Should fit on one line at exact width"
780 );
781
782 let with_extra = "a b c d e dakl asdl d fox fox badger😊😊😊 😊😊😊";
784
785 let wrapped_overflow = soft_wrap_line(with_extra, base_width);
787
788 assert_eq!(wrapped_overflow.len(), 2, "Should wrap to two lines");
789 assert!(
790 wrapped_overflow[1].text.contains('😊'),
791 "Second line should have the overflow emoji"
792 );
793
794 let reconstructed: String = wrapped_overflow.iter().map(|l| l.text).collect();
796 assert_eq!(reconstructed, with_extra, "Content must be preserved");
797 }
798
799 #[test]
800 fn test_wide_char_at_boundary_edge_cases() {
801 let text = "a b c d e dakl asdl d fox fox badger😊😊😊 😊😊";
805 let at_47 = soft_wrap_line(text, 47);
810 assert_eq!(at_47.len(), 1);
811
812 let at_46 = soft_wrap_line(text, 46);
815 assert_eq!(at_46.len(), 2);
816 assert_eq!(at_46[0].width, 45); assert_eq!(at_46[1].text, "😊");
818
819 let at_45 = soft_wrap_line(text, 45);
821 assert_eq!(at_45.len(), 2);
822
823 for width in [47, 46, 45, 44, 43, 42, 41, 40] {
825 let wrapped = soft_wrap_line(text, width);
826 let reconstructed: String = wrapped.iter().map(|l| l.text).collect();
827 assert_eq!(reconstructed, text, "Content corrupted at width {}", width);
828 }
829 }
830}