Skip to main content

saorsa_core/
wrap.rs

1//! Soft-wrap logic for splitting logical lines into visual lines.
2//!
3//! Provides functions for wrapping text to a given display width,
4//! accounting for double-width characters (CJK, emoji). Used by
5//! [`crate::widget::TextArea`] to render wrapped text with line numbers.
6
7use crate::text_buffer::TextBuffer;
8use unicode_width::UnicodeWidthChar;
9
10/// A single visual line resulting from soft-wrapping a logical line.
11#[derive(Clone, Debug, PartialEq, Eq)]
12pub struct WrapLine {
13    /// The text content of this visual line.
14    pub text: String,
15    /// The index of the logical line this visual line belongs to.
16    pub logical_line: usize,
17    /// The character offset within the logical line where this visual
18    /// line starts.
19    pub start_col: usize,
20}
21
22/// The result of wrapping an entire buffer.
23#[derive(Clone, Debug)]
24pub struct WrapResult {
25    /// All visual lines in display order.
26    pub lines: Vec<WrapLine>,
27    /// The width in characters needed for line number display.
28    pub line_number_width: u16,
29}
30
31/// Wrap a single line of text to the given display width.
32///
33/// Returns a list of `(text, start_col)` pairs. The text for each
34/// visual line and the character offset within the original line.
35///
36/// The algorithm:
37/// 1. Break at word boundaries (whitespace) when possible.
38/// 2. Fall back to character boundary for words longer than `width`.
39/// 3. Respects display width (CJK = 2, emoji = 2).
40/// 4. Never splits multi-byte characters.
41pub fn wrap_line(text: &str, width: usize) -> Vec<(String, usize)> {
42    if width == 0 {
43        return vec![(text.to_string(), 0)];
44    }
45
46    if text.is_empty() {
47        return vec![(String::new(), 0)];
48    }
49
50    let mut result = Vec::new();
51    let mut current_line = String::new();
52    let mut current_width: usize = 0;
53    let mut line_start_col: usize = 0;
54
55    for (char_col, ch) in text.chars().enumerate() {
56        let ch_width = ch.width().unwrap_or(0);
57
58        if current_width + ch_width > width && !current_line.is_empty() {
59            // Need to wrap — try to find a word boundary to break at
60            if let Some(space_byte_idx) = find_last_space(&current_line) {
61                // Break at the last space
62                let before: String = current_line[..space_byte_idx].to_string();
63                let after: String = current_line[space_byte_idx..].trim_start().to_string();
64                let before_char_count = before.chars().count();
65                result.push((before, line_start_col));
66                current_width = display_width_of(&after);
67                line_start_col +=
68                    before_char_count + count_trimmed_spaces(&current_line[space_byte_idx..]);
69                current_line = after;
70            } else {
71                // No space found — break at character boundary
72                result.push((current_line.clone(), line_start_col));
73                line_start_col = char_col;
74                current_line = String::new();
75                current_width = 0;
76            }
77        }
78
79        current_line.push(ch);
80        current_width += ch_width;
81    }
82
83    if !current_line.is_empty() || result.is_empty() {
84        result.push((current_line, line_start_col));
85    }
86
87    result
88}
89
90/// Wrap all lines of a text buffer to the given display width.
91pub fn wrap_lines(buffer: &TextBuffer, width: usize) -> WrapResult {
92    let total_lines = buffer.line_count();
93    let mut lines = Vec::new();
94
95    for line_idx in 0..total_lines {
96        if let Some(line_text) = buffer.line(line_idx) {
97            let wrapped = wrap_line(&line_text, width);
98            for (text, start_col) in wrapped {
99                lines.push(WrapLine {
100                    text,
101                    logical_line: line_idx,
102                    start_col,
103                });
104            }
105        }
106    }
107
108    let lnw = line_number_width(total_lines);
109    WrapResult {
110        lines,
111        line_number_width: lnw,
112    }
113}
114
115/// Calculate the width in characters needed for line number display.
116///
117/// Returns the number of digits needed to display the largest line
118/// number (1-based).
119pub fn line_number_width(line_count: usize) -> u16 {
120    if line_count == 0 {
121        return 1;
122    }
123    let digits = (line_count as f64).log10().floor() as u16 + 1;
124    digits.max(1)
125}
126
127/// Calculate the display width of a string.
128fn display_width_of(text: &str) -> usize {
129    text.chars().map(|c| c.width().unwrap_or(0)).sum()
130}
131
132/// Find the byte index of the last space character in a string.
133fn find_last_space(text: &str) -> Option<usize> {
134    text.rfind(' ')
135}
136
137/// Count spaces at the start of a string (for trimming calculation).
138fn count_trimmed_spaces(text: &str) -> usize {
139    text.chars().take_while(|c| *c == ' ').count()
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    // --- wrap_line ---
147
148    #[test]
149    fn short_line_no_wrap() {
150        let result = wrap_line("hello", 20);
151        assert!(result.len() == 1);
152        assert!(result[0].0 == "hello");
153        assert!(result[0].1 == 0);
154    }
155
156    #[test]
157    fn exact_width_no_wrap() {
158        let result = wrap_line("12345", 5);
159        assert!(result.len() == 1);
160        assert!(result[0].0 == "12345");
161    }
162
163    #[test]
164    fn overflow_by_one_char() {
165        let result = wrap_line("123456", 5);
166        assert!(result.len() == 2);
167    }
168
169    #[test]
170    fn word_wrap() {
171        let result = wrap_line("hello world foo", 12);
172        assert!(result.len() == 2);
173        assert!(result[0].0 == "hello world");
174        assert!(result[1].0 == "foo");
175    }
176
177    #[test]
178    fn long_word_break() {
179        let result = wrap_line("abcdefghij", 5);
180        assert!(result.len() == 2);
181        assert!(result[0].0 == "abcde");
182        assert!(result[1].0 == "fghij");
183    }
184
185    #[test]
186    fn cjk_characters_width_2() {
187        // Each CJK char is 2 cells wide
188        let result = wrap_line("日本語テスト", 6);
189        // 日(2)+本(2)+語(2) = 6, テ(2)+ス(2)+ト(2) = 6
190        assert!(result.len() == 2);
191        assert!(result[0].0 == "日本語");
192        assert!(result[1].0 == "テスト");
193    }
194
195    #[test]
196    fn mixed_content() {
197        let result = wrap_line("abc日本", 5);
198        // a(1)+b(1)+c(1)+日(2) = 5
199        assert!(result.len() == 2);
200        assert!(result[0].0 == "abc日");
201        // 本(2) fits in 5
202        assert!(result[1].0 == "本");
203    }
204
205    #[test]
206    fn empty_line() {
207        let result = wrap_line("", 10);
208        assert!(result.len() == 1);
209        assert!(result[0].0.is_empty());
210    }
211
212    #[test]
213    fn single_char_line() {
214        let result = wrap_line("x", 10);
215        assert!(result.len() == 1);
216        assert!(result[0].0 == "x");
217    }
218
219    // --- line_number_width ---
220
221    #[test]
222    fn line_number_width_small() {
223        assert!(line_number_width(1) == 1);
224        assert!(line_number_width(9) == 1);
225    }
226
227    #[test]
228    fn line_number_width_medium() {
229        assert!(line_number_width(10) == 2);
230        assert!(line_number_width(99) == 2);
231        assert!(line_number_width(100) == 3);
232    }
233
234    #[test]
235    fn line_number_width_zero() {
236        assert!(line_number_width(0) == 1);
237    }
238
239    // --- wrap_lines (buffer) ---
240
241    #[test]
242    fn wrap_buffer_multiline() {
243        let buf = TextBuffer::from_text("short\nthis is a longer line");
244        let result = wrap_lines(&buf, 10);
245        // "short" → 1 visual line
246        // "this is a longer line" → wraps
247        assert!(result.lines.len() >= 3);
248        assert!(result.lines[0].logical_line == 0);
249        assert!(result.lines[1].logical_line == 1);
250    }
251
252    #[test]
253    fn wrap_result_line_number_width() {
254        let buf = TextBuffer::from_text("a\nb\nc\nd\ne\nf\ng\nh\ni\nj");
255        let result = wrap_lines(&buf, 80);
256        assert!(result.line_number_width == 2); // 10 lines → 2 digits
257    }
258}