1use 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
27pub 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_config: SerialConfig,
48 current_line_endings: LineEndingConfig,
54 current_modem: ModemLineSnapshot,
61 current_modal_style: ModalStyle,
67 toasts: ToastQueue,
73 current_cli_overrides: Vec<&'static str>,
81 wheel_scroll_lines: u16,
86 body_rows: u16,
91}
92
93impl TuiApp {
94 #[must_use]
102 pub fn new(bus: EventBus) -> Self {
103 Self {
104 bus,
105 menu_open: false,
106 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 pub fn set_wheel_scroll_lines(&mut self, n: u16) {
131 self.wheel_scroll_lines = n.max(1);
132 }
133
134 pub fn set_cli_overrides(&mut self, fields: Vec<&'static str>) {
142 self.current_cli_overrides = fields;
143 }
144
145 pub fn push_toast(&mut self, message: impl Into<String>, level: ToastLevel) {
148 self.toasts.push(message, level);
149 }
150
151 pub const fn toasts_mut(&mut self) -> &mut ToastQueue {
154 &mut self.toasts
155 }
156
157 #[must_use]
159 pub const fn toasts(&self) -> &ToastQueue {
160 &self.toasts
161 }
162
163 pub const fn set_serial_config(&mut self, cfg: SerialConfig) {
169 self.current_config = cfg;
170 }
171
172 pub const fn set_line_endings(&mut self, le: LineEndingConfig) {
179 self.current_line_endings = le;
180 }
181
182 pub const fn set_modem_lines(&mut self, snapshot: ModemLineSnapshot) {
189 self.current_modem = snapshot;
190 }
191
192 pub const fn set_modal_style(&mut self, style: ModalStyle) {
199 self.current_modal_style = style;
200 }
201
202 #[must_use]
204 pub const fn is_menu_open(&self) -> bool {
205 self.menu_open
206 }
207
208 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 pub fn set_config_summary(&mut self, config_summary: impl Into<String>) {
227 self.config_summary = config_summary.into();
228 }
229
230 pub const fn serial_pane_mut(&mut self) -> &mut SerialPane {
235 &mut self.serial_pane
236 }
237
238 #[allow(dead_code)]
240 pub(crate) const fn bus(&self) -> &EventBus {
241 &self.bus
242 }
243
244 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 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 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 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 pub fn handle_mouse(&mut self, ev: MouseEvent) -> Dispatch {
365 if self.menu_open {
366 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 _ => {}
383 }
384 Dispatch::Noop
385 }
386
387 pub fn render(&mut self, f: &mut Frame<'_>) {
408 let area = f.area();
409 let (top, body, bottom) = main_chrome(area);
410
411 if body.height > 0 && body.width > 0 {
413 self.serial_pane.resize(body.height, body.width);
414 }
415 self.body_rows = body.height;
419
420 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 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 let in_fullscreen_menu =
448 self.menu_open && self.current_modal_style == ModalStyle::Fullscreen;
449
450 if !in_fullscreen_menu {
451 let term_widget = PseudoTerminal::new(self.serial_pane.screen());
453 f.render_widget(term_widget, body);
454
455 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 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 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 self.toasts.tick();
491 if !self.toasts.is_empty() {
492 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 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 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 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 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 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 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 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 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 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 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 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 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 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); 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 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 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 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 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 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 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 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}