Skip to main content

turbo_debug_console/
streamview.rs

1// Copyright (c) 2026 Enzo Lombardi
2// SPDX-License-Identifier: MIT
3
4//! A scrollback view over styled cells, one `Vec<Cell>` per line.
5
6use turbo_vision::core::draw::{Cell, DrawBuffer};
7use turbo_vision::core::event::{
8    Event, EventType, KB_DOWN, KB_END, KB_HOME, KB_PGDN, KB_PGUP, KB_UP,
9};
10use turbo_vision::core::geometry::Rect;
11use turbo_vision::core::palette::{Attr, TvColor};
12use turbo_vision::terminal::Terminal;
13use turbo_vision::views::view::{View, write_line_to_terminal};
14
15/// Default scrollback depth.
16pub const DEFAULT_MAX_LINES: usize = 10_000;
17
18/// A scrollback of styled lines, with autoscroll that releases when the user
19/// scrolls back and re-arms at the bottom.
20#[derive(Debug)]
21pub struct StreamView {
22    bounds: Rect,
23    /// Completed lines, oldest first.
24    lines: Vec<Vec<Cell>>,
25    /// The line currently streaming in, not yet terminated by a newline.
26    partial: Option<Vec<Cell>>,
27    max_lines: usize,
28    /// Index of the topmost displayed line.
29    top: usize,
30    /// Horizontal scroll offset, in cells.
31    left: usize,
32    /// True while the view follows the tail.
33    follow: bool,
34    fill: Attr,
35}
36
37impl StreamView {
38    #[must_use]
39    pub fn new(bounds: Rect) -> Self {
40        Self {
41            bounds,
42            lines: Vec::new(),
43            partial: None,
44            max_lines: DEFAULT_MAX_LINES,
45            top: 0,
46            left: 0,
47            follow: true,
48            fill: Attr::new(TvColor::LightGray, TvColor::Black),
49        }
50    }
51
52    pub fn set_max_lines(&mut self, n: usize) {
53        self.max_lines = n.max(1);
54        self.trim();
55    }
56
57    /// Appends a completed line.
58    pub fn push_line(&mut self, cells: Vec<Cell>) {
59        self.lines.push(cells);
60        self.trim();
61        if self.follow {
62            self.scroll_to_bottom();
63        }
64    }
65
66    /// Replaces the in-progress line. Called on every repaint while a line is
67    /// still streaming, so it must overwrite rather than append.
68    pub fn set_partial(&mut self, cells: Vec<Cell>) {
69        self.partial = if cells.is_empty() { None } else { Some(cells) };
70        if self.follow {
71            self.scroll_to_bottom();
72        }
73    }
74
75    pub fn clear(&mut self) {
76        self.lines.clear();
77        self.partial = None;
78        self.top = 0;
79        self.left = 0;
80        self.follow = true;
81    }
82
83    /// Total displayed lines, including the in-progress one.
84    #[must_use]
85    pub fn line_count(&self) -> usize {
86        self.lines.len() + usize::from(self.partial.is_some())
87    }
88
89    /// Visible rows, i.e. the view height.
90    fn page(&self) -> usize {
91        usize::try_from(self.bounds.height()).unwrap_or(0).max(1)
92    }
93
94    fn max_top(&self) -> usize {
95        self.line_count().saturating_sub(self.page())
96    }
97
98    pub fn scroll_to_bottom(&mut self) {
99        self.top = self.max_top();
100        self.follow = true;
101    }
102
103    pub fn scroll_to_top(&mut self) {
104        self.top = 0;
105        self.follow = false;
106    }
107
108    pub fn scroll_up(&mut self, n: usize) {
109        self.top = self.top.saturating_sub(n);
110        self.follow = false;
111    }
112
113    pub fn scroll_down(&mut self, n: usize) {
114        self.top = (self.top + n).min(self.max_top());
115        self.follow = self.top == self.max_top();
116    }
117
118    #[must_use]
119    pub fn is_at_bottom(&self) -> bool {
120        self.follow
121    }
122
123    /// The whole scrollback with attributes stripped, for File > Save As.
124    #[must_use]
125    pub fn plain_text(&self) -> String {
126        let mut out = String::new();
127        for (i, line) in self.iter_lines().enumerate() {
128            if i > 0 {
129                out.push('\n');
130            }
131            out.extend(line.iter().map(|c| c.ch));
132        }
133        out
134    }
135
136    /// The whole scrollback with attributes intact, for tests and golden files.
137    #[must_use]
138    pub fn styled_lines(&self) -> Vec<Vec<Cell>> {
139        self.iter_lines().cloned().collect()
140    }
141
142    fn iter_lines(&self) -> impl Iterator<Item = &Vec<Cell>> {
143        self.lines.iter().chain(self.partial.iter())
144    }
145
146    fn trim(&mut self) {
147        if self.lines.len() > self.max_lines {
148            let drop = self.lines.len() - self.max_lines;
149            self.lines.drain(..drop);
150            self.top = self.top.saturating_sub(drop);
151        }
152    }
153}
154
155impl View for StreamView {
156    fn bounds(&self) -> Rect {
157        self.bounds
158    }
159
160    fn set_bounds(&mut self, bounds: Rect) {
161        self.bounds = bounds;
162        if self.follow {
163            self.scroll_to_bottom();
164        } else {
165            self.top = self.top.min(self.max_top());
166        }
167    }
168
169    fn draw(&mut self, terminal: &mut Terminal) {
170        if self.bounds.height() <= 0 {
171            return;
172        }
173        let width = usize::try_from(self.bounds.width()).unwrap_or(0);
174        let page = self.page();
175        let lines: Vec<&Vec<Cell>> = self.iter_lines().skip(self.top).take(page).collect();
176
177        for row in 0..page {
178            let mut buf = DrawBuffer::new(width);
179            for i in 0..width {
180                buf.put_char(i, ' ', self.fill);
181            }
182            if let Some(line) = lines.get(row) {
183                for (i, cell) in line.iter().skip(self.left).take(width).enumerate() {
184                    buf.put_char(i, cell.ch, cell.attr);
185                }
186            }
187            let y = self.bounds.a.y + i16::try_from(row).unwrap_or(i16::MAX);
188            write_line_to_terminal(terminal, self.bounds.a.x, y, &buf);
189        }
190    }
191
192    fn handle_event(&mut self, event: &mut Event) {
193        if event.what != EventType::Keyboard {
194            return;
195        }
196        let page = self.page();
197        match event.key_code {
198            KB_UP => self.scroll_up(1),
199            KB_DOWN => self.scroll_down(1),
200            KB_PGUP => self.scroll_up(page),
201            KB_PGDN => self.scroll_down(page),
202            KB_HOME => self.scroll_to_top(),
203            KB_END => self.scroll_to_bottom(),
204            _ => return,
205        }
206        event.clear();
207    }
208
209    fn can_focus(&self) -> bool {
210        true
211    }
212
213    fn get_palette(&self) -> Option<turbo_vision::core::palette::Palette> {
214        // Cells already carry resolved `Attr`s (from `AnsiLineAssembler`), so
215        // there is no logical-color index for a palette to remap.
216        None
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use std::io;
224    use std::time::Duration;
225    use turbo_vision::core::palette::TvColor;
226    use turbo_vision::terminal::Backend;
227
228    fn line(s: &str) -> Vec<Cell> {
229        s.chars()
230            .map(|c| Cell::new(c, Attr::new(TvColor::LightGray, TvColor::Black)))
231            .collect()
232    }
233
234    fn view() -> StreamView {
235        StreamView::new(Rect::new(0, 0, 40, 10))
236    }
237
238    /// An in-memory `Backend` for tests: no real TTY, fixed size, no I/O.
239    /// `Terminal::write_line`/`write_cell` write straight into `Terminal`'s
240    /// own in-memory buffer, so this stub only needs to satisfy
241    /// initialization and size queries for `Terminal::with_backend`.
242    struct FakeBackend {
243        width: u16,
244        height: u16,
245    }
246
247    impl Backend for FakeBackend {
248        fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
249            self
250        }
251
252        fn init(&mut self) -> io::Result<()> {
253            Ok(())
254        }
255
256        fn cleanup(&mut self) -> io::Result<()> {
257            Ok(())
258        }
259
260        fn size(&self) -> io::Result<(u16, u16)> {
261            Ok((self.width, self.height))
262        }
263
264        fn poll_event(&mut self, _timeout: Duration) -> io::Result<Option<Event>> {
265            Ok(None)
266        }
267
268        fn write_raw(&mut self, _data: &[u8]) -> io::Result<()> {
269            Ok(())
270        }
271
272        fn flush(&mut self) -> io::Result<()> {
273            Ok(())
274        }
275
276        fn show_cursor(&mut self, _x: u16, _y: u16) -> io::Result<()> {
277            Ok(())
278        }
279
280        fn hide_cursor(&mut self) -> io::Result<()> {
281            Ok(())
282        }
283    }
284
285    fn fake_terminal(width: u16, height: u16) -> Terminal {
286        Terminal::with_backend(Box::new(FakeBackend { width, height }))
287            .expect("fake backend never fails to init")
288    }
289
290    #[test]
291    fn scrollback_cap_drops_oldest_lines() {
292        let mut v = view();
293        v.set_max_lines(3);
294        for i in 0..5 {
295            v.push_line(line(&i.to_string()));
296        }
297        assert_eq!(v.line_count(), 3);
298        assert_eq!(v.plain_text(), "2\n3\n4");
299    }
300
301    #[test]
302    fn autoscroll_holds_at_bottom_while_lines_arrive() {
303        let mut v = view();
304        for i in 0..50 {
305            v.push_line(line(&i.to_string()));
306        }
307        assert!(v.is_at_bottom());
308    }
309
310    #[test]
311    fn scrolling_up_releases_autoscroll_and_end_rearms_it() {
312        let mut v = view();
313        for i in 0..50 {
314            v.push_line(line(&i.to_string()));
315        }
316        v.scroll_up(5);
317        assert!(!v.is_at_bottom());
318        v.push_line(line("new"));
319        assert!(
320            !v.is_at_bottom(),
321            "a new line must not yank a scrolled-back reader to the bottom"
322        );
323        v.scroll_to_bottom();
324        assert!(v.is_at_bottom());
325    }
326
327    #[test]
328    fn partial_line_is_replaced_not_appended() {
329        let mut v = view();
330        v.set_partial(line("par"));
331        v.set_partial(line("part"));
332        assert_eq!(v.plain_text(), "part");
333        assert_eq!(v.line_count(), 1);
334    }
335
336    #[test]
337    fn plain_text_strips_attributes() {
338        let mut v = view();
339        v.push_line(vec![Cell::new(
340            'x',
341            Attr::new(TvColor::LightRed, TvColor::Blue),
342        )]);
343        assert_eq!(v.plain_text(), "x");
344    }
345
346    #[test]
347    fn resize_larger_while_scrolled_back_reclamps_top_to_show_a_full_page() {
348        let mut v = StreamView::new(Rect::new(0, 0, 40, 5));
349        for i in 0..50 {
350            v.push_line(line(&i.to_string()));
351        }
352        // Scroll back so `top` sits well below the current max_top()
353        // (line_count 50, page 5 -> max_top 45).
354        v.scroll_to_top();
355        v.scroll_down(40);
356        assert!(!v.is_at_bottom());
357        let old_top = v.top;
358        assert!(old_top < v.max_top());
359
360        // Grow the view a lot: max_top() shrinks to line_count - new_page
361        // (50 - 48 = 2), which is now well below the old `top` (40). Left
362        // unclamped, that would leave blank rows at the bottom of the
363        // viewport even though unshown history sits above.
364        v.set_bounds(Rect::new(0, 0, 40, 48));
365
366        assert!(
367            v.top <= v.max_top(),
368            "top ({}) must not exceed max_top ({}) after growing",
369            v.top,
370            v.max_top()
371        );
372        let lines: Vec<&Vec<Cell>> = v.iter_lines().skip(v.top).take(v.page()).collect();
373        assert_eq!(
374            lines.len(),
375            v.page().min(v.line_count()),
376            "a full page of content should be visible after growing"
377        );
378    }
379
380    #[test]
381    fn draw_clips_to_bounds_and_applies_horizontal_offset() {
382        let mut v = StreamView::new(Rect::new(2, 1, 8, 4));
383        v.push_line(line("abcdefghij")); // longer than the 6-wide view
384        v.push_line(line("short")); // shorter than the 6-wide view
385        v.left = 2; // horizontal scroll offset
386
387        let mut terminal = fake_terminal(20, 10);
388        v.draw(&mut terminal);
389
390        // Row 0 (bounds.a.y == 1): "abcdefghij" skipped by `left` 2, then
391        // clipped to width 6, drawn starting at bounds.a.x == 2.
392        for (i, expected) in "cdefgh".chars().enumerate() {
393            let cell = terminal
394                .read_cell(2 + i16::try_from(i).unwrap_or(i16::MAX), 1)
395                .expect("cell within terminal bounds");
396            assert_eq!(cell.ch, expected);
397        }
398        // Nothing is drawn past the view's width (x == 8 is out of bounds).
399        assert_eq!(terminal.read_cell(8, 1).unwrap().ch, ' ');
400
401        // Row 1 (bounds.a.y == 2): "short" skipped by `left` 2 -> "ort",
402        // padded with the fill space for the remaining 3 columns.
403        for (i, expected) in "ort   ".chars().enumerate() {
404            let cell = terminal
405                .read_cell(2 + i16::try_from(i).unwrap_or(i16::MAX), 2)
406                .expect("cell within terminal bounds");
407            assert_eq!(cell.ch, expected);
408        }
409
410        // Nothing above or below the view's rows was touched.
411        assert_eq!(terminal.read_cell(2, 0).unwrap().ch, ' ');
412    }
413
414    #[test]
415    fn draw_on_zero_height_view_writes_nothing() {
416        let mut v = StreamView::new(Rect::new(0, 0, 10, 0));
417        v.push_line(line("hello"));
418        let mut terminal = fake_terminal(20, 10);
419        v.draw(&mut terminal);
420        for y in 0..10 {
421            for x in 0..20 {
422                assert_eq!(
423                    terminal.read_cell(x, y).unwrap().ch,
424                    ' ',
425                    "zero-height view must not write any cell"
426                );
427            }
428        }
429    }
430}