Skip to main content

forge_terminal/
screen.rs

1//! The screen as text: what the agent reads, and what a reattaching client is
2//! repainted from.
3//!
4//! **Plain text, never raw escapes.** A tool result carries the characters a
5//! person would see, with trailing blanks trimmed; the client gets the same
6//! grid through the repaint in [`crate::repaint`], not through this.
7
8use alacritty_terminal::event::EventListener;
9use alacritty_terminal::grid::{Dimensions, Grid};
10use alacritty_terminal::index::Line;
11use alacritty_terminal::term::Term;
12use alacritty_terminal::term::cell::{Cell, Flags};
13
14/// Where the cursor sits on the visible screen.
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub struct Cursor {
17    pub col: u16,
18    pub row: u16,
19}
20
21/// The visible screen as text, with the cursor and the size.
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct Screen {
24    pub text: String,
25    pub cursor: Cursor,
26    pub cols: u16,
27    pub rows: u16,
28}
29
30/// One bounded read of the scrollback and the screen.
31#[derive(Clone, Debug, PartialEq, Eq)]
32pub struct ReadResult {
33    pub text: String,
34    /// The cursor to pass as `since` on the next read.
35    pub cursor: u64,
36    pub cols: u16,
37    pub rows: u16,
38    pub truncated: bool,
39}
40
41/// The visible screen, as text.
42pub fn extract<T: EventListener>(term: &Term<T>) -> Screen {
43    let grid = term.grid();
44    let rows = grid.screen_lines();
45    let cols = grid.columns();
46    let mut lines = Vec::with_capacity(rows);
47    for row in 0..rows {
48        lines.push(line_text(grid, Line(i32::try_from(row).unwrap_or(i32::MAX))));
49    }
50    let point = grid.cursor.point;
51    let cursor = Cursor {
52        col: u16::try_from(point.column.0).unwrap_or(u16::MAX),
53        row: u16::try_from(
54            point.line.0.clamp(0, i32::try_from(rows.saturating_sub(1)).unwrap_or(i32::MAX)),
55        )
56        .unwrap_or(0),
57    };
58    Screen {
59        text: lines.join("\n"),
60        cursor,
61        cols: u16::try_from(cols).unwrap_or(u16::MAX),
62        rows: u16::try_from(rows).unwrap_or(u16::MAX),
63    }
64}
65
66/// A bounded read of the buffer from `since`, or the tail when `since` is
67/// absent or older than the buffer.
68///
69/// **The cursor is a lifetime line number, not a buffer position.** The
70/// scrollback is bounded, so the buffer's own count stops advancing once it
71/// fills; `appended` is how many lines have ever entered it, and the buffer's
72/// first line is `appended - total`. A caller that falls further behind than
73/// the buffer gets the tail and `truncated` says so.
74///
75/// A read that reaches the buffer's last line hands back that line's index,
76/// so the next read repeats it: the last line can still change while a
77/// program is writing.
78pub fn read<T: EventListener>(
79    term: &Term<T>,
80    appended: u64,
81    since: Option<u64>,
82    max_lines: usize,
83    max_bytes: usize,
84) -> ReadResult {
85    let grid = term.grid();
86    let top = grid.topmost_line().0;
87    let bottom = grid.bottommost_line().0;
88    let total = u64::try_from(bottom - top + 1).unwrap_or(0);
89    let first = appended.saturating_sub(total);
90    let tail = total.saturating_sub(u64::try_from(max_lines).unwrap_or(u64::MAX));
91    // A cursor inside the buffer reads forward; a cursor the buffer has
92    // already dropped means the caller fell behind, and the tail is what it
93    // gets, reported as truncated. A cursor at the lifetime end is caught up.
94    let (start, mut truncated) = match since {
95        Some(since) if since >= first => (since - first, false),
96        Some(_) => (0, true),
97        None => (tail, tail > 0),
98    };
99    let start = start.min(total);
100    let mut lines = Vec::new();
101    let mut bytes = 0usize;
102    for index in start..total {
103        let Ok(offset) = i32::try_from(index) else { break };
104        let text = line_text(grid, Line(top + offset));
105        let len = text.len() + 1;
106        if lines.len() >= max_lines || bytes + len > max_bytes {
107            truncated = true;
108            break;
109        }
110        bytes += len;
111        lines.push(text);
112    }
113    let end = start.saturating_add(u64::try_from(lines.len()).unwrap_or(u64::MAX));
114    let cursor = if end >= total && total > 0 { appended.saturating_sub(1) } else { first + end };
115    ReadResult {
116        text: lines.join("\n"),
117        cursor,
118        cols: u16::try_from(grid.columns()).unwrap_or(u16::MAX),
119        rows: u16::try_from(grid.screen_lines()).unwrap_or(u16::MAX),
120        truncated,
121    }
122}
123
124/// The dimensions a resize drives the grid with.
125struct GridSize {
126    cols: usize,
127    rows: usize,
128}
129
130impl Dimensions for GridSize {
131    fn total_lines(&self) -> usize {
132        self.rows
133    }
134    fn screen_lines(&self) -> usize {
135        self.rows
136    }
137    fn columns(&self) -> usize {
138        self.cols
139    }
140}
141
142/// Resize the grid, moving the lifetime read cursor with the reflow.
143///
144/// **A reflow is not output.** The grid's line count changes without a byte
145/// arriving, so a cursor read against `appended - total` would slide to a
146/// different line; moving the lifetime count by the same amount keeps the
147/// buffer's first line where the cursor was measured against it.
148pub(crate) fn resize<T: EventListener>(
149    term: &mut Term<T>,
150    cols: usize,
151    rows: usize,
152    appended: &mut u64,
153) {
154    let before = term.grid().total_lines();
155    term.resize(GridSize { cols, rows });
156    let after = term.grid().total_lines();
157    if after >= before {
158        *appended = appended.saturating_add(u64::try_from(after - before).unwrap_or(u64::MAX));
159    } else {
160        *appended = appended.saturating_sub(u64::try_from(before - after).unwrap_or(u64::MAX));
161    }
162}
163
164/// One grid line as text, trailing blanks trimmed.
165fn line_text(grid: &Grid<Cell>, line: Line) -> String {
166    let row = &grid[line];
167    let mut text = String::with_capacity(row.len());
168    for cell in row {
169        if cell.flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) {
170            continue;
171        }
172        text.push(cell.c);
173    }
174    let trimmed = text.trim_end();
175    trimmed.to_owned()
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use alacritty_terminal::event::VoidListener;
182    use alacritty_terminal::term::Config;
183    use alacritty_terminal::vte::ansi::Processor;
184
185    struct Size {
186        cols: usize,
187        rows: usize,
188    }
189
190    impl alacritty_terminal::grid::Dimensions for Size {
191        fn total_lines(&self) -> usize {
192            self.rows
193        }
194        fn screen_lines(&self) -> usize {
195            self.rows
196        }
197        fn columns(&self) -> usize {
198            self.cols
199        }
200    }
201
202    fn term_with(bytes: &[u8], cols: usize, rows: usize) -> Term<VoidListener> {
203        driven(bytes, cols, rows, 100).0
204    }
205
206    /// A term fed `bytes`, and the lifetime line count a read cursor is
207    /// measured against.
208    fn driven(bytes: &[u8], cols: usize, rows: usize, history: usize) -> (Term<VoidListener>, u64) {
209        let mut term = Term::new(
210            Config { scrolling_history: history, ..Default::default() },
211            &Size { cols, rows },
212            VoidListener,
213        );
214        let mut appended = u64::try_from(term.grid().total_lines()).unwrap_or(0);
215        let mut parser: Processor = Processor::new();
216        crate::parser::advance(&mut parser, &mut term, bytes, history, &mut appended);
217        (term, appended)
218    }
219
220    fn feed(
221        term: &mut Term<VoidListener>,
222        parser: &mut Processor,
223        bytes: &[u8],
224        history: usize,
225        appended: &mut u64,
226    ) {
227        crate::parser::advance(parser, term, bytes, history, appended);
228    }
229
230    /// The extractor returns the characters a person sees, with trailing
231    /// blanks trimmed and the cursor where the program left it.
232    #[test]
233    fn the_screen_is_the_text_a_person_sees() {
234        let term = term_with(b"hello\r\nworld", 20, 5);
235        let screen = extract(&term);
236        assert_eq!(screen.text.lines().next(), Some("hello"));
237        assert_eq!(screen.text.lines().nth(1), Some("world"));
238        assert_eq!(screen.cursor, Cursor { col: 5, row: 1 });
239        assert_eq!((screen.cols, screen.rows), (20, 5));
240    }
241
242    /// A read from a cursor returns only the lines after it, and the cursor
243    /// moves to the end.
244    #[test]
245    fn a_read_from_a_cursor_returns_the_new_lines() {
246        let (term, appended) = driven(b"one\r\ntwo\r\nthree", 20, 5, 100);
247        let all = read(&term, appended, None, 200, 32 * 1024);
248        assert!(all.text.contains("one"));
249        assert!(all.text.contains("three"));
250        let after = read(&term, appended, Some(all.cursor), 200, 32 * 1024);
251        assert!(after.text.is_empty(), "nothing new after the cursor: {:?}", after.text);
252        assert!(!after.truncated, "caught up is not truncated");
253    }
254
255    /// The bounds are honoured and `truncated` says so, so a read never
256    /// floods the model.
257    #[test]
258    fn a_read_is_bounded_and_says_when_it_truncated() {
259        let (term, appended) = driven(b"a\r\nb\r\nc\r\nd\r\ne", 20, 5, 100);
260        let bounded = read(&term, appended, None, 2, 32 * 1024);
261        assert!(bounded.truncated, "two lines of five is truncated");
262        assert_eq!(bounded.text.lines().count(), 2);
263    }
264
265    /// A bound that cuts a read leaves the cursor on the last line it
266    /// returned, so the lines it did not return are still reachable.
267    #[test]
268    fn a_bounded_read_leaves_the_cursor_at_the_last_line_it_returned() {
269        let (term, appended) = driven(b"a\r\nb\r\nc\r\nd\r\ne", 20, 5, 100);
270        let first = read(&term, appended, Some(0), 2, 32 * 1024);
271        assert_eq!(first.text.lines().collect::<Vec<_>>(), ["a", "b"]);
272        assert!(first.truncated, "the bound cut the read");
273        let next = read(&term, appended, Some(first.cursor), 10, 32 * 1024);
274        assert_eq!(next.text.lines().collect::<Vec<_>>(), ["c", "d", "e"]);
275        assert!(!next.truncated, "the second read reached the end");
276    }
277
278    /// Once the scrollback is full the buffer's line count stops growing;
279    /// the cursor is a lifetime line number, so it keeps advancing and a
280    /// read still returns what was written after it.
281    #[test]
282    fn a_read_cursor_advances_after_the_scrollback_is_full() {
283        let mut term = Term::new(
284            Config { scrolling_history: 2, ..Default::default() },
285            &Size { cols: 20, rows: 2 },
286            VoidListener,
287        );
288        let mut appended = u64::try_from(term.grid().total_lines()).unwrap_or(0);
289        let mut parser: Processor = Processor::new();
290        feed(&mut term, &mut parser, b"1\r\n2\r\n3\r\n4\r\n5\r\n6\r\n7\r\n8\r\n", 2, &mut appended);
291        let caught = read(&term, appended, None, 10, 32 * 1024);
292        assert_eq!(caught.text.lines().collect::<Vec<_>>(), ["6", "7", "8"]);
293        let bound = caught.cursor;
294        feed(&mut term, &mut parser, b"9\r\n10\r\n", 2, &mut appended);
295        let next = read(&term, appended, Some(bound), 10, 32 * 1024);
296        assert_eq!(next.text.lines().collect::<Vec<_>>(), ["9", "10"]);
297        assert!(!next.truncated, "nothing fell out of the buffer behind the cursor");
298    }
299
300    /// **A wrapped line reflows, and its characters survive.** A terminal
301    /// narrowed under a running program re-lays the line rather than
302    /// truncating it, which is what a person's own terminal does.
303    #[test]
304    fn a_wrapped_line_reflows_when_the_terminal_narrows() {
305        let mut term = Term::new(
306            Config { scrolling_history: 100, ..Default::default() },
307            &Size { cols: 20, rows: 12 },
308            VoidListener,
309        );
310        let mut appended = 12u64;
311        let mut parser: Processor = Processor::new();
312        feed(
313            &mut term,
314            &mut parser,
315            b"\x1b[32mok\x1b[0m aaaaaaaaaaaaaaaaaaaaaaaa\r\n\x1b[1mbold\x1b[0m done",
316            100,
317            &mut appended,
318        );
319        let wide = read(&term, 10_000, None, 500, 1 << 20);
320        let wide_flat: String = wide.text.chars().filter(|c| !c.is_whitespace()).collect();
321        assert!(wide_flat.contains("okaaaaaaaaaaaaaaaaaaaaaaaa"), "{:?}", wide.text);
322        term.resize(Size { cols: 10, rows: 12 });
323        // The buffer, not the viewport: a reflow can move a row into the
324        // scrollback, and the characters are what must survive.
325        let narrow = read(&term, 10_000, None, 500, 1 << 20);
326        let flat: String = narrow.text.chars().filter(|c| !c.is_whitespace()).collect();
327        assert!(
328            flat.contains("okaaaaaaaaaaaaaaaaaaaaaaaa"),
329            "the wrapped run survives the reflow: {:?}",
330            narrow.text
331        );
332        assert!(flat.contains("bolddone"), "{:?}", narrow.text);
333    }
334
335    /// **A reflow does not move the read window.** A cursor is a lifetime
336    /// line number read against `appended - total`; the resize changes the
337    /// grid's line count without output, so the count moves with it and the
338    /// buffer's first line stays where the cursor was measured against it.
339    #[test]
340    fn a_reflow_keeps_the_read_windows_first_line() {
341        let mut term = Term::new(
342            Config { scrolling_history: 100, ..Default::default() },
343            &Size { cols: 10, rows: 4 },
344            VoidListener,
345        );
346        let mut appended = u64::try_from(term.grid().total_lines()).unwrap_or(0);
347        let mut parser: Processor = Processor::new();
348        feed(
349            &mut term,
350            &mut parser,
351            b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\r\n",
352            100,
353            &mut appended,
354        );
355        let first = appended.saturating_sub(u64::try_from(term.grid().total_lines()).unwrap_or(0));
356        // Widening reflows the wrapped line into one row: the grid shrinks
357        // without a byte arriving.
358        resize(&mut term, 40, 4, &mut appended);
359        let after = appended.saturating_sub(u64::try_from(term.grid().total_lines()).unwrap_or(0));
360        assert_eq!(after, first, "the reflow moved the buffer's first line under the cursor");
361    }
362
363    /// A wide character occupies one cell and is extracted once, not as its
364    /// spacer.
365    #[test]
366    fn a_wide_character_is_extracted_once() {
367        let term = term_with("日本".as_bytes(), 20, 5);
368        let screen = extract(&term);
369        assert_eq!(screen.text.lines().next(), Some("日本"));
370    }
371}