agcodex_tui/
live_wrap.rs

1use unicode_width::UnicodeWidthChar;
2use unicode_width::UnicodeWidthStr;
3
4/// A single visual row produced by RowBuilder.
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct Row {
7    pub text: String,
8    /// True if this row ends with an explicit line break (as opposed to a hard wrap).
9    pub explicit_break: bool,
10}
11
12impl Row {
13    pub fn width(&self) -> usize {
14        self.text.width()
15    }
16}
17
18/// Incrementally wraps input text into visual rows of at most `width` cells.
19///
20/// Step 1: plain-text only. ANSI-carry and styled spans will be added later.
21pub struct RowBuilder {
22    target_width: usize,
23    /// Buffer for the current logical line (until a '\n' is seen).
24    current_line: String,
25    /// Output rows built so far for the current logical line and previous ones.
26    rows: Vec<Row>,
27}
28
29impl RowBuilder {
30    pub fn new(target_width: usize) -> Self {
31        Self {
32            target_width: target_width.max(1),
33            current_line: String::new(),
34            rows: Vec::new(),
35        }
36    }
37
38    pub const fn width(&self) -> usize {
39        self.target_width
40    }
41
42    pub fn set_width(&mut self, width: usize) {
43        self.target_width = width.max(1);
44        // Rewrap everything we have (simple approach for Step 1).
45        let mut all = String::new();
46        for row in self.rows.drain(..) {
47            all.push_str(&row.text);
48            if row.explicit_break {
49                all.push('\n');
50            }
51        }
52        all.push_str(&self.current_line);
53        self.current_line.clear();
54        self.push_fragment(&all);
55    }
56
57    /// Push an input fragment. May contain newlines.
58    pub fn push_fragment(&mut self, fragment: &str) {
59        if fragment.is_empty() {
60            return;
61        }
62        let mut start = 0usize;
63        for (i, ch) in fragment.char_indices() {
64            if ch == '\n' {
65                // Flush anything pending before the newline.
66                if start < i {
67                    self.current_line.push_str(&fragment[start..i]);
68                }
69                self.flush_current_line(true);
70                start = i + ch.len_utf8();
71            }
72        }
73        if start < fragment.len() {
74            self.current_line.push_str(&fragment[start..]);
75            self.wrap_current_line();
76        }
77    }
78
79    /// Mark the end of the current logical line (equivalent to pushing a '\n').
80    pub fn end_line(&mut self) {
81        self.flush_current_line(true);
82    }
83
84    /// Drain and return all produced rows.
85    pub fn drain_rows(&mut self) -> Vec<Row> {
86        std::mem::take(&mut self.rows)
87    }
88
89    /// Return a snapshot of produced rows (non-draining).
90    pub fn rows(&self) -> &[Row] {
91        &self.rows
92    }
93
94    /// Rows suitable for display, including the current partial line if any.
95    pub fn display_rows(&self) -> Vec<Row> {
96        let mut out = self.rows.clone();
97        if !self.current_line.is_empty() {
98            out.push(Row {
99                text: self.current_line.clone(),
100                explicit_break: false,
101            });
102        }
103        out
104    }
105
106    /// Drain the oldest rows that exceed `max_keep` display rows (including the
107    /// current partial line, if any). Returns the drained rows in order.
108    pub fn drain_commit_ready(&mut self, max_keep: usize) -> Vec<Row> {
109        let display_count = self.rows.len() + if self.current_line.is_empty() { 0 } else { 1 };
110        if display_count <= max_keep {
111            return Vec::new();
112        }
113        let to_commit = display_count - max_keep;
114        let commit_count = to_commit.min(self.rows.len());
115        let mut drained = Vec::with_capacity(commit_count);
116        for _ in 0..commit_count {
117            drained.push(self.rows.remove(0));
118        }
119        drained
120    }
121
122    fn flush_current_line(&mut self, explicit_break: bool) {
123        // Wrap any remaining content in the current line and then finalize with explicit_break.
124        self.wrap_current_line();
125        // If the current line ended exactly on a width boundary and is non-empty, represent
126        // the explicit break as an empty explicit row so that fragmentation invariance holds.
127        if explicit_break {
128            if self.current_line.is_empty() {
129                // We ended on a boundary previously; add an empty explicit row.
130                self.rows.push(Row {
131                    text: String::new(),
132                    explicit_break: true,
133                });
134            } else {
135                // There is leftover content that did not wrap yet; push it now with the explicit flag.
136                let mut s = String::new();
137                std::mem::swap(&mut s, &mut self.current_line);
138                self.rows.push(Row {
139                    text: s,
140                    explicit_break: true,
141                });
142            }
143        }
144        // Reset current line buffer for next logical line.
145        self.current_line.clear();
146    }
147
148    fn wrap_current_line(&mut self) {
149        // While the current_line exceeds width, cut a prefix.
150        loop {
151            if self.current_line.is_empty() {
152                break;
153            }
154            let (prefix, suffix, taken) =
155                take_prefix_by_width(&self.current_line, self.target_width);
156            if taken == 0 {
157                // Avoid infinite loop on pathological inputs; take one scalar and continue.
158                if let Some((i, ch)) = self.current_line.char_indices().next() {
159                    let len = i + ch.len_utf8();
160                    let p = self.current_line[..len].to_string();
161                    self.rows.push(Row {
162                        text: p,
163                        explicit_break: false,
164                    });
165                    self.current_line = self.current_line[len..].to_string();
166                    continue;
167                }
168                break;
169            }
170            if suffix.is_empty() {
171                // Fits entirely; keep in buffer (do not push yet) so we can append more later.
172                break;
173            } else {
174                // Emit wrapped prefix as a non-explicit row and continue with the remainder.
175                self.rows.push(Row {
176                    text: prefix,
177                    explicit_break: false,
178                });
179                self.current_line = suffix.to_string();
180            }
181        }
182    }
183}
184
185/// Take a prefix of `text` whose visible width is at most `max_cols`.
186/// Returns (prefix, suffix, prefix_width).
187pub fn take_prefix_by_width(text: &str, max_cols: usize) -> (String, &str, usize) {
188    if max_cols == 0 || text.is_empty() {
189        return (String::new(), text, 0);
190    }
191    let mut cols = 0usize;
192    let mut end_idx = 0usize;
193    for (i, ch) in text.char_indices() {
194        let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
195        if cols.saturating_add(ch_width) > max_cols {
196            break;
197        }
198        cols += ch_width;
199        end_idx = i + ch.len_utf8();
200        if cols == max_cols {
201            break;
202        }
203    }
204    let prefix = text[..end_idx].to_string();
205    let suffix = &text[end_idx..];
206    (prefix, suffix, cols)
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use pretty_assertions::assert_eq;
213
214    #[test]
215    fn rows_do_not_exceed_width_ascii() {
216        let mut rb = RowBuilder::new(10);
217        rb.push_fragment("hello whirl this is a test");
218        let rows = rb.rows().to_vec();
219        assert_eq!(
220            rows,
221            vec![
222                Row {
223                    text: "hello whir".to_string(),
224                    explicit_break: false
225                },
226                Row {
227                    text: "l this is ".to_string(),
228                    explicit_break: false
229                }
230            ]
231        );
232    }
233
234    #[test]
235    fn rows_do_not_exceed_width_emoji_cjk() {
236        // 😀 is width 2; 你/好 are width 2.
237        let mut rb = RowBuilder::new(6);
238        rb.push_fragment("😀😀 你好");
239        let rows = rb.rows().to_vec();
240        // At width 6, we expect the first row to fit exactly two emojis and a space
241        // (2 + 2 + 1 = 5) plus one more column for the first CJK char (2 would overflow),
242        // so only the two emojis and the space fit; the rest remains buffered.
243        assert_eq!(
244            rows,
245            vec![Row {
246                text: "😀😀 ".to_string(),
247                explicit_break: false
248            }]
249        );
250    }
251
252    #[test]
253    fn fragmentation_invariance_long_token() {
254        let s = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; // 26 chars
255        let mut rb_all = RowBuilder::new(7);
256        rb_all.push_fragment(s);
257        let all_rows = rb_all.rows().to_vec();
258
259        let mut rb_chunks = RowBuilder::new(7);
260        for i in (0..s.len()).step_by(3) {
261            let end = (i + 3).min(s.len());
262            rb_chunks.push_fragment(&s[i..end]);
263        }
264        let chunk_rows = rb_chunks.rows().to_vec();
265
266        assert_eq!(all_rows, chunk_rows);
267    }
268
269    #[test]
270    fn newline_splits_rows() {
271        let mut rb = RowBuilder::new(10);
272        rb.push_fragment("hello\nworld");
273        let rows = rb.display_rows();
274        assert!(rows.iter().any(|r| r.explicit_break));
275        assert_eq!(rows[0].text, "hello");
276        // Second row should begin with 'world'
277        assert!(rows.iter().any(|r| r.text.starts_with("world")));
278    }
279
280    #[test]
281    fn rewrap_on_width_change() {
282        let mut rb = RowBuilder::new(10);
283        rb.push_fragment("abcdefghijK");
284        assert!(!rb.rows().is_empty());
285        rb.set_width(5);
286        for r in rb.rows() {
287            assert!(r.width() <= 5);
288        }
289    }
290}