Skip to main content

term_ui/
wrap.rs

1//! Row counting for the input `TextArea`.
2//!
3//! `ratatui_textarea::TextArea` does not expose how many on-screen rows its content occupies, so
4//! counting them requires the very wrapping algorithm the widget renders with. The functions in
5//! this module are a simplified copy of `wrap.rs` from `ratatui-textarea` v0.9.2 (MIT), dropping
6//! the line-number gutter and tab-stop width logic, and the actual range handling (we only need a
7//! number of lines as the result).
8
9use ratatui_textarea::WrapMode;
10use unicode_segmentation::UnicodeSegmentation;
11use unicode_width::UnicodeWidthChar;
12
13/// How many lines need to be rendered for the given `mode`, at least 1.
14#[must_use]
15pub(crate) fn wrapped_line_count(line: &str, mode: WrapMode, width: usize) -> usize {
16    let width = width.max(1);
17    match mode {
18        WrapMode::None => 1,
19        WrapMode::Glyph => split_range_by_grapheme_width(line, 0, line.len(), width),
20        WrapMode::Word => wrap_word_chunks(line, width, false),
21        WrapMode::WordOrGlyph => wrap_word_chunks(line, width, true),
22    }
23    .max(1)
24}
25
26/// Wraps the given line at word boundaries, falling back to grapheme splitting for words wider
27/// than the width when enabled.
28fn wrap_word_chunks(line: &str, width: usize, fallback_to_glyph: bool) -> usize {
29    let mut line_count = 0;
30    let mut seg_start = 0;
31    let mut seg_end = 0;
32    let mut seg_width = 0usize;
33
34    for (start, text) in UnicodeSegmentation::split_word_bound_indices(line) {
35        let end = start + text.len();
36
37        if seg_end == seg_start {
38            seg_start = start;
39        }
40
41        let chunk_width = display_width(text);
42        if seg_width + chunk_width <= width {
43            seg_end = end;
44            seg_width += chunk_width;
45            continue;
46        }
47
48        if seg_end > seg_start {
49            line_count += 1;
50            seg_start = start;
51            seg_width = 0;
52
53            if chunk_width <= width {
54                seg_end = end;
55                seg_width += chunk_width;
56                continue;
57            }
58        }
59
60        if fallback_to_glyph {
61            line_count += split_range_by_grapheme_width(line, start, end, width);
62        } else {
63            line_count += 1;
64        }
65
66        seg_start = end;
67        seg_end = end;
68        seg_width = 0;
69    }
70
71    if seg_end > seg_start {
72        line_count += 1;
73    }
74
75    line_count
76}
77
78/// Wraps the given byte range of a line at grapheme boundaries, accounting for wide characters.
79fn split_range_by_grapheme_width(line: &str, start: usize, end: usize, width: usize) -> usize {
80    let mut line_count = 0;
81
82    let mut segment_start = start;
83    while segment_start < end {
84        let mut segment_end = segment_start;
85        let mut segment_width = 0usize;
86
87        for (offset, grapheme) in
88            UnicodeSegmentation::grapheme_indices(&line[segment_start..end], true)
89        {
90            let grapheme_start = segment_start + offset;
91            let grapheme_end = grapheme_start + grapheme.len();
92            let grapheme_width = display_width(grapheme);
93
94            if segment_end != segment_start && segment_width + grapheme_width > width {
95                break;
96            }
97
98            segment_end = grapheme_end;
99            segment_width += grapheme_width;
100            if segment_width > width {
101                break;
102            }
103        }
104
105        if segment_end == segment_start {
106            if let Some(ch) = line[segment_start..end].chars().next() {
107                segment_end = segment_start + ch.len_utf8();
108            } else {
109                break;
110            }
111        }
112
113        segment_start = segment_end;
114
115        line_count += 1;
116    }
117
118    line_count
119}
120
121/// Total display width of the given text.
122#[inline]
123fn display_width(text: &str) -> usize {
124    text.chars().map(|c| c.width().unwrap_or(0)).sum()
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn trailing_space_rolls_onto_its_own_row() {
133        assert_eq!(wrapped_line_count("foo ", WrapMode::Word, 3), 2);
134        assert_eq!(wrapped_line_count("foo ", WrapMode::WordOrGlyph, 3), 2);
135        assert_eq!(wrapped_line_count("foo ", WrapMode::Glyph, 3), 2);
136    }
137
138    #[test]
139    fn internal_space_runs_consume_width() {
140        assert_eq!(wrapped_line_count("a  b", WrapMode::Word, 2), 3);
141    }
142
143    #[test]
144    fn empty_line_is_one_row() {
145        assert_eq!(wrapped_line_count("", WrapMode::Word, 10), 1);
146    }
147
148    #[test]
149    fn word_wrap_keeps_long_word() {
150        assert_eq!(wrapped_line_count("helloworld", WrapMode::Word, 4), 1);
151    }
152
153    #[test]
154    fn word_or_glyph_wrap_splits_long_word() {
155        assert_eq!(
156            wrapped_line_count("helloworld", WrapMode::WordOrGlyph, 4),
157            3
158        );
159    }
160
161    #[test]
162    fn glyph_wrap_handles_wide_chars() {
163        assert_eq!(wrapped_line_count("ab犬猫", WrapMode::Glyph, 4), 2);
164    }
165
166    #[test]
167    fn glyph_wrap_keeps_combining_grapheme_cluster() {
168        assert_eq!(wrapped_line_count("e\u{301}x", WrapMode::Glyph, 1), 2);
169    }
170
171    #[test]
172    fn glyph_wrap_preserves_full_mixed_width_row_capacity() {
173        assert_eq!(wrapped_line_count("a中bcde", WrapMode::Glyph, 4), 2);
174    }
175}