Skip to main content

rtcom_tui/
app.rs

1//! Top-level TUI application object.
2
3use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
4use ratatui::{
5    layout::Rect,
6    style::{Color, Modifier, Style},
7    text::{Line, Span},
8    widgets::Paragraph,
9    Frame,
10};
11use rtcom_config::ModalStyle;
12use rtcom_core::{
13    command::{Command, CommandKeyParser, ParseOutput},
14    Event, EventBus, LineEndingConfig, ModemLineSnapshot, SerialConfig,
15};
16use tui_term::widget::PseudoTerminal;
17
18use crate::{
19    input::Dispatch,
20    layout::main_chrome,
21    menu::RootMenu,
22    modal::{DialogOutcome, ModalStack},
23    serial_pane::SerialPane,
24    toast::{render_toasts, ToastLevel, ToastQueue},
25};
26
27/// Owns the TUI render state and input dispatcher.
28///
29/// Tracks the serial data pane, the configuration-menu open/closed
30/// state, and a lightweight device summary shown on the top bar.
31/// Input handling lives in [`TuiApp::handle_key`], which routes
32/// keyboard events through an internal [`CommandKeyParser`] whenever
33/// the menu is closed.
34pub struct TuiApp {
35    bus: EventBus,
36    menu_open: bool,
37    serial_pane: SerialPane,
38    device_path: String,
39    config_summary: String,
40    parser: CommandKeyParser,
41    modal_stack: ModalStack,
42    /// Current serial-link configuration; seeded to
43    /// [`SerialConfig::default`] at construction and updated by
44    /// [`TuiApp::set_serial_config`]. Forwarded into new [`RootMenu`]
45    /// instances so sub-dialogs (starting with T12's
46    /// [`crate::menu::SerialPortSetupDialog`]) can display live values.
47    current_config: SerialConfig,
48    /// Current line-ending mapper configuration; seeded to
49    /// [`LineEndingConfig::default`] at construction and updated by
50    /// [`TuiApp::set_line_endings`]. Forwarded into new [`RootMenu`]
51    /// instances so the T13 [`crate::menu::LineEndingsDialog`] opens
52    /// with live values.
53    current_line_endings: LineEndingConfig,
54    /// Current DTR / RTS output-line snapshot as known to rtcom;
55    /// seeded to [`ModemLineSnapshot::default`] (both lines
56    /// de-asserted) at construction and updated by
57    /// [`TuiApp::set_modem_lines`]. Forwarded into new [`RootMenu`]
58    /// instances so the T14 [`crate::menu::ModemControlDialog`] opens
59    /// with live values.
60    current_modem: ModemLineSnapshot,
61    /// Current modal render style; seeded to [`ModalStyle::default`]
62    /// at construction and updated by
63    /// [`TuiApp::set_modal_style`]. Forwarded into new [`RootMenu`]
64    /// instances so the T15 [`crate::menu::ScreenOptionsDialog`] opens
65    /// with the live value.
66    current_modal_style: ModalStyle,
67    /// Queue of timed toast notifications. Populated by the runner's
68    /// bus-event handler for [`Event::ProfileSaved`] /
69    /// [`Event::ProfileLoadFailed`] / [`Event::Error`]. Rendered on
70    /// top of the main chrome + modal in [`TuiApp::render`] so
71    /// outcome messages are always visible.
72    toasts: ToastQueue,
73    /// Names of the CLI flags (e.g. `-b`, `-d`, `--omap/--imap/--emap`)
74    /// that overrode a profile value at startup. Seeded empty and set
75    /// by the binary's `main` via [`TuiApp::set_cli_overrides`]. The
76    /// list is forwarded into [`RootMenu`] and then
77    /// [`crate::menu::SerialPortSetupDialog`] so the dialog can render
78    /// a hint explaining why the on-screen values may not match the
79    /// saved profile.
80    current_cli_overrides: Vec<&'static str>,
81    /// Lines scrolled per mouse-wheel notch in the serial pane.
82    /// Seeded to 3 and overridden from the profile by the binary's
83    /// `main` via [`TuiApp::set_wheel_scroll_lines`]. Always at least
84    /// 1 so the wheel never becomes a no-op.
85    wheel_scroll_lines: u16,
86    /// Height in rows of the body area at the last render. Used by
87    /// [`TuiApp::handle_key`] to compute half-screen page scrolls.
88    /// Seeded to 24 so the first Shift+PageUp / Shift+PageDown press
89    /// before any render still has a sensible denominator.
90    body_rows: u16,
91}
92
93impl TuiApp {
94    /// Construct a new `TuiApp` bound to the given event bus.
95    ///
96    /// Starts with a `24x80` serial pane and a default [`SerialConfig`];
97    /// the pane is resized to the terminal body on every call to
98    /// [`TuiApp::render`], and the config is overwritten by
99    /// [`TuiApp::set_serial_config`] once the runner knows the real
100    /// link parameters.
101    #[must_use]
102    pub fn new(bus: EventBus) -> Self {
103        Self {
104            bus,
105            menu_open: false,
106            // 24x80 is a safe default; actual size is set on first render.
107            serial_pane: SerialPane::new(24, 80),
108            device_path: String::new(),
109            config_summary: String::new(),
110            parser: CommandKeyParser::default(),
111            modal_stack: ModalStack::new(),
112            current_config: SerialConfig::default(),
113            current_line_endings: LineEndingConfig::default(),
114            current_modem: ModemLineSnapshot::default(),
115            current_modal_style: ModalStyle::default(),
116            toasts: ToastQueue::new(),
117            current_cli_overrides: Vec::new(),
118            wheel_scroll_lines: 3,
119            body_rows: 24,
120        }
121    }
122
123    /// Override the mouse-wheel scroll speed (lines per notch).
124    ///
125    /// Values less than 1 are clamped to 1 so the wheel never turns
126    /// into a no-op — a wheel event that moves nothing visibly
127    /// suggests rtcom is broken even when the underlying config is
128    /// "intentionally disabled". Users who want to pin the view can
129    /// simply not scroll.
130    pub fn set_wheel_scroll_lines(&mut self, n: u16) {
131        self.wheel_scroll_lines = n.max(1);
132    }
133
134    /// Record which CLI flags overrode a profile value at startup.
135    ///
136    /// Each element is a short, user-facing flag label (`-b`, `-d`,
137    /// `--omap/--imap/--emap`, ...). The
138    /// [`crate::menu::SerialPortSetupDialog`] shows a "N field(s)
139    /// overridden by CLI" hint at the bottom when this list is
140    /// non-empty; empty disables the hint entirely.
141    pub fn set_cli_overrides(&mut self, fields: Vec<&'static str>) {
142        self.current_cli_overrides = fields;
143    }
144
145    /// Push a new toast onto the queue. Consumed by the runner's
146    /// bus-event handler for profile IO + error events.
147    pub fn push_toast(&mut self, message: impl Into<String>, level: ToastLevel) {
148        self.toasts.push(message, level);
149    }
150
151    /// Mutable access to the toast queue. Mainly used by tests and
152    /// the main-loop tick to advance expiration.
153    pub const fn toasts_mut(&mut self) -> &mut ToastQueue {
154        &mut self.toasts
155    }
156
157    /// Immutable borrow of the toast queue (read-only introspection).
158    #[must_use]
159    pub const fn toasts(&self) -> &ToastQueue {
160        &self.toasts
161    }
162
163    /// Update the cached [`SerialConfig`] that new [`RootMenu`] pushes
164    /// pass down to sub-dialogs.
165    ///
166    /// Call this whenever the live session's config changes (T17 wires
167    /// this into `Event::ConfigChanged`).
168    pub const fn set_serial_config(&mut self, cfg: SerialConfig) {
169        self.current_config = cfg;
170    }
171
172    /// Update the cached [`LineEndingConfig`] that new [`RootMenu`]
173    /// pushes pass down to the T13
174    /// [`crate::menu::LineEndingsDialog`].
175    ///
176    /// Call this whenever the live session's mapper configuration
177    /// changes (T17 wires this into the `ApplyLineEndingsLive` path).
178    pub const fn set_line_endings(&mut self, le: LineEndingConfig) {
179        self.current_line_endings = le;
180    }
181
182    /// Update the cached [`ModemLineSnapshot`] that new [`RootMenu`]
183    /// pushes pass down to the T14
184    /// [`crate::menu::ModemControlDialog`].
185    ///
186    /// Call this whenever the live session's modem output lines
187    /// change (T17 wires this into the `SetDtr` / `SetRts` paths).
188    pub const fn set_modem_lines(&mut self, snapshot: ModemLineSnapshot) {
189        self.current_modem = snapshot;
190    }
191
192    /// Update the cached [`ModalStyle`] that new [`RootMenu`] pushes
193    /// pass down to the T15 [`crate::menu::ScreenOptionsDialog`].
194    ///
195    /// Call this whenever the live modal-style preference changes
196    /// (T17 wires this into the `ApplyModalStyleLive` / `AndSave`
197    /// paths).
198    pub const fn set_modal_style(&mut self, style: ModalStyle) {
199        self.current_modal_style = style;
200    }
201
202    /// Whether the configuration menu is currently open.
203    #[must_use]
204    pub const fn is_menu_open(&self) -> bool {
205        self.menu_open
206    }
207
208    /// Update the device path + config summary shown on the top bar.
209    ///
210    /// Accepts any type convertible to `String` so call sites can pass
211    /// either borrowed or owned strings.
212    pub fn set_device_summary(
213        &mut self,
214        device_path: impl Into<String>,
215        config_summary: impl Into<String>,
216    ) {
217        self.device_path = device_path.into();
218        self.config_summary = config_summary.into();
219    }
220
221    /// Update just the config-summary portion of the top bar, leaving
222    /// the device path untouched.
223    ///
224    /// Used by the bus subscriber to refresh the status line after an
225    /// [`Event::ConfigChanged`] without having to know the device path.
226    pub fn set_config_summary(&mut self, config_summary: impl Into<String>) {
227        self.config_summary = config_summary.into();
228    }
229
230    /// Mutable access to the serial data pane.
231    ///
232    /// Primarily used by the serial-reader subscriber to ingest incoming
233    /// bytes; tests also use it to seed a known screen state.
234    pub const fn serial_pane_mut(&mut self) -> &mut SerialPane {
235        &mut self.serial_pane
236    }
237
238    /// Internal accessor for the bus (later tasks wire this in).
239    #[allow(dead_code)]
240    pub(crate) const fn bus(&self) -> &EventBus {
241        &self.bus
242    }
243
244    /// Route a key event.
245    ///
246    /// When the menu is closed, the event is converted to bytes via
247    /// [`crate::input::key_to_bytes`] and fed one byte at a time to
248    /// the internal [`CommandKeyParser`]:
249    ///
250    /// - [`ParseOutput::Data`] bytes accumulate into a
251    ///   [`Dispatch::TxBytes`] payload.
252    /// - [`Command::OpenMenu`] flips `menu_open`, pushes a
253    ///   [`RootMenu`] onto the modal stack, publishes
254    ///   [`Event::MenuOpened`], and returns [`Dispatch::OpenedMenu`].
255    /// - [`Command::Quit`] returns [`Dispatch::Quit`].
256    /// - Any other [`Command`] is published on the bus as
257    ///   [`Event::Command`]; the dispatcher returns [`Dispatch::Noop`]
258    ///   (T17 refactors this into direct `Session` handles).
259    ///
260    /// When the menu is open, the event is handed to the topmost
261    /// [`crate::modal::Dialog`] on the [`ModalStack`]. The stack
262    /// auto-manages `Close` / `Push` outcomes; this function only
263    /// needs to detect the root dialog closing (stack becomes empty)
264    /// to publish [`Event::MenuClosed`] and flip `menu_open` back.
265    /// `Action` outcomes bubble up as [`Dispatch::Action`] for the
266    /// runner to apply.
267    pub fn handle_key(&mut self, key: KeyEvent) -> Dispatch {
268        if self.menu_open {
269            let outcome = self.modal_stack.handle_key(key);
270            if self.modal_stack.is_empty() {
271                // Root dialog closed; menu is fully dismissed.
272                self.menu_open = false;
273                let _ = self.bus.publish(Event::MenuClosed);
274                return Dispatch::ClosedMenu;
275            }
276            return match outcome {
277                DialogOutcome::Action(action) => Dispatch::Action(action),
278                _ => Dispatch::Noop,
279            };
280        }
281
282        // Scrollback navigation: Shift+PageUp/Down, Shift+Up/Down,
283        // Shift+Home/End. Intercepted before CommandKeyParser so they
284        // never reach the wire and never fight device input.
285        if key.modifiers.contains(KeyModifiers::SHIFT) {
286            let half_screen = (usize::from(self.body_rows) / 2).max(1);
287            match key.code {
288                KeyCode::PageUp => {
289                    self.serial_pane.scroll_up(half_screen);
290                    return Dispatch::Noop;
291                }
292                KeyCode::PageDown => {
293                    self.serial_pane.scroll_down(half_screen);
294                    return Dispatch::Noop;
295                }
296                KeyCode::Up => {
297                    self.serial_pane.scroll_up(1);
298                    return Dispatch::Noop;
299                }
300                KeyCode::Down => {
301                    self.serial_pane.scroll_down(1);
302                    return Dispatch::Noop;
303                }
304                KeyCode::Home => {
305                    self.serial_pane.scroll_to_top();
306                    return Dispatch::Noop;
307                }
308                KeyCode::End => {
309                    self.serial_pane.scroll_to_bottom();
310                    return Dispatch::Noop;
311                }
312                _ => {}
313            }
314        }
315
316        let bytes = crate::input::key_to_bytes(key);
317        if bytes.is_empty() {
318            return Dispatch::Noop;
319        }
320
321        let mut tx = Vec::new();
322        for &b in &bytes {
323            match self.parser.feed(b) {
324                ParseOutput::None => {}
325                ParseOutput::Data(data_byte) => tx.push(data_byte),
326                ParseOutput::Command(Command::OpenMenu) => {
327                    self.menu_open = true;
328                    self.modal_stack.push(Box::new(RootMenu::new(
329                        self.current_config,
330                        self.current_line_endings,
331                        self.current_modem,
332                        self.current_modal_style,
333                        self.current_cli_overrides.clone(),
334                    )));
335                    let _ = self.bus.publish(Event::MenuOpened);
336                    return Dispatch::OpenedMenu;
337                }
338                ParseOutput::Command(Command::Quit) => {
339                    return Dispatch::Quit;
340                }
341                ParseOutput::Command(cmd) => {
342                    // Forward all other commands onto the bus; T17
343                    // refactors this into direct Session handles.
344                    let _ = self.bus.publish(Event::Command(cmd));
345                }
346            }
347        }
348
349        if tx.is_empty() {
350            Dispatch::Noop
351        } else {
352            Dispatch::TxBytes(tx)
353        }
354    }
355
356    /// Route a mouse event.
357    ///
358    /// v0.2 handles only wheel scroll: [`MouseEventKind::ScrollUp`] and
359    /// [`MouseEventKind::ScrollDown`] move the serial pane's scrollback
360    /// view by [`TuiApp::set_wheel_scroll_lines`] lines per notch.
361    /// Click / drag / move events are ignored until v0.2.1 lands
362    /// selection + copy. Menu-open mouse events are also ignored — the
363    /// menu is keyboard-only for now.
364    pub fn handle_mouse(&mut self, ev: MouseEvent) -> Dispatch {
365        if self.menu_open {
366            // Menus are keyboard-driven in v0.2; mouse events into a
367            // modal dialog are dropped. v0.2.1 may grow menu mouse
368            // support.
369            return Dispatch::Noop;
370        }
371        match ev.kind {
372            MouseEventKind::ScrollUp => {
373                self.serial_pane
374                    .scroll_up(usize::from(self.wheel_scroll_lines));
375            }
376            MouseEventKind::ScrollDown => {
377                self.serial_pane
378                    .scroll_down(usize::from(self.wheel_scroll_lines));
379            }
380            // Drag / Click / Move / ScrollLeft / ScrollRight: deferred
381            // to v0.2.1 (native selection + copy).
382            _ => {}
383        }
384        Dispatch::Noop
385    }
386
387    /// Render the main screen into `f`.
388    ///
389    /// Layout: 1-row top bar ("rtcom {version} | {device} | {config}"),
390    /// body (serial pane rendered via [`tui_term`]), 1-row bottom bar
391    /// with command-key hints. The serial pane is resized to the body
392    /// size every frame so it follows terminal resizes.
393    ///
394    /// When the configuration menu is open, the body is drawn according
395    /// to the current [`ModalStyle`] (set via
396    /// [`TuiApp::set_modal_style`]):
397    ///
398    /// - [`ModalStyle::Overlay`]: serial pane drawn normally; the
399    ///   modal dialog is painted over it at its preferred size.
400    /// - [`ModalStyle::DimmedOverlay`]: serial pane drawn normally,
401    ///   then every body cell has [`Modifier::DIM`] OR-ed into its
402    ///   style so the stream fades behind the modal. The modal is then
403    ///   painted on top at full brightness.
404    /// - [`ModalStyle::Fullscreen`]: the serial pane is **not** drawn
405    ///   at all; the modal fills the entire body area. The top/bottom
406    ///   chrome bars remain visible.
407    pub fn render(&mut self, f: &mut Frame<'_>) {
408        let area = f.area();
409        let (top, body, bottom) = main_chrome(area);
410
411        // Keep the serial pane's internal grid aligned with the body.
412        if body.height > 0 && body.width > 0 {
413            self.serial_pane.resize(body.height, body.width);
414        }
415        // Cache the body height so `handle_key` can compute
416        // half-screen Shift+PageUp / PageDown scrolls without having a
417        // render frame on hand.
418        self.body_rows = body.height;
419
420        // Top bar.
421        let version = env!("CARGO_PKG_VERSION");
422        let mut top_spans = vec![
423            Span::styled(
424                format!(" rtcom {version} "),
425                Style::default().add_modifier(Modifier::REVERSED),
426            ),
427            Span::raw("  "),
428            Span::raw(self.device_path.clone()),
429            Span::raw("  "),
430            Span::raw(self.config_summary.clone()),
431        ];
432        // Scrollback indicator: only rendered when the view is above
433        // the live tail so the top bar stays clean in the common case.
434        if self.serial_pane.is_scrolled() {
435            top_spans.push(Span::styled(
436                format!(
437                    "  [SCROLL \u{2191}{}]",
438                    self.serial_pane.scrollback_offset()
439                ),
440                Style::default().fg(Color::Yellow),
441            ));
442        }
443        f.render_widget(Paragraph::new(Line::from(top_spans)), top);
444
445        // Body: whether to draw the live serial pane, and whether to
446        // dim it, depends on the current modal style and menu state.
447        let in_fullscreen_menu =
448            self.menu_open && self.current_modal_style == ModalStyle::Fullscreen;
449
450        if !in_fullscreen_menu {
451            // Serial pane via tui-term's PseudoTerminal widget.
452            let term_widget = PseudoTerminal::new(self.serial_pane.screen());
453            f.render_widget(term_widget, body);
454
455            // DimmedOverlay: OR DIM into every cell in the body area so
456            // the background stream fades behind the upcoming modal.
457            // `Buffer::set_style` composes by OR-ing `add_modifier` into
458            // each cell, preserving existing fg/bg/modifiers.
459            if self.menu_open && self.current_modal_style == ModalStyle::DimmedOverlay {
460                f.buffer_mut()
461                    .set_style(body, Style::default().add_modifier(Modifier::DIM));
462            }
463        }
464
465        // Bottom bar: hint text.
466        let bottom_line = Line::from(Span::styled(
467            " ^A m menu · ^A ? help · ^A ^Q quit ",
468            Style::default().add_modifier(Modifier::DIM),
469        ));
470        f.render_widget(Paragraph::new(bottom_line), bottom);
471
472        // Modal overlay: topmost dialog drawn over the (possibly
473        // dimmed, possibly skipped) body. In Fullscreen mode the
474        // modal fills the body area; otherwise it uses its preferred
475        // size (typically a centered box).
476        if self.menu_open {
477            if let Some(top_dialog) = self.modal_stack.top() {
478                let dialog_area = if in_fullscreen_menu {
479                    body
480                } else {
481                    top_dialog.preferred_size(area)
482                };
483                top_dialog.render(dialog_area, f.buffer_mut());
484            }
485        }
486
487        // Toast overlay: tick to drop expired entries, then draw the
488        // remainder on top of the main chrome *and* the modal so
489        // outcome messages stay visible regardless of menu state.
490        self.toasts.tick();
491        if !self.toasts.is_empty() {
492            // Reserve up to max_visible rows immediately below the top
493            // bar; clamp so we never overflow the terminal height.
494            let height = u16::try_from(self.toasts.visible_count())
495                .unwrap_or(u16::MAX)
496                .min(area.height.saturating_sub(top.height));
497            if height > 0 {
498                let toast_area = Rect {
499                    x: area.x,
500                    y: area.y + top.height,
501                    width: area.width,
502                    height,
503                };
504                render_toasts(&self.toasts, toast_area, f.buffer_mut());
505            }
506        }
507    }
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513    use crate::Dispatch;
514    use ratatui::{backend::TestBackend, Terminal};
515    use rtcom_core::EventBus;
516
517    fn render_app(app: &mut TuiApp, width: u16, height: u16) -> Terminal<TestBackend> {
518        let backend = TestBackend::new(width, height);
519        let mut terminal = Terminal::new(backend).unwrap();
520        terminal.draw(|f| app.render(f)).unwrap();
521        terminal
522    }
523
524    #[test]
525    fn tui_app_builds_without_running() {
526        let bus = EventBus::new(64);
527        let app = TuiApp::new(bus);
528        assert!(!app.is_menu_open());
529    }
530
531    #[test]
532    fn main_screen_80x24_empty_snapshot() {
533        let bus = EventBus::new(64);
534        let mut app = TuiApp::new(bus);
535        app.set_device_summary("/dev/ttyUSB0", "115200 8N1 none");
536        let terminal = render_app(&mut app, 80, 24);
537        insta::assert_snapshot!(terminal.backend());
538    }
539
540    #[test]
541    fn main_screen_80x24_with_serial_data_snapshot() {
542        let bus = EventBus::new(64);
543        let mut app = TuiApp::new(bus);
544        app.set_device_summary("/dev/ttyUSB0", "115200 8N1 none");
545        app.serial_pane_mut().ingest(b"boot: starting...\r\nok\r\n");
546        let terminal = render_app(&mut app, 80, 24);
547        insta::assert_snapshot!(terminal.backend());
548    }
549
550    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
551
552    const fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
553        KeyEvent::new(code, mods)
554    }
555
556    #[test]
557    fn key_passthrough_when_menu_closed() {
558        let bus = EventBus::new(64);
559        let mut app = TuiApp::new(bus);
560        let out = app.handle_key(key(KeyCode::Char('h'), KeyModifiers::NONE));
561        assert!(matches!(out, Dispatch::TxBytes(ref b) if b == b"h"));
562    }
563
564    #[test]
565    fn ctrl_a_then_m_opens_menu() {
566        let bus = EventBus::new(64);
567        let mut app = TuiApp::new(bus);
568        let step1 = app.handle_key(key(KeyCode::Char('a'), KeyModifiers::CONTROL));
569        assert!(matches!(step1, Dispatch::Noop));
570        let step2 = app.handle_key(key(KeyCode::Char('m'), KeyModifiers::NONE));
571        assert!(matches!(step2, Dispatch::OpenedMenu));
572        assert!(app.is_menu_open());
573    }
574
575    #[test]
576    fn ctrl_q_requests_quit() {
577        let bus = EventBus::new(64);
578        let mut app = TuiApp::new(bus);
579        // Bytes: ^A then ^Q
580        let _ = app.handle_key(key(KeyCode::Char('a'), KeyModifiers::CONTROL));
581        let out = app.handle_key(key(KeyCode::Char('q'), KeyModifiers::CONTROL));
582        assert!(matches!(out, Dispatch::Quit));
583    }
584
585    #[test]
586    fn ctrl_a_m_second_press_is_swallowed_by_menu() {
587        let bus = EventBus::new(64);
588        let mut app = TuiApp::new(bus);
589        // open
590        let _ = app.handle_key(key(KeyCode::Char('a'), KeyModifiers::CONTROL));
591        let _ = app.handle_key(key(KeyCode::Char('m'), KeyModifiers::NONE));
592        assert!(app.is_menu_open());
593        // With the modal stack wired in T11, menu-open keys go to the
594        // root dialog. `^A` reaches the dialog as `0x01` (a plain
595        // unprintable Ctrl char), which the root menu simply consumes.
596        // The menu stays open.
597        let out = app.handle_key(key(KeyCode::Char('a'), KeyModifiers::CONTROL));
598        assert!(matches!(out, Dispatch::Noop));
599        let out = app.handle_key(key(KeyCode::Char('m'), KeyModifiers::NONE));
600        assert!(matches!(out, Dispatch::Noop));
601        assert!(app.is_menu_open());
602    }
603
604    #[test]
605    fn esc_in_root_menu_closes_it() {
606        let bus = EventBus::new(64);
607        let mut app = TuiApp::new(bus);
608        let _ = app.handle_key(key(KeyCode::Char('a'), KeyModifiers::CONTROL));
609        let _ = app.handle_key(key(KeyCode::Char('m'), KeyModifiers::NONE));
610        assert!(app.is_menu_open());
611        let out = app.handle_key(key(KeyCode::Esc, KeyModifiers::NONE));
612        assert!(matches!(out, Dispatch::ClosedMenu));
613        assert!(!app.is_menu_open());
614    }
615
616    #[test]
617    fn main_screen_80x24_menu_open_snapshot() {
618        let bus = EventBus::new(64);
619        let mut app = TuiApp::new(bus);
620        app.set_device_summary("/dev/ttyUSB0", "115200 8N1 none");
621        let _ = app.handle_key(key(KeyCode::Char('a'), KeyModifiers::CONTROL));
622        let _ = app.handle_key(key(KeyCode::Char('m'), KeyModifiers::NONE));
623        assert!(app.is_menu_open());
624        let terminal = render_app(&mut app, 80, 24);
625        insta::assert_snapshot!(terminal.backend());
626    }
627
628    #[test]
629    fn main_screen_80x24_serial_port_setup_open_snapshot() {
630        let bus = EventBus::new(64);
631        let mut app = TuiApp::new(bus);
632        app.set_device_summary("/dev/ttyUSB0", "115200 8N1 none");
633        // Open menu (^A m), then Enter on "Serial port setup" (idx 0).
634        let _ = app.handle_key(key(KeyCode::Char('a'), KeyModifiers::CONTROL));
635        let _ = app.handle_key(key(KeyCode::Char('m'), KeyModifiers::NONE));
636        let _ = app.handle_key(key(KeyCode::Enter, KeyModifiers::NONE));
637        assert!(app.is_menu_open());
638        let terminal = render_app(&mut app, 80, 24);
639        insta::assert_snapshot!(terminal.backend());
640    }
641
642    #[test]
643    fn enter_emits_cr_byte() {
644        let bus = EventBus::new(64);
645        let mut app = TuiApp::new(bus);
646        let out = app.handle_key(key(KeyCode::Enter, KeyModifiers::NONE));
647        assert!(matches!(out, Dispatch::TxBytes(ref b) if b == b"\r"));
648    }
649
650    #[test]
651    fn push_toast_appears_in_queue() {
652        let bus = EventBus::new(64);
653        let mut app = TuiApp::new(bus);
654        assert_eq!(app.toasts().visible_count(), 0);
655        app.push_toast("saved", crate::toast::ToastLevel::Info);
656        assert_eq!(app.toasts().visible_count(), 1);
657        assert_eq!(app.toasts().visible()[0].message, "saved");
658    }
659
660    #[test]
661    fn main_screen_80x24_with_toast_snapshot() {
662        let bus = EventBus::new(64);
663        let mut app = TuiApp::new(bus);
664        app.set_device_summary("/dev/ttyUSB0", "115200 8N1 none");
665        app.push_toast(
666            "profile saved: ~/.config/rtcom/default.toml",
667            crate::toast::ToastLevel::Info,
668        );
669        let terminal = render_app(&mut app, 80, 24);
670        insta::assert_snapshot!(terminal.backend());
671    }
672
673    // ----- T20: ModalStyle render matrix -----
674
675    /// Helper: open the configuration menu via `^A m`. Leaves the root
676    /// dialog on top of the modal stack.
677    fn open_menu(app: &mut TuiApp) {
678        let _ = app.handle_key(key(KeyCode::Char('a'), KeyModifiers::CONTROL));
679        let _ = app.handle_key(key(KeyCode::Char('m'), KeyModifiers::NONE));
680        assert!(app.is_menu_open());
681    }
682
683    #[test]
684    fn main_screen_120x40_empty_snapshot() {
685        let bus = EventBus::new(64);
686        let mut app = TuiApp::new(bus);
687        app.set_device_summary("/dev/ttyUSB0", "115200 8N1 none");
688        let terminal = render_app(&mut app, 120, 40);
689        insta::assert_snapshot!(terminal.backend());
690    }
691
692    #[test]
693    fn main_screen_120x40_menu_open_overlay_snapshot() {
694        let bus = EventBus::new(64);
695        let mut app = TuiApp::new(bus);
696        app.set_device_summary("/dev/ttyUSB0", "115200 8N1 none");
697        open_menu(&mut app);
698        let terminal = render_app(&mut app, 120, 40);
699        insta::assert_snapshot!(terminal.backend());
700    }
701
702    #[test]
703    fn main_screen_80x24_menu_open_dimmed_overlay_snapshot() {
704        let bus = EventBus::new(64);
705        let mut app = TuiApp::new(bus);
706        app.set_device_summary("/dev/ttyUSB0", "115200 8N1 none");
707        app.set_modal_style(ModalStyle::DimmedOverlay);
708        // Seed the serial pane so dimming is applied over visible
709        // content (the DIM modifier is invisible in TestBackend's
710        // text output, but the content itself should still appear).
711        app.serial_pane_mut()
712            .ingest(b"background line one\r\nbackground line two\r\n");
713        open_menu(&mut app);
714        let terminal = render_app(&mut app, 80, 24);
715        insta::assert_snapshot!(terminal.backend());
716    }
717
718    #[test]
719    fn main_screen_80x24_menu_open_fullscreen_snapshot() {
720        let bus = EventBus::new(64);
721        let mut app = TuiApp::new(bus);
722        app.set_device_summary("/dev/ttyUSB0", "115200 8N1 none");
723        app.set_modal_style(ModalStyle::Fullscreen);
724        // Content ingested but should NOT appear: fullscreen hides the
725        // serial pane entirely while the menu is open.
726        app.serial_pane_mut().ingest(b"hidden background\r\n");
727        open_menu(&mut app);
728        let terminal = render_app(&mut app, 80, 24);
729        insta::assert_snapshot!(terminal.backend());
730    }
731
732    // ----- T20: direct buffer inspection tests -----
733    //
734    // TestBackend's `Display` impl only emits cell symbols, so a
735    // snapshot alone cannot distinguish DimmedOverlay from Overlay.
736    // These tests inspect the rendered buffer to verify each
737    // ModalStyle actually has the intended effect on cell styles.
738
739    fn dim_probe_at(app: &mut TuiApp, width: u16, height: u16) -> ratatui::style::Style {
740        use ratatui::layout::Position;
741        let backend = TestBackend::new(width, height);
742        let mut terminal = Terminal::new(backend).unwrap();
743        terminal.draw(|f| app.render(f)).unwrap();
744        // (0, 1) = first column of the first body row (below the top
745        // bar). Well outside the centered modal on an 80x24 screen.
746        let buf = terminal.backend().buffer();
747        buf.cell(Position::new(0, 1)).unwrap().style()
748    }
749
750    #[test]
751    fn dimmed_overlay_actually_dims_body_cells() {
752        let bus = EventBus::new(64);
753        let mut app = TuiApp::new(bus);
754        app.set_device_summary("/dev/ttyUSB0", "115200 8N1 none");
755        app.set_modal_style(ModalStyle::DimmedOverlay);
756        app.serial_pane_mut().ingest(b"hello\r\n");
757        open_menu(&mut app);
758        let style = dim_probe_at(&mut app, 80, 24);
759        assert!(
760            style.add_modifier.contains(Modifier::DIM),
761            "expected DIM on body cell outside modal, got {style:?}"
762        );
763    }
764
765    #[test]
766    fn overlay_does_not_dim_body_cells() {
767        let bus = EventBus::new(64);
768        let mut app = TuiApp::new(bus);
769        app.set_device_summary("/dev/ttyUSB0", "115200 8N1 none");
770        // Default is Overlay.
771        assert_eq!(app.current_modal_style, ModalStyle::Overlay);
772        app.serial_pane_mut().ingest(b"hello\r\n");
773        open_menu(&mut app);
774        let style = dim_probe_at(&mut app, 80, 24);
775        assert!(
776            !style.add_modifier.contains(Modifier::DIM),
777            "expected no DIM on body cell with Overlay style, got {style:?}"
778        );
779    }
780
781    // ----- T24: scrollback keyboard + mouse handling -----
782
783    fn seed_pane_with_rows(app: &mut TuiApp, rows: usize) {
784        for i in 0..rows {
785            app.serial_pane_mut()
786                .ingest(format!("row {i}\r\n").as_bytes());
787        }
788    }
789
790    #[test]
791    fn shift_page_up_scrolls_up_half_screen() {
792        let bus = EventBus::new(64);
793        let mut app = TuiApp::new(bus);
794        // Render once so body_rows is populated (22 for 80x24 main chrome).
795        let _ = render_app(&mut app, 80, 24);
796        seed_pane_with_rows(&mut app, 40);
797        let out = app.handle_key(key(KeyCode::PageUp, KeyModifiers::SHIFT));
798        assert!(matches!(out, Dispatch::Noop));
799        // body.height == 22, half == 11.
800        assert_eq!(app.serial_pane.scrollback_offset(), 11);
801    }
802
803    #[test]
804    fn shift_page_down_scrolls_down_half_screen() {
805        let bus = EventBus::new(64);
806        let mut app = TuiApp::new(bus);
807        let _ = render_app(&mut app, 80, 24);
808        seed_pane_with_rows(&mut app, 200); // plenty of scrollback
809        app.serial_pane_mut().scroll_up(50);
810        let before = app.serial_pane.scrollback_offset();
811        let out = app.handle_key(key(KeyCode::PageDown, KeyModifiers::SHIFT));
812        assert!(matches!(out, Dispatch::Noop));
813        // body.height on 80x24 = 22, half = 11. Exact arithmetic only
814        // works when scroll_up didn't clamp against scrollback length,
815        // which is why we seed 200 rows up-front.
816        assert_eq!(app.serial_pane.scrollback_offset(), before - 11);
817    }
818
819    #[test]
820    fn shift_up_scrolls_one_line() {
821        let bus = EventBus::new(64);
822        let mut app = TuiApp::new(bus);
823        seed_pane_with_rows(&mut app, 40);
824        let _ = app.handle_key(key(KeyCode::Up, KeyModifiers::SHIFT));
825        assert_eq!(app.serial_pane.scrollback_offset(), 1);
826        let _ = app.handle_key(key(KeyCode::Up, KeyModifiers::SHIFT));
827        assert_eq!(app.serial_pane.scrollback_offset(), 2);
828    }
829
830    #[test]
831    fn shift_down_scrolls_back_one_line() {
832        let bus = EventBus::new(64);
833        let mut app = TuiApp::new(bus);
834        seed_pane_with_rows(&mut app, 40);
835        app.serial_pane_mut().scroll_up(5);
836        let _ = app.handle_key(key(KeyCode::Down, KeyModifiers::SHIFT));
837        assert_eq!(app.serial_pane.scrollback_offset(), 4);
838    }
839
840    #[test]
841    fn shift_home_and_end_jump_to_top_and_bottom() {
842        let bus = EventBus::new(64);
843        let mut app = TuiApp::new(bus);
844        seed_pane_with_rows(&mut app, 40);
845        let _ = app.handle_key(key(KeyCode::Home, KeyModifiers::SHIFT));
846        assert!(app.serial_pane.is_scrolled());
847        let _ = app.handle_key(key(KeyCode::End, KeyModifiers::SHIFT));
848        assert_eq!(app.serial_pane.scrollback_offset(), 0);
849    }
850
851    #[test]
852    fn plain_page_up_without_shift_does_not_scroll() {
853        let bus = EventBus::new(64);
854        let mut app = TuiApp::new(bus);
855        seed_pane_with_rows(&mut app, 40);
856        // PageUp without Shift has no wire encoding in `key_to_bytes`,
857        // so it's Noop, but must not affect the scrollback offset.
858        let _ = app.handle_key(key(KeyCode::PageUp, KeyModifiers::NONE));
859        assert_eq!(app.serial_pane.scrollback_offset(), 0);
860    }
861
862    #[test]
863    fn shift_scroll_keys_are_swallowed_when_menu_open() {
864        let bus = EventBus::new(64);
865        let mut app = TuiApp::new(bus);
866        seed_pane_with_rows(&mut app, 40);
867        // Open the menu first.
868        let _ = app.handle_key(key(KeyCode::Char('a'), KeyModifiers::CONTROL));
869        let _ = app.handle_key(key(KeyCode::Char('m'), KeyModifiers::NONE));
870        assert!(app.is_menu_open());
871        // Shift+PageUp while the menu is open: MUST NOT scroll the
872        // serial pane — the menu-open branch runs first.
873        let before = app.serial_pane.scrollback_offset();
874        let _ = app.handle_key(key(KeyCode::PageUp, KeyModifiers::SHIFT));
875        assert_eq!(app.serial_pane.scrollback_offset(), before);
876    }
877
878    const fn mouse(kind: MouseEventKind) -> MouseEvent {
879        MouseEvent {
880            kind,
881            column: 10,
882            row: 10,
883            modifiers: KeyModifiers::NONE,
884        }
885    }
886
887    #[test]
888    fn mouse_wheel_up_scrolls_by_wheel_scroll_lines() {
889        let bus = EventBus::new(64);
890        let mut app = TuiApp::new(bus);
891        seed_pane_with_rows(&mut app, 40);
892        app.set_wheel_scroll_lines(5);
893        let _ = app.handle_mouse(mouse(MouseEventKind::ScrollUp));
894        assert_eq!(app.serial_pane.scrollback_offset(), 5);
895        let _ = app.handle_mouse(mouse(MouseEventKind::ScrollUp));
896        assert_eq!(app.serial_pane.scrollback_offset(), 10);
897    }
898
899    #[test]
900    fn mouse_wheel_down_scrolls_back() {
901        let bus = EventBus::new(64);
902        let mut app = TuiApp::new(bus);
903        seed_pane_with_rows(&mut app, 40);
904        app.set_wheel_scroll_lines(3);
905        app.serial_pane_mut().scroll_up(10);
906        let _ = app.handle_mouse(mouse(MouseEventKind::ScrollDown));
907        assert_eq!(app.serial_pane.scrollback_offset(), 7);
908    }
909
910    #[test]
911    fn mouse_click_does_not_scroll() {
912        let bus = EventBus::new(64);
913        let mut app = TuiApp::new(bus);
914        seed_pane_with_rows(&mut app, 40);
915        let _ = app.handle_mouse(mouse(MouseEventKind::Down(
916            crossterm::event::MouseButton::Left,
917        )));
918        assert_eq!(app.serial_pane.scrollback_offset(), 0);
919    }
920
921    #[test]
922    fn mouse_wheel_ignored_when_menu_open() {
923        let bus = EventBus::new(64);
924        let mut app = TuiApp::new(bus);
925        seed_pane_with_rows(&mut app, 40);
926        let _ = app.handle_key(key(KeyCode::Char('a'), KeyModifiers::CONTROL));
927        let _ = app.handle_key(key(KeyCode::Char('m'), KeyModifiers::NONE));
928        assert!(app.is_menu_open());
929        let _ = app.handle_mouse(mouse(MouseEventKind::ScrollUp));
930        assert_eq!(app.serial_pane.scrollback_offset(), 0);
931    }
932
933    #[test]
934    fn set_wheel_scroll_lines_clamps_zero_to_one() {
935        let bus = EventBus::new(64);
936        let mut app = TuiApp::new(bus);
937        app.set_wheel_scroll_lines(0);
938        seed_pane_with_rows(&mut app, 40);
939        let _ = app.handle_mouse(mouse(MouseEventKind::ScrollUp));
940        // Wheel must still move at least one line — 0 is clamped to 1.
941        assert_eq!(app.serial_pane.scrollback_offset(), 1);
942    }
943
944    #[test]
945    fn top_bar_shows_scroll_indicator_when_scrolled() {
946        let bus = EventBus::new(64);
947        let mut app = TuiApp::new(bus);
948        app.set_device_summary("/dev/ttyUSB0", "115200 8N1 none");
949        seed_pane_with_rows(&mut app, 40);
950        app.serial_pane_mut().scroll_up(7);
951        let terminal = render_app(&mut app, 80, 24);
952        let rendered = format!("{}", terminal.backend());
953        assert!(
954            rendered.contains("[SCROLL \u{2191}7]"),
955            "expected '[SCROLL ↑7]' in top bar, got:\n{rendered}"
956        );
957    }
958
959    #[test]
960    fn top_bar_hides_scroll_indicator_when_live() {
961        let bus = EventBus::new(64);
962        let mut app = TuiApp::new(bus);
963        app.set_device_summary("/dev/ttyUSB0", "115200 8N1 none");
964        seed_pane_with_rows(&mut app, 40);
965        // Not scrolled; indicator must not appear.
966        let terminal = render_app(&mut app, 80, 24);
967        let rendered = format!("{}", terminal.backend());
968        assert!(
969            !rendered.contains("[SCROLL"),
970            "unexpected scroll indicator in top bar:\n{rendered}"
971        );
972    }
973
974    #[test]
975    fn fullscreen_menu_hides_serial_pane_content() {
976        let bus = EventBus::new(64);
977        let mut app = TuiApp::new(bus);
978        app.set_device_summary("/dev/ttyUSB0", "115200 8N1 none");
979        app.set_modal_style(ModalStyle::Fullscreen);
980        // Distinctive marker that would appear in the top-left of the
981        // body if the serial pane were drawn.
982        app.serial_pane_mut().ingest(b"ZZZZZ-secret-marker\r\n");
983        open_menu(&mut app);
984        let backend = TestBackend::new(80, 24);
985        let mut terminal = Terminal::new(backend).unwrap();
986        terminal.draw(|f| app.render(f)).unwrap();
987        let rendered = format!("{}", terminal.backend());
988        assert!(
989            !rendered.contains("ZZZZZ-secret-marker"),
990            "Fullscreen menu should hide serial pane content, \
991             but marker leaked into render:\n{rendered}"
992        );
993    }
994}