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.
18pub fn visual_line_count(text: &str, width: usize) -> usize {
19    if text.is_empty() {
20        return 1;
21    }
22    let w = width.max(1);
23    let mut rows = 1usize;
24    let mut col = 0usize;
25    let mut word_start_col = 0usize;
26    let mut in_word = false;
27
28    for ch in text.chars() {
29        let char_w = ch.width().unwrap_or(0);
30        let is_space = ch == ' ' || ch == '\t';
31
32        if is_space {
33            in_word = false;
34            if col + char_w > w {
35                rows += 1;
36                col = char_w;
37            } else {
38                col += char_w;
39            }
40            word_start_col = col;
41        } else {
42            if !in_word {
43                word_start_col = col;
44                in_word = true;
45            }
46            if col + char_w > w {
47                if word_start_col > 0 && word_start_col <= w {
48                    // Word doesn't fit but row had prior content:
49                    // wrap *before* this word.
50                    rows += 1;
51                    let word_len_so_far = col - word_start_col;
52                    col = word_len_so_far + char_w;
53                    word_start_col = 0;
54                } else {
55                    // Word at column 0 (longer than width): force-break.
56                    rows += 1;
57                    col = char_w;
58                    word_start_col = 0;
59                }
60            } else {
61                col += char_w;
62            }
63        }
64    }
65    rows
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71
72    #[test]
73    fn short_line() {
74        assert_eq!(visual_line_count("hello", 80), 1);
75    }
76
77    #[test]
78    fn empty_line() {
79        assert_eq!(visual_line_count("", 80), 1);
80    }
81
82    #[test]
83    fn char_wrap() {
84        assert_eq!(visual_line_count(&"x".repeat(160), 80), 2);
85    }
86
87    #[test]
88    fn word_wrap() {
89        let line = format!("{} {}", "a".repeat(75), "b".repeat(75));
90        assert_eq!(visual_line_count(&line, 80), 2);
91    }
92
93    #[test]
94    fn word_longer_than_width() {
95        assert_eq!(visual_line_count(&"x".repeat(200), 80), 3);
96    }
97
98    #[test]
99    fn exact_width() {
100        assert_eq!(visual_line_count(&"x".repeat(80), 80), 1);
101    }
102
103    #[test]
104    fn exact_width_plus_one() {
105        assert_eq!(visual_line_count(&"x".repeat(81), 80), 2);
106    }
107
108    #[test]
109    fn word_wrap_breaks_before_word() {
110        let text = format!("{}  foobar", "a".repeat(76));
111        assert_eq!(visual_line_count(&text, 80), 2);
112    }
113}