Skip to main content

telex/
text.rs

1//! Unicode-aware text handling for Telex.
2//!
3//! This module provides the foundation for proper text handling:
4//! - Grapheme cluster awareness (user-perceived characters)
5//! - Display width calculation (handles wide characters like CJK, emoji)
6//! - Soft wrapping (visual-only, never modifies content)
7//! - Cursor positioning (grapheme-indexed, not byte-indexed)
8
9use unicode_segmentation::UnicodeSegmentation;
10use unicode_width::UnicodeWidthStr;
11
12/// A position in text measured in grapheme clusters.
13///
14/// This is the correct unit for cursor positioning - it represents
15/// user-perceived characters, not bytes or code points.
16#[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
41/// Get the display width of a string in terminal columns.
42///
43/// Handles:
44/// - ASCII: 1 column each
45/// - CJK characters: 2 columns each
46/// - Emoji: typically 2 columns
47/// - Zero-width characters: 0 columns
48pub fn display_width(s: &str) -> usize {
49    UnicodeWidthStr::width(s)
50}
51
52/// Get the display width of a single grapheme cluster.
53pub fn grapheme_width(grapheme: &str) -> usize {
54    UnicodeWidthStr::width(grapheme)
55}
56
57/// Count the number of grapheme clusters in a string.
58pub fn grapheme_count(s: &str) -> usize {
59    s.graphemes(true).count()
60}
61
62/// Iterate over grapheme clusters in a string.
63pub fn graphemes(s: &str) -> impl Iterator<Item = &str> {
64    s.graphemes(true)
65}
66
67/// A visual line produced by soft wrapping.
68///
69/// Contains the byte range into the original string and display info.
70#[derive(Debug, Clone)]
71pub struct VisualLine<'a> {
72    /// The text content of this visual line
73    pub text: &'a str,
74    /// Starting grapheme index in the original line
75    pub grapheme_start: usize,
76    /// Ending grapheme index (exclusive) in the original line
77    pub grapheme_end: usize,
78    /// Display width of this line in columns
79    pub width: usize,
80}
81
82/// Soft-wrap a single line of text to fit within a given width.
83///
84/// This is **visual-only** wrapping - it returns references into the original
85/// string and never modifies content. The original text is unchanged.
86///
87/// Wrapping strategy: **Character wrap** - characters flow to where they fit.
88/// When a character doesn't fit on the current line, it goes to the next line.
89/// No word rearrangement, no jumping.
90///
91/// # Arguments
92/// * `text` - A single line of text (should not contain newlines)
93/// * `width` - Maximum display width in columns
94///
95/// # Returns
96/// A vector of `VisualLine`s representing how the text should be displayed.
97pub fn soft_wrap_line(text: &str, width: usize) -> Vec<VisualLine<'_>> {
98    if width == 0 {
99        return vec![];
100    }
101
102    // Fast path: if text fits, return as-is
103    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    // Character wrap: fill each line until it's full, then continue on next line
114    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        // Check if this grapheme would exceed the width
124        if current_width + g_width > width {
125            // This grapheme doesn't fit - finish current line, start new one
126            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            // Start new line with this grapheme
136            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    // Don't forget the last line
147    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    // Handle empty string case
158    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
170/// Soft-wrap multi-line text, preserving existing line breaks.
171///
172/// This wraps each logical line (separated by '\n') independently.
173/// The '\n' characters in the content are **preserved** - this function
174/// only adds visual breaks, never modifies the content.
175pub 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            // Adjust grapheme indices to be relative to the full text
187            visual_line.grapheme_start += line_grapheme_offset;
188            visual_line.grapheme_end += line_grapheme_offset;
189            result.push(visual_line);
190        }
191        // Account for the newline character (1 grapheme)
192        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
207/// Convert a grapheme index to screen coordinates (column, row) given a width.
208///
209/// This accounts for soft wrapping to determine which visual line and column
210/// the cursor should appear at.
211///
212/// # Arguments
213/// * `text` - The full text content
214/// * `grapheme_idx` - Cursor position as a grapheme index
215/// * `width` - Display width for wrapping
216///
217/// # Returns
218/// (column, row) where both are 0-indexed
219pub 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        // Check if cursor is within this logical line
231        if grapheme_idx <= grapheme_offset + line_graphemes {
232            // Cursor is in this line - now find which visual line
233            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                    // Cursor is in this visual line
239                    let col_grapheme = cursor_in_line - visual_line.grapheme_start;
240                    // Calculate display column by measuring width of graphemes before cursor
241                    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            // Cursor at end of line
250            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; // +1 for newline
257        row += soft_wrap_line(line, width).len() as u16;
258    }
259
260    // Cursor past end of text
261    (0, row.saturating_sub(1))
262}
263
264/// Convert screen coordinates (column, row) to a grapheme index.
265///
266/// This is the inverse of `cursor_to_screen`.
267///
268/// # Arguments
269/// * `text` - The full text content
270/// * `col` - Screen column (0-indexed)
271/// * `row` - Screen row (0-indexed)
272/// * `width` - Display width for wrapping
273///
274/// # Returns
275/// The grapheme index at or nearest to the given screen position
276pub 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                // Found the target row - now find the column
290                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                        // Click is on this grapheme
296                        return grapheme_offset + visual_line.grapheme_start + grapheme_in_line;
297                    }
298                    current_col += g_width;
299                }
300
301                // Click past end of line
302                return grapheme_offset + visual_line.grapheme_end;
303            }
304            current_row += 1;
305        }
306
307        grapheme_offset += grapheme_count(line) + 1; // +1 for newline
308    }
309
310    // Click below text
311    grapheme_count(text)
312}
313
314/// Get the byte offset for a given grapheme index.
315///
316/// Returns None if the index is out of bounds.
317pub 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            // Index might be at the end
323            if grapheme_idx == grapheme_count(text) {
324                Some(text.len())
325            } else {
326                None
327            }
328        })
329}
330
331/// Get the grapheme index for a given byte offset.
332///
333/// Returns the grapheme that contains or starts at this byte offset.
334pub 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
340/// Insert a string at a grapheme index, returning the new string.
341pub 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
350/// Remove the grapheme at the given index, returning the new string.
351///
352/// Returns None if the index is out of bounds.
353pub 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
362/// Calculate the wrapped height of text (number of visual lines).
363pub 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); // 5 + 4
385    }
386
387    #[test]
388    fn test_grapheme_count() {
389        assert_eq!(grapheme_count("hello"), 5);
390        assert_eq!(grapheme_count("é"), 1); // Single grapheme (composed)
391        assert_eq!(grapheme_count("e\u{0301}"), 1); // e + combining acute = 1 grapheme
392    }
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        // Character wrap: text flows to where it fits, no word rearrangement
404        let lines = soft_wrap_line("hello world", 8);
405        assert_eq!(lines.len(), 2);
406        assert_eq!(lines[0].text, "hello wo"); // 8 chars fit
407        assert_eq!(lines[1].text, "rld"); // rest flows to next line
408    }
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)); // End of "hello"
432        assert_eq!(cursor_to_screen(text, 6, 80), (0, 1)); // Start of "world"
433        assert_eq!(cursor_to_screen(text, 8, 80), (2, 1)); // "wo|rld"
434    }
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    // ==========================================================================
452    // Tests that prove the auto-wrap bug fix
453    // ==========================================================================
454
455    #[test]
456    fn test_soft_wrap_returns_references_not_copies() {
457        // This proves soft_wrap doesn't modify content - it returns slices
458        // into the original string. The content is NEVER modified.
459        let original = "here is my text before I resize the screen";
460        let wrapped = soft_wrap_line(original, 20);
461
462        // Each visual line's text is a slice of the original
463        for line in &wrapped {
464            // Verify text is a substring of original
465            assert!(original.contains(line.text));
466        }
467
468        // Character wrap: concatenating all visual lines directly gives us
469        // back the original content (no spaces added/removed)
470        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        // The original bug: typing would insert '\n' into content.
477        // This test proves soft_wrap NEVER adds newlines.
478        let content = "here is my text before I resize the screen it is lovely";
479
480        // Wrap at narrow width
481        let wrapped_narrow = soft_wrap_line(content, 20);
482        assert!(wrapped_narrow.len() > 1, "Should wrap at width 20");
483
484        // Wrap at wide width
485        let wrapped_wide = soft_wrap_line(content, 80);
486        assert_eq!(wrapped_wide.len(), 1, "Should not wrap at width 80");
487
488        // CRITICAL: The content itself has NO newlines
489        assert!(!content.contains('\n'), "Content must not contain newlines");
490
491        // Visual lines don't contain newlines either
492        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        // The original bug: text wrapped at width 40 would have '\n' baked in,
503        // so resizing to 80 wouldn't reflow - lines stayed short.
504        //
505        // This test proves: same content, different widths = different visual lines.
506        let content = "here is my text before I resize the screen";
507
508        // "Narrow window" - 20 cols
509        let at_20 = soft_wrap_line(content, 20);
510
511        // "Wide window" - 60 cols
512        let at_60 = soft_wrap_line(content, 60);
513
514        // "Very wide window" - 80 cols
515        let at_80 = soft_wrap_line(content, 80);
516
517        // Narrow wraps more
518        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        // But content is identical - just different visual presentation
528        // (no '\n' characters were added to content)
529        assert!(!content.contains('\n'));
530    }
531
532    #[test]
533    fn test_content_integrity_after_simulated_typing() {
534        // Simulate what happens when user types a long string.
535        // With the OLD buggy code: content would have '\n' inserted.
536        // With the FIX: content stays as one line, wrapping is visual-only.
537
538        let mut content = String::new();
539
540        // Simulate typing 80 characters
541        for i in 0..80 {
542            content.push(char::from_u32('a' as u32 + (i % 26)).unwrap());
543        }
544
545        // Content should be exactly 80 chars, NO newlines
546        assert_eq!(content.len(), 80);
547        assert_eq!(content.chars().filter(|&c| c == '\n').count(), 0);
548
549        // Even when wrapped at narrow width, content is unchanged
550        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        // Content still has no newlines
558        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        // CJK characters are 2 columns wide.
567        // This proves we wrap by DISPLAY width, not byte/char count.
568        let content = "中文中文中文"; // 6 CJK chars = 12 display columns
569
570        // Width 6 should fit 3 CJK chars per line
571        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        // Grapheme clusters (like emoji with modifiers) should not be split
580        let content = "hello 👨‍👩‍👧‍👦 world"; // Family emoji is one grapheme
581
582        // Even at narrow width, the emoji stays together
583        let wrapped = soft_wrap_line(content, 10);
584
585        // Find which line has the emoji
586        let emoji_line = wrapped.iter().find(|l| l.text.contains('👨')).unwrap();
587
588        // The full emoji cluster should be intact
589        assert!(emoji_line.text.contains("👨‍👩‍👧‍👦"));
590    }
591
592    // ==========================================================================
593    // Character wrap specific tests
594    // ==========================================================================
595
596    #[test]
597    fn test_character_wrap_typing_at_edge() {
598        // Simulates: user types "here we are on a thin scre" (26 chars)
599        // then types "e" - the "e" should flow to next line
600        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        // Before: fits on one line
607        assert_eq!(wrapped_before.len(), 1);
608        assert_eq!(wrapped_before[0].text, before);
609
610        // After: flows to two lines
611        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        // Type "en" after "scre" - both chars should be on line 2
619        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        // Content that wraps at width 20, but fits at width 40
630        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        // At 20: wraps to 2 lines
636        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        // At 40: fits on 1 line
641        assert_eq!(at_40.len(), 1);
642        assert_eq!(at_40[0].text, content);
643
644        // Content unchanged in both cases
645        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        // At 26: fits on 1 line
660        assert_eq!(at_26.len(), 1);
661
662        // At 13: wraps to 2 lines
663        assert_eq!(at_13.len(), 2);
664        assert_eq!(at_13[0].text, "abcdefghijklm");
665        assert_eq!(at_13[1].text, "nopqrstuvwxyz");
666
667        // At 5: wraps to 6 lines
668        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        // Text exactly matches width - should not wrap
676        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        // Text is exactly one char over - that char wraps
686        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        // Spaces should be preserved, not eaten by wrap
697        let content = "ab cd ef";
698        let wrapped = soft_wrap_line(content, 4);
699
700        // "ab c" | "d ef" - spaces preserved
701        assert_eq!(wrapped.len(), 2);
702        assert_eq!(wrapped[0].text, "ab c");
703        assert_eq!(wrapped[1].text, "d ef");
704
705        // Reconstruction gives original
706        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        // Line 0: "abcd" graphemes 0-4
716        assert_eq!(wrapped[0].grapheme_start, 0);
717        assert_eq!(wrapped[0].grapheme_end, 4);
718
719        // Line 1: "efgh" graphemes 4-8
720        assert_eq!(wrapped[1].grapheme_start, 4);
721        assert_eq!(wrapped[1].grapheme_end, 8);
722
723        // Line 2: "ij" graphemes 8-10
724        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        // "abcdefgh" wrapped at width 5 = "abcde" + "fgh"
731        // Cursor at grapheme 6 (the 'g') should be at (1, 1) - col 1, row 1
732        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        // Cursor at exactly the wrap point
742        let content = "abcdefgh";
743
744        // Cursor at grapheme 5 (the 'f') - first char of second line
745        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        // Simulate resize: 40 -> 20 -> 40 -> 10 -> 40
753        // Content should always reconstruct to original
754        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        // User's test case: emojis at boundary
770        // Each emoji is 2 display columns wide
771        let base = "a b c d e dakl asdl d fox fox badger😊😊😊 😊😊";
772        let base_width = display_width(base);
773
774        // Test wrapping at exactly the base width (should fit on one line)
775        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        // Now add one more emoji (2 cols) - should wrap
783        let with_extra = "a b c d e dakl asdl d fox fox badger😊😊😊 😊😊😊";
784
785        // Wrap at original base_width - the extra emoji should go to line 2
786        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        // Content integrity: reconstruction should match original
795        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        // Test when a 2-wide character (emoji) would overflow by just 1 column
802        // Width 46 means the last emoji (width=2) at position 45-46 doesn't fit
803
804        let text = "a b c d e dakl asdl d fox fox badger😊😊😊 😊😊";
805        // Display width = 47
806        // Position 45 starts the last 😊 (which needs cols 45-46)
807
808        // At width 47: fits exactly
809        let at_47 = soft_wrap_line(text, 47);
810        assert_eq!(at_47.len(), 1);
811
812        // At width 46: emoji at position 45 would need cols 45-46, but we only have 0-45
813        // So the last emoji should wrap to line 2
814        let at_46 = soft_wrap_line(text, 46);
815        assert_eq!(at_46.len(), 2);
816        assert_eq!(at_46[0].width, 45); // "a b c d e dakl asdl d fox fox badger😊😊😊 😊" = 45
817        assert_eq!(at_46[1].text, "😊");
818
819        // At width 45: now the second-to-last emoji also doesn't fit
820        let at_45 = soft_wrap_line(text, 45);
821        assert_eq!(at_45.len(), 2);
822
823        // Content always preserved
824        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}