Skip to main content

koda_cli/
wrap_util.rs

1//! Shared word-wrap line counting.
2//!
3//! Single source of truth for the word-boundary wrapping algorithm
4//! used by both `scroll_buffer` (history panel) and `wrap_input`
5//! (input area). Must match ratatui's `Wrap { trim: false }` behavior.
6//!
7//! Extracted from duplicated implementations (#527).
8
9use unicode_width::UnicodeWidthChar;
10
11/// Compute how many visual lines a text string occupies at a given width.
12///
13/// Uses word-boundary wrapping consistent with ratatui's
14/// `Paragraph::wrap(Wrap { trim: false })`. When a word would overflow
15/// the current row, it breaks *before* the word.
16///
17/// For words longer than the terminal width, force-breaks mid-word.
18///
19/// # Examples
20///
21/// ```ignore
22/// use koda_cli::wrap_util::visual_line_count;
23///
24/// // Short line — fits in one row
25/// assert_eq!(visual_line_count("hello world", 80), 1);
26///
27/// // Empty string counts as one row (the cursor still occupies a cell)
28/// assert_eq!(visual_line_count("", 80), 1);
29///
30/// // 160 identical chars at width 80 = 2 rows
31/// assert_eq!(visual_line_count(&"x".repeat(160), 80), 2);
32///
33/// // Word-wrap: the second word is wrapped to the next row
34/// // because 75 + 1 + 75 = 151 > 80
35/// let s = format!("{} {}", "a".repeat(75), "b".repeat(75));
36/// assert_eq!(visual_line_count(&s, 80), 2);
37/// ```
38pub fn visual_line_count(text: &str, width: usize) -> usize {
39    if text.is_empty() {
40        return 1;
41    }
42    let w = width.max(1);
43    let mut rows = 1usize;
44    let mut col = 0usize;
45    let mut word_start_col = 0usize;
46    let mut in_word = false;
47
48    for ch in text.chars() {
49        let char_w = ch.width().unwrap_or(0);
50        let is_space = ch == ' ' || ch == '\t';
51
52        if is_space {
53            in_word = false;
54            if col + char_w > w {
55                rows += 1;
56                col = char_w;
57            } else {
58                col += char_w;
59            }
60            word_start_col = col;
61        } else {
62            if !in_word {
63                word_start_col = col;
64                in_word = true;
65            }
66            if col + char_w > w {
67                if word_start_col > 0 && word_start_col <= w {
68                    // Word doesn't fit but row had prior content:
69                    // wrap *before* this word.
70                    rows += 1;
71                    let word_len_so_far = col - word_start_col;
72                    col = word_len_so_far + char_w;
73                    word_start_col = 0;
74                } else {
75                    // Word at column 0 (longer than width): force-break.
76                    rows += 1;
77                    col = char_w;
78                    word_start_col = 0;
79                }
80            } else {
81                col += char_w;
82            }
83        }
84    }
85    rows
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn short_line() {
94        assert_eq!(visual_line_count("hello", 80), 1);
95    }
96
97    #[test]
98    fn empty_line() {
99        assert_eq!(visual_line_count("", 80), 1);
100    }
101
102    #[test]
103    fn char_wrap() {
104        assert_eq!(visual_line_count(&"x".repeat(160), 80), 2);
105    }
106
107    #[test]
108    fn word_wrap() {
109        let line = format!("{} {}", "a".repeat(75), "b".repeat(75));
110        assert_eq!(visual_line_count(&line, 80), 2);
111    }
112
113    #[test]
114    fn word_longer_than_width() {
115        assert_eq!(visual_line_count(&"x".repeat(200), 80), 3);
116    }
117
118    #[test]
119    fn exact_width() {
120        assert_eq!(visual_line_count(&"x".repeat(80), 80), 1);
121    }
122
123    #[test]
124    fn exact_width_plus_one() {
125        assert_eq!(visual_line_count(&"x".repeat(81), 80), 2);
126    }
127
128    #[test]
129    fn word_wrap_breaks_before_word() {
130        let text = format!("{}  foobar", "a".repeat(76));
131        assert_eq!(visual_line_count(&text, 80), 2);
132    }
133}