Skip to main content

supercode_frontend_tui/foundation/
live_wrap.rs

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