Skip to main content

rtcom_tui/
serial_pane.rs

1//! Serial data pane backed by a vt100 terminal emulator.
2//!
3//! Bytes from the serial port are fed into [`vt100::Parser`] which
4//! maintains a 2D cell grid with full ANSI support (colour, cursor
5//! movement, scroll regions, and so on). Later tasks render that
6//! grid into the ratatui main view; T7 stops at "state is well-kept".
7
8/// Terminal-emulator-backed pane for serial data.
9///
10/// Wraps a [`vt100::Parser`] with a fixed scrollback budget.
11/// Use [`SerialPane::ingest`] to feed newly received bytes;
12/// [`SerialPane::screen`] exposes the current grid for rendering.
13pub struct SerialPane {
14    parser: vt100::Parser,
15    scrollback_rows: usize,
16}
17
18impl SerialPane {
19    /// Default scrollback capacity applied by [`SerialPane::new`].
20    ///
21    /// 10,000 rows is enough for ~minutes of typical embedded debug
22    /// output and keeps peak memory bounded (cell grid at 80 cols
23    /// ≈ 80 bytes × `10_000` ≈ 800 KiB).
24    pub const DEFAULT_SCROLLBACK_ROWS: usize = 10_000;
25
26    /// Build a pane with the default scrollback capacity.
27    #[must_use]
28    pub fn new(rows: u16, cols: u16) -> Self {
29        Self::with_scrollback(rows, cols, Self::DEFAULT_SCROLLBACK_ROWS)
30    }
31
32    /// Build a pane with an explicit scrollback capacity.
33    #[must_use]
34    pub fn with_scrollback(rows: u16, cols: u16, scrollback_rows: usize) -> Self {
35        Self {
36            parser: vt100::Parser::new(rows, cols, scrollback_rows),
37            scrollback_rows,
38        }
39    }
40
41    /// Feed bytes into the terminal emulator.
42    ///
43    /// Safe to call with any arbitrary byte stream — invalid escape
44    /// sequences are dropped by vt100.
45    pub fn ingest(&mut self, bytes: &[u8]) {
46        self.parser.process(bytes);
47    }
48
49    /// Reference to the current vt100 [`Screen`](vt100::Screen).
50    ///
51    /// The screen is mutable internally but exposed as `&` so callers
52    /// can only read it; ingestion must go through [`SerialPane::ingest`].
53    #[must_use]
54    pub fn screen(&self) -> &vt100::Screen {
55        self.parser.screen()
56    }
57
58    /// Resize the emulator grid.
59    ///
60    /// vt100 reflows accordingly — lines longer than the new width
61    /// wrap; scrollback is preserved as-is.
62    pub fn resize(&mut self, rows: u16, cols: u16) {
63        self.parser.screen_mut().set_size(rows, cols);
64    }
65
66    /// Scrollback row count configured for this pane.
67    #[must_use]
68    pub const fn scrollback_rows(&self) -> usize {
69        self.scrollback_rows
70    }
71
72    /// Current scrollback offset from the live tail (0 = live).
73    ///
74    /// A non-zero value means the rendered view is above the live
75    /// tail by that many rows; new bytes continue to accumulate in
76    /// the buffer but do not scroll the view until the user scrolls
77    /// back down via [`SerialPane::scroll_down`] /
78    /// [`SerialPane::scroll_to_bottom`].
79    #[must_use]
80    pub fn scrollback_offset(&self) -> usize {
81        self.parser.screen().scrollback()
82    }
83
84    /// True when the view is above the live tail.
85    ///
86    /// Consumers (e.g. the top-bar renderer) use this as the "should
87    /// I show the `[SCROLL ↑N]` indicator?" predicate.
88    #[must_use]
89    pub fn is_scrolled(&self) -> bool {
90        self.scrollback_offset() > 0
91    }
92
93    /// Scroll up by `lines` (toward older content).
94    ///
95    /// Clamped to the configured scrollback capacity so extreme input
96    /// values (e.g. `usize::MAX`) do not overflow. vt100 also clamps
97    /// internally to the *actual* amount of scrollback accumulated so
98    /// far, so requesting more than exists simply lands at "top of
99    /// history".
100    pub fn scroll_up(&mut self, lines: usize) {
101        let target = self
102            .scrollback_offset()
103            .saturating_add(lines)
104            .min(self.scrollback_rows);
105        self.parser.screen_mut().set_scrollback(target);
106    }
107
108    /// Scroll down by `lines` (toward newer content / the live tail).
109    ///
110    /// Saturates at 0 (live tail); calling with a huge value is
111    /// equivalent to [`SerialPane::scroll_to_bottom`].
112    pub fn scroll_down(&mut self, lines: usize) {
113        let target = self.scrollback_offset().saturating_sub(lines);
114        self.parser.screen_mut().set_scrollback(target);
115    }
116
117    /// Jump to the oldest row retained in the scrollback buffer.
118    ///
119    /// Requests the configured scrollback capacity; vt100 internally
120    /// clamps to however much history actually exists.
121    pub fn scroll_to_top(&mut self) {
122        self.parser
123            .screen_mut()
124            .set_scrollback(self.scrollback_rows);
125    }
126
127    /// Jump back to the live tail (`scrollback_offset == 0`).
128    pub fn scroll_to_bottom(&mut self) {
129        self.parser.screen_mut().set_scrollback(0);
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn serial_pane_ingests_bytes_into_vt100() {
139        let mut pane = SerialPane::new(24, 80);
140        pane.ingest(b"hello\r\nworld");
141        let screen = pane.screen();
142        assert_eq!(screen.cell(0, 0).unwrap().contents(), "h");
143        assert_eq!(screen.cell(1, 0).unwrap().contents(), "w");
144    }
145
146    #[test]
147    fn serial_pane_resize_updates_size() {
148        let mut pane = SerialPane::new(24, 80);
149        for _ in 0..30 {
150            pane.ingest(b"line\r\n");
151        }
152        pane.resize(40, 80);
153        assert_eq!(pane.screen().size(), (40, 80));
154    }
155
156    #[test]
157    fn serial_pane_default_scrollback_is_ten_thousand() {
158        let _pane = SerialPane::new(24, 80);
159        // Implementation detail — make it a public const we can verify.
160        assert_eq!(SerialPane::DEFAULT_SCROLLBACK_ROWS, 10_000);
161    }
162
163    #[test]
164    fn serial_pane_custom_scrollback() {
165        let pane = SerialPane::with_scrollback(24, 80, 500);
166        // Just ensure construction with custom scrollback succeeds.
167        // Actual scrollback semantics are exercised by vt100 itself.
168        assert_eq!(pane.scrollback_rows(), 500);
169    }
170
171    #[test]
172    fn scroll_up_increments_offset() {
173        let mut pane = SerialPane::new(24, 80);
174        // Need enough content to build scrollback — ingest > 24 rows.
175        for i in 0..40 {
176            pane.ingest(format!("row {i}\r\n").as_bytes());
177        }
178        assert_eq!(pane.scrollback_offset(), 0);
179        assert!(!pane.is_scrolled());
180        pane.scroll_up(5);
181        assert_eq!(pane.scrollback_offset(), 5);
182        assert!(pane.is_scrolled());
183    }
184
185    #[test]
186    fn scroll_down_decrements_offset() {
187        let mut pane = SerialPane::new(24, 80);
188        for i in 0..40 {
189            pane.ingest(format!("row {i}\r\n").as_bytes());
190        }
191        pane.scroll_up(10);
192        pane.scroll_down(4);
193        assert_eq!(pane.scrollback_offset(), 6);
194    }
195
196    #[test]
197    fn scroll_to_bottom_resets_offset() {
198        let mut pane = SerialPane::new(24, 80);
199        for i in 0..40 {
200            pane.ingest(format!("row {i}\r\n").as_bytes());
201        }
202        pane.scroll_up(15);
203        assert!(pane.is_scrolled());
204        pane.scroll_to_bottom();
205        assert_eq!(pane.scrollback_offset(), 0);
206        assert!(!pane.is_scrolled());
207    }
208
209    #[test]
210    fn scroll_up_clamps_to_scrollback_capacity() {
211        let mut pane = SerialPane::new(24, 80);
212        for _ in 0..5 {
213            pane.ingest(b"x\r\n");
214        }
215        // Request a massive scroll — the API must not overflow and
216        // must not exceed the configured scrollback capacity.
217        pane.scroll_up(usize::MAX / 2);
218        assert!(pane.scrollback_offset() <= SerialPane::DEFAULT_SCROLLBACK_ROWS);
219    }
220
221    #[test]
222    fn scroll_down_saturates_at_zero() {
223        let mut pane = SerialPane::new(24, 80);
224        for i in 0..40 {
225            pane.ingest(format!("row {i}\r\n").as_bytes());
226        }
227        // Not scrolled: scroll_down from 0 must stay at 0.
228        pane.scroll_down(100);
229        assert_eq!(pane.scrollback_offset(), 0);
230    }
231
232    #[test]
233    fn scroll_to_top_jumps_to_oldest() {
234        let mut pane = SerialPane::new(24, 80);
235        for i in 0..40 {
236            pane.ingest(format!("row {i}\r\n").as_bytes());
237        }
238        pane.scroll_to_top();
239        // vt100 clamps to the actual scrollback length, which is at
240        // most (total_rows - visible_rows) = 40 - 24 = 16. We only
241        // assert that we moved up and that we haven't exceeded the
242        // configured capacity.
243        assert!(pane.is_scrolled());
244        assert!(pane.scrollback_offset() <= SerialPane::DEFAULT_SCROLLBACK_ROWS);
245    }
246
247    #[test]
248    fn serial_pane_ingest_handles_ansi_escape_sequences() {
249        let mut pane = SerialPane::new(24, 80);
250        // Red foreground, then 'X', then reset
251        pane.ingest(b"\x1b[31mX\x1b[0m");
252        let cell = pane.screen().cell(0, 0).unwrap();
253        assert_eq!(cell.contents(), "X");
254        // vt100 should have captured the red fg
255        let fgcolor = cell.fgcolor();
256        // vt100::Color is an enum; red maps to Color::Idx(1) in the
257        // ANSI palette. Check "not default".
258        assert!(
259            !matches!(fgcolor, vt100::Color::Default),
260            "expected coloured fg, got {fgcolor:?}",
261        );
262    }
263}