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}