rtcom-tui 0.2.1

Terminal UI for rtcom, the Rust terminal communication tool.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
//! Top-level TUI application object.

use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
use ratatui::{
    layout::Rect,
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::Paragraph,
    Frame,
};
use rtcom_config::ModalStyle;
use rtcom_core::{
    command::{Command, CommandKeyParser, ParseOutput},
    Event, EventBus, LineEndingConfig, ModemLineSnapshot, SerialConfig,
};
use tui_term::widget::PseudoTerminal;

use crate::{
    input::Dispatch,
    layout::main_chrome,
    menu::RootMenu,
    modal::{DialogOutcome, ModalStack},
    serial_pane::SerialPane,
    toast::{render_toasts, ToastLevel, ToastQueue},
};

/// Owns the TUI render state and input dispatcher.
///
/// Tracks the serial data pane, the configuration-menu open/closed
/// state, and a lightweight device summary shown on the top bar.
/// Input handling lives in [`TuiApp::handle_key`], which routes
/// keyboard events through an internal [`CommandKeyParser`] whenever
/// the menu is closed.
pub struct TuiApp {
    bus: EventBus,
    menu_open: bool,
    serial_pane: SerialPane,
    device_path: String,
    config_summary: String,
    parser: CommandKeyParser,
    modal_stack: ModalStack,
    /// Current serial-link configuration; seeded to
    /// [`SerialConfig::default`] at construction and updated by
    /// [`TuiApp::set_serial_config`]. Forwarded into new [`RootMenu`]
    /// instances so sub-dialogs (starting with T12's
    /// [`crate::menu::SerialPortSetupDialog`]) can display live values.
    current_config: SerialConfig,
    /// Current line-ending mapper configuration; seeded to
    /// [`LineEndingConfig::default`] at construction and updated by
    /// [`TuiApp::set_line_endings`]. Forwarded into new [`RootMenu`]
    /// instances so the T13 [`crate::menu::LineEndingsDialog`] opens
    /// with live values.
    current_line_endings: LineEndingConfig,
    /// Current DTR / RTS output-line snapshot as known to rtcom;
    /// seeded to [`ModemLineSnapshot::default`] (both lines
    /// de-asserted) at construction and updated by
    /// [`TuiApp::set_modem_lines`]. Forwarded into new [`RootMenu`]
    /// instances so the T14 [`crate::menu::ModemControlDialog`] opens
    /// with live values.
    current_modem: ModemLineSnapshot,
    /// Current modal render style; seeded to [`ModalStyle::default`]
    /// at construction and updated by
    /// [`TuiApp::set_modal_style`]. Forwarded into new [`RootMenu`]
    /// instances so the T15 [`crate::menu::ScreenOptionsDialog`] opens
    /// with the live value.
    current_modal_style: ModalStyle,
    /// Queue of timed toast notifications. Populated by the runner's
    /// bus-event handler for [`Event::ProfileSaved`] /
    /// [`Event::ProfileLoadFailed`] / [`Event::Error`]. Rendered on
    /// top of the main chrome + modal in [`TuiApp::render`] so
    /// outcome messages are always visible.
    toasts: ToastQueue,
    /// Names of the CLI flags (e.g. `-b`, `-d`, `--omap/--imap/--emap`)
    /// that overrode a profile value at startup. Seeded empty and set
    /// by the binary's `main` via [`TuiApp::set_cli_overrides`]. The
    /// list is forwarded into [`RootMenu`] and then
    /// [`crate::menu::SerialPortSetupDialog`] so the dialog can render
    /// a hint explaining why the on-screen values may not match the
    /// saved profile.
    current_cli_overrides: Vec<&'static str>,
    /// Lines scrolled per mouse-wheel notch in the serial pane.
    /// Seeded to 3 and overridden from the profile by the binary's
    /// `main` via [`TuiApp::set_wheel_scroll_lines`]. Always at least
    /// 1 so the wheel never becomes a no-op.
    wheel_scroll_lines: u16,
    /// Height in rows of the body area at the last render. Used by
    /// [`TuiApp::handle_key`] to compute half-screen page scrolls.
    /// Seeded to 24 so the first Shift+PageUp / Shift+PageDown press
    /// before any render still has a sensible denominator.
    body_rows: u16,
}

impl TuiApp {
    /// Construct a new `TuiApp` bound to the given event bus.
    ///
    /// Starts with a `24x80` serial pane and a default [`SerialConfig`];
    /// the pane is resized to the terminal body on every call to
    /// [`TuiApp::render`], and the config is overwritten by
    /// [`TuiApp::set_serial_config`] once the runner knows the real
    /// link parameters.
    #[must_use]
    pub fn new(bus: EventBus) -> Self {
        Self {
            bus,
            menu_open: false,
            // 24x80 is a safe default; actual size is set on first render.
            serial_pane: SerialPane::new(24, 80),
            device_path: String::new(),
            config_summary: String::new(),
            parser: CommandKeyParser::default(),
            modal_stack: ModalStack::new(),
            current_config: SerialConfig::default(),
            current_line_endings: LineEndingConfig::default(),
            current_modem: ModemLineSnapshot::default(),
            current_modal_style: ModalStyle::default(),
            toasts: ToastQueue::new(),
            current_cli_overrides: Vec::new(),
            wheel_scroll_lines: 3,
            body_rows: 24,
        }
    }

    /// Override the mouse-wheel scroll speed (lines per notch).
    ///
    /// Values less than 1 are clamped to 1 so the wheel never turns
    /// into a no-op — a wheel event that moves nothing visibly
    /// suggests rtcom is broken even when the underlying config is
    /// "intentionally disabled". Users who want to pin the view can
    /// simply not scroll.
    pub fn set_wheel_scroll_lines(&mut self, n: u16) {
        self.wheel_scroll_lines = n.max(1);
    }

    /// Record which CLI flags overrode a profile value at startup.
    ///
    /// Each element is a short, user-facing flag label (`-b`, `-d`,
    /// `--omap/--imap/--emap`, ...). The
    /// [`crate::menu::SerialPortSetupDialog`] shows a "N field(s)
    /// overridden by CLI" hint at the bottom when this list is
    /// non-empty; empty disables the hint entirely.
    pub fn set_cli_overrides(&mut self, fields: Vec<&'static str>) {
        self.current_cli_overrides = fields;
    }

    /// Push a new toast onto the queue. Consumed by the runner's
    /// bus-event handler for profile IO + error events.
    pub fn push_toast(&mut self, message: impl Into<String>, level: ToastLevel) {
        self.toasts.push(message, level);
    }

    /// Mutable access to the toast queue. Mainly used by tests and
    /// the main-loop tick to advance expiration.
    pub const fn toasts_mut(&mut self) -> &mut ToastQueue {
        &mut self.toasts
    }

    /// Immutable borrow of the toast queue (read-only introspection).
    #[must_use]
    pub const fn toasts(&self) -> &ToastQueue {
        &self.toasts
    }

    /// Update the cached [`SerialConfig`] that new [`RootMenu`] pushes
    /// pass down to sub-dialogs.
    ///
    /// Call this whenever the live session's config changes (T17 wires
    /// this into `Event::ConfigChanged`).
    pub const fn set_serial_config(&mut self, cfg: SerialConfig) {
        self.current_config = cfg;
    }

    /// Update the cached [`LineEndingConfig`] that new [`RootMenu`]
    /// pushes pass down to the T13
    /// [`crate::menu::LineEndingsDialog`].
    ///
    /// Call this whenever the live session's mapper configuration
    /// changes (T17 wires this into the `ApplyLineEndingsLive` path).
    pub const fn set_line_endings(&mut self, le: LineEndingConfig) {
        self.current_line_endings = le;
    }

    /// Update the cached [`ModemLineSnapshot`] that new [`RootMenu`]
    /// pushes pass down to the T14
    /// [`crate::menu::ModemControlDialog`].
    ///
    /// Call this whenever the live session's modem output lines
    /// change (T17 wires this into the `SetDtr` / `SetRts` paths).
    pub const fn set_modem_lines(&mut self, snapshot: ModemLineSnapshot) {
        self.current_modem = snapshot;
    }

    /// Update the cached [`ModalStyle`] that new [`RootMenu`] pushes
    /// pass down to the T15 [`crate::menu::ScreenOptionsDialog`].
    ///
    /// Call this whenever the live modal-style preference changes
    /// (T17 wires this into the `ApplyModalStyleLive` / `AndSave`
    /// paths).
    pub const fn set_modal_style(&mut self, style: ModalStyle) {
        self.current_modal_style = style;
    }

    /// Whether the configuration menu is currently open.
    #[must_use]
    pub const fn is_menu_open(&self) -> bool {
        self.menu_open
    }

    /// Update the device path + config summary shown on the top bar.
    ///
    /// Accepts any type convertible to `String` so call sites can pass
    /// either borrowed or owned strings.
    pub fn set_device_summary(
        &mut self,
        device_path: impl Into<String>,
        config_summary: impl Into<String>,
    ) {
        self.device_path = device_path.into();
        self.config_summary = config_summary.into();
    }

    /// Update just the config-summary portion of the top bar, leaving
    /// the device path untouched.
    ///
    /// Used by the bus subscriber to refresh the status line after an
    /// [`Event::ConfigChanged`] without having to know the device path.
    pub fn set_config_summary(&mut self, config_summary: impl Into<String>) {
        self.config_summary = config_summary.into();
    }

    /// Mutable access to the serial data pane.
    ///
    /// Primarily used by the serial-reader subscriber to ingest incoming
    /// bytes; tests also use it to seed a known screen state.
    pub const fn serial_pane_mut(&mut self) -> &mut SerialPane {
        &mut self.serial_pane
    }

    /// Internal accessor for the bus (later tasks wire this in).
    #[allow(dead_code)]
    pub(crate) const fn bus(&self) -> &EventBus {
        &self.bus
    }

    /// Route a key event.
    ///
    /// When the menu is closed, the event is converted to bytes via
    /// [`crate::input::key_to_bytes`] and fed one byte at a time to
    /// the internal [`CommandKeyParser`]:
    ///
    /// - [`ParseOutput::Data`] bytes accumulate into a
    ///   [`Dispatch::TxBytes`] payload.
    /// - [`Command::OpenMenu`] flips `menu_open`, pushes a
    ///   [`RootMenu`] onto the modal stack, publishes
    ///   [`Event::MenuOpened`], and returns [`Dispatch::OpenedMenu`].
    /// - [`Command::Quit`] returns [`Dispatch::Quit`].
    /// - Any other [`Command`] is published on the bus as
    ///   [`Event::Command`]; the dispatcher returns [`Dispatch::Noop`]
    ///   (T17 refactors this into direct `Session` handles).
    ///
    /// When the menu is open, the event is handed to the topmost
    /// [`crate::modal::Dialog`] on the [`ModalStack`]. The stack
    /// auto-manages `Close` / `Push` outcomes; this function only
    /// needs to detect the root dialog closing (stack becomes empty)
    /// to publish [`Event::MenuClosed`] and flip `menu_open` back.
    /// `Action` outcomes bubble up as [`Dispatch::Action`] for the
    /// runner to apply.
    pub fn handle_key(&mut self, key: KeyEvent) -> Dispatch {
        if self.menu_open {
            let outcome = self.modal_stack.handle_key(key);
            if self.modal_stack.is_empty() {
                // Root dialog closed; menu is fully dismissed.
                self.menu_open = false;
                let _ = self.bus.publish(Event::MenuClosed);
                return Dispatch::ClosedMenu;
            }
            return match outcome {
                DialogOutcome::Action(action) => Dispatch::Action(action),
                _ => Dispatch::Noop,
            };
        }

        // Scrollback navigation: Shift+PageUp/Down, Shift+Up/Down,
        // Shift+Home/End. Intercepted before CommandKeyParser so they
        // never reach the wire and never fight device input.
        if key.modifiers.contains(KeyModifiers::SHIFT) {
            let half_screen = (usize::from(self.body_rows) / 2).max(1);
            match key.code {
                KeyCode::PageUp => {
                    self.serial_pane.scroll_up(half_screen);
                    return Dispatch::Noop;
                }
                KeyCode::PageDown => {
                    self.serial_pane.scroll_down(half_screen);
                    return Dispatch::Noop;
                }
                KeyCode::Up => {
                    self.serial_pane.scroll_up(1);
                    return Dispatch::Noop;
                }
                KeyCode::Down => {
                    self.serial_pane.scroll_down(1);
                    return Dispatch::Noop;
                }
                KeyCode::Home => {
                    self.serial_pane.scroll_to_top();
                    return Dispatch::Noop;
                }
                KeyCode::End => {
                    self.serial_pane.scroll_to_bottom();
                    return Dispatch::Noop;
                }
                _ => {}
            }
        }

        let bytes = crate::input::key_to_bytes(key);
        if bytes.is_empty() {
            return Dispatch::Noop;
        }

        let mut tx = Vec::new();
        for &b in &bytes {
            match self.parser.feed(b) {
                ParseOutput::None => {}
                ParseOutput::Data(data_byte) => tx.push(data_byte),
                ParseOutput::Command(Command::OpenMenu) => {
                    self.menu_open = true;
                    self.modal_stack.push(Box::new(RootMenu::new(
                        self.current_config,
                        self.current_line_endings,
                        self.current_modem,
                        self.current_modal_style,
                        self.current_cli_overrides.clone(),
                    )));
                    let _ = self.bus.publish(Event::MenuOpened);
                    return Dispatch::OpenedMenu;
                }
                ParseOutput::Command(Command::Quit) => {
                    return Dispatch::Quit;
                }
                ParseOutput::Command(cmd) => {
                    // Forward all other commands onto the bus; T17
                    // refactors this into direct Session handles.
                    let _ = self.bus.publish(Event::Command(cmd));
                }
            }
        }

        if tx.is_empty() {
            Dispatch::Noop
        } else {
            Dispatch::TxBytes(tx)
        }
    }

    /// Route a mouse event.
    ///
    /// v0.2 handles only wheel scroll: [`MouseEventKind::ScrollUp`] and
    /// [`MouseEventKind::ScrollDown`] move the serial pane's scrollback
    /// view by [`TuiApp::set_wheel_scroll_lines`] lines per notch.
    /// Click / drag / move events are ignored until v0.2.1 lands
    /// selection + copy. Menu-open mouse events are also ignored — the
    /// menu is keyboard-only for now.
    pub fn handle_mouse(&mut self, ev: MouseEvent) -> Dispatch {
        if self.menu_open {
            // Menus are keyboard-driven in v0.2; mouse events into a
            // modal dialog are dropped. v0.2.1 may grow menu mouse
            // support.
            return Dispatch::Noop;
        }
        match ev.kind {
            MouseEventKind::ScrollUp => {
                self.serial_pane
                    .scroll_up(usize::from(self.wheel_scroll_lines));
            }
            MouseEventKind::ScrollDown => {
                self.serial_pane
                    .scroll_down(usize::from(self.wheel_scroll_lines));
            }
            // Drag / Click / Move / ScrollLeft / ScrollRight: deferred
            // to v0.2.1 (native selection + copy).
            _ => {}
        }
        Dispatch::Noop
    }

    /// Render the main screen into `f`.
    ///
    /// Layout: 1-row top bar ("rtcom {version} | {device} | {config}"),
    /// body (serial pane rendered via [`tui_term`]), 1-row bottom bar
    /// with command-key hints. The serial pane is resized to the body
    /// size every frame so it follows terminal resizes.
    ///
    /// When the configuration menu is open, the body is drawn according
    /// to the current [`ModalStyle`] (set via
    /// [`TuiApp::set_modal_style`]):
    ///
    /// - [`ModalStyle::Overlay`]: serial pane drawn normally; the
    ///   modal dialog is painted over it at its preferred size.
    /// - [`ModalStyle::DimmedOverlay`]: serial pane drawn normally,
    ///   then every body cell has [`Modifier::DIM`] OR-ed into its
    ///   style so the stream fades behind the modal. The modal is then
    ///   painted on top at full brightness.
    /// - [`ModalStyle::Fullscreen`]: the serial pane is **not** drawn
    ///   at all; the modal fills the entire body area. The top/bottom
    ///   chrome bars remain visible.
    pub fn render(&mut self, f: &mut Frame<'_>) {
        let area = f.area();
        let (top, body, bottom) = main_chrome(area);

        // Keep the serial pane's internal grid aligned with the body.
        if body.height > 0 && body.width > 0 {
            self.serial_pane.resize(body.height, body.width);
        }
        // Cache the body height so `handle_key` can compute
        // half-screen Shift+PageUp / PageDown scrolls without having a
        // render frame on hand.
        self.body_rows = body.height;

        // Top bar.
        let version = env!("CARGO_PKG_VERSION");
        let mut top_spans = vec![
            Span::styled(
                format!(" rtcom {version} "),
                Style::default().add_modifier(Modifier::REVERSED),
            ),
            Span::raw("  "),
            Span::raw(self.device_path.clone()),
            Span::raw("  "),
            Span::raw(self.config_summary.clone()),
        ];
        // Scrollback indicator: only rendered when the view is above
        // the live tail so the top bar stays clean in the common case.
        if self.serial_pane.is_scrolled() {
            top_spans.push(Span::styled(
                format!(
                    "  [SCROLL \u{2191}{}]",
                    self.serial_pane.scrollback_offset()
                ),
                Style::default().fg(Color::Yellow),
            ));
        }
        f.render_widget(Paragraph::new(Line::from(top_spans)), top);

        // Body: whether to draw the live serial pane, and whether to
        // dim it, depends on the current modal style and menu state.
        let in_fullscreen_menu =
            self.menu_open && self.current_modal_style == ModalStyle::Fullscreen;

        if !in_fullscreen_menu {
            // Serial pane via tui-term's PseudoTerminal widget.
            let term_widget = PseudoTerminal::new(self.serial_pane.screen());
            f.render_widget(term_widget, body);

            // DimmedOverlay: OR DIM into every cell in the body area so
            // the background stream fades behind the upcoming modal.
            // `Buffer::set_style` composes by OR-ing `add_modifier` into
            // each cell, preserving existing fg/bg/modifiers.
            if self.menu_open && self.current_modal_style == ModalStyle::DimmedOverlay {
                f.buffer_mut()
                    .set_style(body, Style::default().add_modifier(Modifier::DIM));
            }
        }

        // Bottom bar: hint text.
        let bottom_line = Line::from(Span::styled(
            " ^A m menu · ^A ? help · ^A ^Q quit ",
            Style::default().add_modifier(Modifier::DIM),
        ));
        f.render_widget(Paragraph::new(bottom_line), bottom);

        // Modal overlay: topmost dialog drawn over the (possibly
        // dimmed, possibly skipped) body. In Fullscreen mode the
        // modal fills the body area; otherwise it uses its preferred
        // size (typically a centered box).
        if self.menu_open {
            if let Some(top_dialog) = self.modal_stack.top() {
                let dialog_area = if in_fullscreen_menu {
                    body
                } else {
                    top_dialog.preferred_size(area)
                };
                top_dialog.render(dialog_area, f.buffer_mut());
            }
        }

        // Toast overlay: tick to drop expired entries, then draw the
        // remainder on top of the main chrome *and* the modal so
        // outcome messages stay visible regardless of menu state.
        self.toasts.tick();
        if !self.toasts.is_empty() {
            // Reserve up to max_visible rows immediately below the top
            // bar; clamp so we never overflow the terminal height.
            let height = u16::try_from(self.toasts.visible_count())
                .unwrap_or(u16::MAX)
                .min(area.height.saturating_sub(top.height));
            if height > 0 {
                let toast_area = Rect {
                    x: area.x,
                    y: area.y + top.height,
                    width: area.width,
                    height,
                };
                render_toasts(&self.toasts, toast_area, f.buffer_mut());
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::Dispatch;
    use ratatui::{backend::TestBackend, Terminal};
    use rtcom_core::EventBus;

    fn render_app(app: &mut TuiApp, width: u16, height: u16) -> Terminal<TestBackend> {
        let backend = TestBackend::new(width, height);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal.draw(|f| app.render(f)).unwrap();
        terminal
    }

    #[test]
    fn tui_app_builds_without_running() {
        let bus = EventBus::new(64);
        let app = TuiApp::new(bus);
        assert!(!app.is_menu_open());
    }

    #[test]
    fn main_screen_80x24_empty_snapshot() {
        let bus = EventBus::new(64);
        let mut app = TuiApp::new(bus);
        app.set_device_summary("/dev/ttyUSB0", "115200 8N1 none");
        let terminal = render_app(&mut app, 80, 24);
        insta::assert_snapshot!(terminal.backend());
    }

    #[test]
    fn main_screen_80x24_with_serial_data_snapshot() {
        let bus = EventBus::new(64);
        let mut app = TuiApp::new(bus);
        app.set_device_summary("/dev/ttyUSB0", "115200 8N1 none");
        app.serial_pane_mut().ingest(b"boot: starting...\r\nok\r\n");
        let terminal = render_app(&mut app, 80, 24);
        insta::assert_snapshot!(terminal.backend());
    }

    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};

    const fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
        KeyEvent::new(code, mods)
    }

    #[test]
    fn key_passthrough_when_menu_closed() {
        let bus = EventBus::new(64);
        let mut app = TuiApp::new(bus);
        let out = app.handle_key(key(KeyCode::Char('h'), KeyModifiers::NONE));
        assert!(matches!(out, Dispatch::TxBytes(ref b) if b == b"h"));
    }

    #[test]
    fn ctrl_a_then_m_opens_menu() {
        let bus = EventBus::new(64);
        let mut app = TuiApp::new(bus);
        let step1 = app.handle_key(key(KeyCode::Char('a'), KeyModifiers::CONTROL));
        assert!(matches!(step1, Dispatch::Noop));
        let step2 = app.handle_key(key(KeyCode::Char('m'), KeyModifiers::NONE));
        assert!(matches!(step2, Dispatch::OpenedMenu));
        assert!(app.is_menu_open());
    }

    #[test]
    fn ctrl_q_requests_quit() {
        let bus = EventBus::new(64);
        let mut app = TuiApp::new(bus);
        // Bytes: ^A then ^Q
        let _ = app.handle_key(key(KeyCode::Char('a'), KeyModifiers::CONTROL));
        let out = app.handle_key(key(KeyCode::Char('q'), KeyModifiers::CONTROL));
        assert!(matches!(out, Dispatch::Quit));
    }

    #[test]
    fn ctrl_a_m_second_press_is_swallowed_by_menu() {
        let bus = EventBus::new(64);
        let mut app = TuiApp::new(bus);
        // open
        let _ = app.handle_key(key(KeyCode::Char('a'), KeyModifiers::CONTROL));
        let _ = app.handle_key(key(KeyCode::Char('m'), KeyModifiers::NONE));
        assert!(app.is_menu_open());
        // With the modal stack wired in T11, menu-open keys go to the
        // root dialog. `^A` reaches the dialog as `0x01` (a plain
        // unprintable Ctrl char), which the root menu simply consumes.
        // The menu stays open.
        let out = app.handle_key(key(KeyCode::Char('a'), KeyModifiers::CONTROL));
        assert!(matches!(out, Dispatch::Noop));
        let out = app.handle_key(key(KeyCode::Char('m'), KeyModifiers::NONE));
        assert!(matches!(out, Dispatch::Noop));
        assert!(app.is_menu_open());
    }

    #[test]
    fn esc_in_root_menu_closes_it() {
        let bus = EventBus::new(64);
        let mut app = TuiApp::new(bus);
        let _ = app.handle_key(key(KeyCode::Char('a'), KeyModifiers::CONTROL));
        let _ = app.handle_key(key(KeyCode::Char('m'), KeyModifiers::NONE));
        assert!(app.is_menu_open());
        let out = app.handle_key(key(KeyCode::Esc, KeyModifiers::NONE));
        assert!(matches!(out, Dispatch::ClosedMenu));
        assert!(!app.is_menu_open());
    }

    #[test]
    fn main_screen_80x24_menu_open_snapshot() {
        let bus = EventBus::new(64);
        let mut app = TuiApp::new(bus);
        app.set_device_summary("/dev/ttyUSB0", "115200 8N1 none");
        let _ = app.handle_key(key(KeyCode::Char('a'), KeyModifiers::CONTROL));
        let _ = app.handle_key(key(KeyCode::Char('m'), KeyModifiers::NONE));
        assert!(app.is_menu_open());
        let terminal = render_app(&mut app, 80, 24);
        insta::assert_snapshot!(terminal.backend());
    }

    #[test]
    fn main_screen_80x24_serial_port_setup_open_snapshot() {
        let bus = EventBus::new(64);
        let mut app = TuiApp::new(bus);
        app.set_device_summary("/dev/ttyUSB0", "115200 8N1 none");
        // Open menu (^A m), then Enter on "Serial port setup" (idx 0).
        let _ = app.handle_key(key(KeyCode::Char('a'), KeyModifiers::CONTROL));
        let _ = app.handle_key(key(KeyCode::Char('m'), KeyModifiers::NONE));
        let _ = app.handle_key(key(KeyCode::Enter, KeyModifiers::NONE));
        assert!(app.is_menu_open());
        let terminal = render_app(&mut app, 80, 24);
        insta::assert_snapshot!(terminal.backend());
    }

    #[test]
    fn enter_emits_cr_byte() {
        let bus = EventBus::new(64);
        let mut app = TuiApp::new(bus);
        let out = app.handle_key(key(KeyCode::Enter, KeyModifiers::NONE));
        assert!(matches!(out, Dispatch::TxBytes(ref b) if b == b"\r"));
    }

    #[test]
    fn push_toast_appears_in_queue() {
        let bus = EventBus::new(64);
        let mut app = TuiApp::new(bus);
        assert_eq!(app.toasts().visible_count(), 0);
        app.push_toast("saved", crate::toast::ToastLevel::Info);
        assert_eq!(app.toasts().visible_count(), 1);
        assert_eq!(app.toasts().visible()[0].message, "saved");
    }

    #[test]
    fn main_screen_80x24_with_toast_snapshot() {
        let bus = EventBus::new(64);
        let mut app = TuiApp::new(bus);
        app.set_device_summary("/dev/ttyUSB0", "115200 8N1 none");
        app.push_toast(
            "profile saved: ~/.config/rtcom/default.toml",
            crate::toast::ToastLevel::Info,
        );
        let terminal = render_app(&mut app, 80, 24);
        insta::assert_snapshot!(terminal.backend());
    }

    // ----- T20: ModalStyle render matrix -----

    /// Helper: open the configuration menu via `^A m`. Leaves the root
    /// dialog on top of the modal stack.
    fn open_menu(app: &mut TuiApp) {
        let _ = app.handle_key(key(KeyCode::Char('a'), KeyModifiers::CONTROL));
        let _ = app.handle_key(key(KeyCode::Char('m'), KeyModifiers::NONE));
        assert!(app.is_menu_open());
    }

    #[test]
    fn main_screen_120x40_empty_snapshot() {
        let bus = EventBus::new(64);
        let mut app = TuiApp::new(bus);
        app.set_device_summary("/dev/ttyUSB0", "115200 8N1 none");
        let terminal = render_app(&mut app, 120, 40);
        insta::assert_snapshot!(terminal.backend());
    }

    #[test]
    fn main_screen_120x40_menu_open_overlay_snapshot() {
        let bus = EventBus::new(64);
        let mut app = TuiApp::new(bus);
        app.set_device_summary("/dev/ttyUSB0", "115200 8N1 none");
        open_menu(&mut app);
        let terminal = render_app(&mut app, 120, 40);
        insta::assert_snapshot!(terminal.backend());
    }

    #[test]
    fn main_screen_80x24_menu_open_dimmed_overlay_snapshot() {
        let bus = EventBus::new(64);
        let mut app = TuiApp::new(bus);
        app.set_device_summary("/dev/ttyUSB0", "115200 8N1 none");
        app.set_modal_style(ModalStyle::DimmedOverlay);
        // Seed the serial pane so dimming is applied over visible
        // content (the DIM modifier is invisible in TestBackend's
        // text output, but the content itself should still appear).
        app.serial_pane_mut()
            .ingest(b"background line one\r\nbackground line two\r\n");
        open_menu(&mut app);
        let terminal = render_app(&mut app, 80, 24);
        insta::assert_snapshot!(terminal.backend());
    }

    #[test]
    fn main_screen_80x24_menu_open_fullscreen_snapshot() {
        let bus = EventBus::new(64);
        let mut app = TuiApp::new(bus);
        app.set_device_summary("/dev/ttyUSB0", "115200 8N1 none");
        app.set_modal_style(ModalStyle::Fullscreen);
        // Content ingested but should NOT appear: fullscreen hides the
        // serial pane entirely while the menu is open.
        app.serial_pane_mut().ingest(b"hidden background\r\n");
        open_menu(&mut app);
        let terminal = render_app(&mut app, 80, 24);
        insta::assert_snapshot!(terminal.backend());
    }

    // ----- T20: direct buffer inspection tests -----
    //
    // TestBackend's `Display` impl only emits cell symbols, so a
    // snapshot alone cannot distinguish DimmedOverlay from Overlay.
    // These tests inspect the rendered buffer to verify each
    // ModalStyle actually has the intended effect on cell styles.

    fn dim_probe_at(app: &mut TuiApp, width: u16, height: u16) -> ratatui::style::Style {
        use ratatui::layout::Position;
        let backend = TestBackend::new(width, height);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal.draw(|f| app.render(f)).unwrap();
        // (0, 1) = first column of the first body row (below the top
        // bar). Well outside the centered modal on an 80x24 screen.
        let buf = terminal.backend().buffer();
        buf.cell(Position::new(0, 1)).unwrap().style()
    }

    #[test]
    fn dimmed_overlay_actually_dims_body_cells() {
        let bus = EventBus::new(64);
        let mut app = TuiApp::new(bus);
        app.set_device_summary("/dev/ttyUSB0", "115200 8N1 none");
        app.set_modal_style(ModalStyle::DimmedOverlay);
        app.serial_pane_mut().ingest(b"hello\r\n");
        open_menu(&mut app);
        let style = dim_probe_at(&mut app, 80, 24);
        assert!(
            style.add_modifier.contains(Modifier::DIM),
            "expected DIM on body cell outside modal, got {style:?}"
        );
    }

    #[test]
    fn overlay_does_not_dim_body_cells() {
        let bus = EventBus::new(64);
        let mut app = TuiApp::new(bus);
        app.set_device_summary("/dev/ttyUSB0", "115200 8N1 none");
        // Default is Overlay.
        assert_eq!(app.current_modal_style, ModalStyle::Overlay);
        app.serial_pane_mut().ingest(b"hello\r\n");
        open_menu(&mut app);
        let style = dim_probe_at(&mut app, 80, 24);
        assert!(
            !style.add_modifier.contains(Modifier::DIM),
            "expected no DIM on body cell with Overlay style, got {style:?}"
        );
    }

    // ----- T24: scrollback keyboard + mouse handling -----

    fn seed_pane_with_rows(app: &mut TuiApp, rows: usize) {
        for i in 0..rows {
            app.serial_pane_mut()
                .ingest(format!("row {i}\r\n").as_bytes());
        }
    }

    #[test]
    fn shift_page_up_scrolls_up_half_screen() {
        let bus = EventBus::new(64);
        let mut app = TuiApp::new(bus);
        // Render once so body_rows is populated (22 for 80x24 main chrome).
        let _ = render_app(&mut app, 80, 24);
        seed_pane_with_rows(&mut app, 40);
        let out = app.handle_key(key(KeyCode::PageUp, KeyModifiers::SHIFT));
        assert!(matches!(out, Dispatch::Noop));
        // body.height == 22, half == 11.
        assert_eq!(app.serial_pane.scrollback_offset(), 11);
    }

    #[test]
    fn shift_page_down_scrolls_down_half_screen() {
        let bus = EventBus::new(64);
        let mut app = TuiApp::new(bus);
        let _ = render_app(&mut app, 80, 24);
        seed_pane_with_rows(&mut app, 200); // plenty of scrollback
        app.serial_pane_mut().scroll_up(50);
        let before = app.serial_pane.scrollback_offset();
        let out = app.handle_key(key(KeyCode::PageDown, KeyModifiers::SHIFT));
        assert!(matches!(out, Dispatch::Noop));
        // body.height on 80x24 = 22, half = 11. Exact arithmetic only
        // works when scroll_up didn't clamp against scrollback length,
        // which is why we seed 200 rows up-front.
        assert_eq!(app.serial_pane.scrollback_offset(), before - 11);
    }

    #[test]
    fn shift_up_scrolls_one_line() {
        let bus = EventBus::new(64);
        let mut app = TuiApp::new(bus);
        seed_pane_with_rows(&mut app, 40);
        let _ = app.handle_key(key(KeyCode::Up, KeyModifiers::SHIFT));
        assert_eq!(app.serial_pane.scrollback_offset(), 1);
        let _ = app.handle_key(key(KeyCode::Up, KeyModifiers::SHIFT));
        assert_eq!(app.serial_pane.scrollback_offset(), 2);
    }

    #[test]
    fn shift_down_scrolls_back_one_line() {
        let bus = EventBus::new(64);
        let mut app = TuiApp::new(bus);
        seed_pane_with_rows(&mut app, 40);
        app.serial_pane_mut().scroll_up(5);
        let _ = app.handle_key(key(KeyCode::Down, KeyModifiers::SHIFT));
        assert_eq!(app.serial_pane.scrollback_offset(), 4);
    }

    #[test]
    fn shift_home_and_end_jump_to_top_and_bottom() {
        let bus = EventBus::new(64);
        let mut app = TuiApp::new(bus);
        seed_pane_with_rows(&mut app, 40);
        let _ = app.handle_key(key(KeyCode::Home, KeyModifiers::SHIFT));
        assert!(app.serial_pane.is_scrolled());
        let _ = app.handle_key(key(KeyCode::End, KeyModifiers::SHIFT));
        assert_eq!(app.serial_pane.scrollback_offset(), 0);
    }

    #[test]
    fn plain_page_up_without_shift_does_not_scroll() {
        let bus = EventBus::new(64);
        let mut app = TuiApp::new(bus);
        seed_pane_with_rows(&mut app, 40);
        // PageUp without Shift has no wire encoding in `key_to_bytes`,
        // so it's Noop, but must not affect the scrollback offset.
        let _ = app.handle_key(key(KeyCode::PageUp, KeyModifiers::NONE));
        assert_eq!(app.serial_pane.scrollback_offset(), 0);
    }

    #[test]
    fn shift_scroll_keys_are_swallowed_when_menu_open() {
        let bus = EventBus::new(64);
        let mut app = TuiApp::new(bus);
        seed_pane_with_rows(&mut app, 40);
        // Open the menu first.
        let _ = app.handle_key(key(KeyCode::Char('a'), KeyModifiers::CONTROL));
        let _ = app.handle_key(key(KeyCode::Char('m'), KeyModifiers::NONE));
        assert!(app.is_menu_open());
        // Shift+PageUp while the menu is open: MUST NOT scroll the
        // serial pane — the menu-open branch runs first.
        let before = app.serial_pane.scrollback_offset();
        let _ = app.handle_key(key(KeyCode::PageUp, KeyModifiers::SHIFT));
        assert_eq!(app.serial_pane.scrollback_offset(), before);
    }

    const fn mouse(kind: MouseEventKind) -> MouseEvent {
        MouseEvent {
            kind,
            column: 10,
            row: 10,
            modifiers: KeyModifiers::NONE,
        }
    }

    #[test]
    fn mouse_wheel_up_scrolls_by_wheel_scroll_lines() {
        let bus = EventBus::new(64);
        let mut app = TuiApp::new(bus);
        seed_pane_with_rows(&mut app, 40);
        app.set_wheel_scroll_lines(5);
        let _ = app.handle_mouse(mouse(MouseEventKind::ScrollUp));
        assert_eq!(app.serial_pane.scrollback_offset(), 5);
        let _ = app.handle_mouse(mouse(MouseEventKind::ScrollUp));
        assert_eq!(app.serial_pane.scrollback_offset(), 10);
    }

    #[test]
    fn mouse_wheel_down_scrolls_back() {
        let bus = EventBus::new(64);
        let mut app = TuiApp::new(bus);
        seed_pane_with_rows(&mut app, 40);
        app.set_wheel_scroll_lines(3);
        app.serial_pane_mut().scroll_up(10);
        let _ = app.handle_mouse(mouse(MouseEventKind::ScrollDown));
        assert_eq!(app.serial_pane.scrollback_offset(), 7);
    }

    #[test]
    fn mouse_click_does_not_scroll() {
        let bus = EventBus::new(64);
        let mut app = TuiApp::new(bus);
        seed_pane_with_rows(&mut app, 40);
        let _ = app.handle_mouse(mouse(MouseEventKind::Down(
            crossterm::event::MouseButton::Left,
        )));
        assert_eq!(app.serial_pane.scrollback_offset(), 0);
    }

    #[test]
    fn mouse_wheel_ignored_when_menu_open() {
        let bus = EventBus::new(64);
        let mut app = TuiApp::new(bus);
        seed_pane_with_rows(&mut app, 40);
        let _ = app.handle_key(key(KeyCode::Char('a'), KeyModifiers::CONTROL));
        let _ = app.handle_key(key(KeyCode::Char('m'), KeyModifiers::NONE));
        assert!(app.is_menu_open());
        let _ = app.handle_mouse(mouse(MouseEventKind::ScrollUp));
        assert_eq!(app.serial_pane.scrollback_offset(), 0);
    }

    #[test]
    fn set_wheel_scroll_lines_clamps_zero_to_one() {
        let bus = EventBus::new(64);
        let mut app = TuiApp::new(bus);
        app.set_wheel_scroll_lines(0);
        seed_pane_with_rows(&mut app, 40);
        let _ = app.handle_mouse(mouse(MouseEventKind::ScrollUp));
        // Wheel must still move at least one line — 0 is clamped to 1.
        assert_eq!(app.serial_pane.scrollback_offset(), 1);
    }

    #[test]
    fn top_bar_shows_scroll_indicator_when_scrolled() {
        let bus = EventBus::new(64);
        let mut app = TuiApp::new(bus);
        app.set_device_summary("/dev/ttyUSB0", "115200 8N1 none");
        seed_pane_with_rows(&mut app, 40);
        app.serial_pane_mut().scroll_up(7);
        let terminal = render_app(&mut app, 80, 24);
        let rendered = format!("{}", terminal.backend());
        assert!(
            rendered.contains("[SCROLL \u{2191}7]"),
            "expected '[SCROLL ↑7]' in top bar, got:\n{rendered}"
        );
    }

    #[test]
    fn top_bar_hides_scroll_indicator_when_live() {
        let bus = EventBus::new(64);
        let mut app = TuiApp::new(bus);
        app.set_device_summary("/dev/ttyUSB0", "115200 8N1 none");
        seed_pane_with_rows(&mut app, 40);
        // Not scrolled; indicator must not appear.
        let terminal = render_app(&mut app, 80, 24);
        let rendered = format!("{}", terminal.backend());
        assert!(
            !rendered.contains("[SCROLL"),
            "unexpected scroll indicator in top bar:\n{rendered}"
        );
    }

    #[test]
    fn fullscreen_menu_hides_serial_pane_content() {
        let bus = EventBus::new(64);
        let mut app = TuiApp::new(bus);
        app.set_device_summary("/dev/ttyUSB0", "115200 8N1 none");
        app.set_modal_style(ModalStyle::Fullscreen);
        // Distinctive marker that would appear in the top-left of the
        // body if the serial pane were drawn.
        app.serial_pane_mut().ingest(b"ZZZZZ-secret-marker\r\n");
        open_menu(&mut app);
        let backend = TestBackend::new(80, 24);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal.draw(|f| app.render(f)).unwrap();
        let rendered = format!("{}", terminal.backend());
        assert!(
            !rendered.contains("ZZZZZ-secret-marker"),
            "Fullscreen menu should hide serial pane content, \
             but marker leaked into render:\n{rendered}"
        );
    }
}