Skip to main content

rtcom_tui/menu/
root.rs

1//! Top-level configuration menu.
2//!
3//! Seven items, arrow / vim navigation with wrap, Enter drills into
4//! child dialogs (placeholders until T14+). Esc or the "Exit menu"
5//! item closes the menu.
6
7use crossterm::event::{KeyCode, KeyEvent};
8use ratatui::{
9    buffer::Buffer,
10    layout::Rect,
11    style::{Modifier, Style},
12    text::{Line, Span},
13    widgets::{Block, Paragraph, Widget},
14};
15use rtcom_config::ModalStyle;
16use rtcom_core::{LineEndingConfig, ModemLineSnapshot, SerialConfig};
17
18use crate::{
19    menu::{
20        confirm::ConfirmDialog, line_endings::LineEndingsDialog, modem_control::ModemControlDialog,
21        screen_options::ScreenOptionsDialog, serial_port::SerialPortSetupDialog,
22    },
23    modal::{Dialog, DialogAction, DialogOutcome},
24};
25
26/// Index of the "Serial port setup" item; selecting it drills into
27/// the real [`SerialPortSetupDialog`] (T12).
28const SERIAL_PORT_SETUP_INDEX: usize = 0;
29/// Index of the "Line endings" item; selecting it drills into the
30/// real [`LineEndingsDialog`] (T13).
31const LINE_ENDINGS_INDEX: usize = 1;
32/// Index of the "Modem control" item; selecting it drills into the
33/// real [`ModemControlDialog`] (T14).
34const MODEM_CONTROL_INDEX: usize = 2;
35/// Index of the "Write profile" item; selecting it drills into a
36/// [`ConfirmDialog`] that emits [`DialogAction::WriteProfile`] on
37/// confirm (T15).
38const WRITE_PROFILE_INDEX: usize = 3;
39/// Index of the "Read profile" item; selecting it drills into a
40/// [`ConfirmDialog`] that emits [`DialogAction::ReadProfile`] on
41/// confirm (T15).
42const READ_PROFILE_INDEX: usize = 4;
43/// Index of the "Screen options" item; selecting it drills into the
44/// real [`ScreenOptionsDialog`] (T15).
45const SCREEN_OPTIONS_INDEX: usize = 5;
46
47/// Top-level configuration menu (the first real [`Dialog`] impl).
48///
49/// Owns a fixed list of seven entries, an integer cursor, a snapshot
50/// of the current [`SerialConfig`] / [`LineEndingConfig`] (passed on
51/// to sub-dialogs), and a rendering style. Emits
52/// [`DialogOutcome::Push`] for every non-exit selection and
53/// [`DialogOutcome::Close`] for Esc / "Exit menu".
54pub struct RootMenu {
55    items: &'static [&'static str],
56    selected: usize,
57    /// Snapshot of the live [`SerialConfig`]; forwarded to
58    /// [`SerialPortSetupDialog::new`] when the user drills in.
59    initial_config: SerialConfig,
60    /// Snapshot of the live [`LineEndingConfig`]; forwarded to
61    /// [`LineEndingsDialog::new`] when the user drills into the
62    /// "Line endings" row.
63    initial_line_endings: LineEndingConfig,
64    /// Snapshot of the live [`ModemLineSnapshot`]; forwarded to
65    /// [`ModemControlDialog::new`] when the user drills into the
66    /// "Modem control" row.
67    initial_modem: ModemLineSnapshot,
68    /// Snapshot of the live [`ModalStyle`]; forwarded to
69    /// [`ScreenOptionsDialog::new`] when the user drills into the
70    /// "Screen options" row (T15).
71    initial_modal_style: ModalStyle,
72    /// Short flag labels for every CLI argument that overrode a
73    /// profile value at startup (e.g. `-b`, `-d`,
74    /// `--omap/--imap/--emap`). Forwarded to
75    /// [`SerialPortSetupDialog::new`] so the dialog can render a hint
76    /// line when the list is non-empty. Empty disables the hint.
77    cli_overrides: Vec<&'static str>,
78}
79
80const ITEMS: &[&str] = &[
81    "Serial port setup", // 0
82    "Line endings",      // 1
83    "Modem control",     // 2
84    // visual separator between config and profile groups
85    "Write profile", // 3
86    "Read profile",  // 4
87    // visual separator between profile and screen groups
88    "Screen options", // 5
89    "Exit menu",      // 6
90];
91
92/// Index of the "Exit menu" sentinel; selecting it closes the menu.
93const EXIT_INDEX: usize = 6;
94
95/// Indices after which a visual separator row is drawn.
96const SEPARATORS_AFTER: &[usize] = &[2, 4];
97
98impl RootMenu {
99    /// Construct a root menu with the cursor on the first item and
100    /// snapshotting `initial_config`, `initial_line_endings`,
101    /// `initial_modem`, `initial_modal_style`, and `cli_overrides` for
102    /// forwarding to sub-dialogs ([`SerialPortSetupDialog`],
103    /// [`LineEndingsDialog`], [`ModemControlDialog`], and
104    /// [`ScreenOptionsDialog`]).
105    ///
106    /// `cli_overrides` carries short flag labels (`-b`, `-d`, ...)
107    /// for every CLI argument that overrode a profile value at
108    /// startup. Pass `Vec::new()` when no flags override anything;
109    /// the [`SerialPortSetupDialog`] skips its hint line in that case.
110    #[must_use]
111    pub const fn new(
112        initial_config: SerialConfig,
113        initial_line_endings: LineEndingConfig,
114        initial_modem: ModemLineSnapshot,
115        initial_modal_style: ModalStyle,
116        cli_overrides: Vec<&'static str>,
117    ) -> Self {
118        Self {
119            items: ITEMS,
120            selected: 0,
121            initial_config,
122            initial_line_endings,
123            initial_modem,
124            initial_modal_style,
125            cli_overrides,
126        }
127    }
128
129    /// Current cursor position (0-based).
130    #[must_use]
131    pub const fn selected(&self) -> usize {
132        self.selected
133    }
134
135    /// Items in display order.
136    #[must_use]
137    pub const fn items(&self) -> &'static [&'static str] {
138        self.items
139    }
140
141    /// Move the cursor up one row, wrapping to the last item when
142    /// called on the first.
143    const fn move_up(&mut self) {
144        if self.selected == 0 {
145            self.selected = self.items.len() - 1;
146        } else {
147            self.selected -= 1;
148        }
149    }
150
151    /// Move the cursor down one row, wrapping to the first item when
152    /// called on the last.
153    const fn move_down(&mut self) {
154        if self.selected + 1 >= self.items.len() {
155            self.selected = 0;
156        } else {
157            self.selected += 1;
158        }
159    }
160
161    /// Handle the Enter key. Exit item closes; every other row pushes
162    /// its associated dialog: [`SerialPortSetupDialog`] (T12),
163    /// [`LineEndingsDialog`] (T13), [`ModemControlDialog`] (T14),
164    /// [`ConfirmDialog`] (write/read profile, T15),
165    /// [`ScreenOptionsDialog`] (T15).
166    fn activate(&self) -> DialogOutcome {
167        match self.selected {
168            EXIT_INDEX => DialogOutcome::Close,
169            SERIAL_PORT_SETUP_INDEX => DialogOutcome::Push(Box::new(SerialPortSetupDialog::new(
170                self.initial_config,
171                self.cli_overrides.clone(),
172            ))),
173            LINE_ENDINGS_INDEX => {
174                DialogOutcome::Push(Box::new(LineEndingsDialog::new(self.initial_line_endings)))
175            }
176            MODEM_CONTROL_INDEX => {
177                DialogOutcome::Push(Box::new(ModemControlDialog::new(self.initial_modem)))
178            }
179            WRITE_PROFILE_INDEX => DialogOutcome::Push(Box::new(ConfirmDialog::new(
180                "Write profile",
181                "Save current configuration to profile file on disk?",
182                DialogAction::WriteProfile,
183            ))),
184            READ_PROFILE_INDEX => DialogOutcome::Push(Box::new(ConfirmDialog::new(
185                "Read profile",
186                "Reload profile from disk? Unsaved changes will be lost.",
187                DialogAction::ReadProfile,
188            ))),
189            SCREEN_OPTIONS_INDEX => {
190                DialogOutcome::Push(Box::new(ScreenOptionsDialog::new(self.initial_modal_style)))
191            }
192            _ => {
193                let title = self.items[self.selected];
194                DialogOutcome::Push(Box::new(crate::menu::PlaceholderDialog::new(title)))
195            }
196        }
197    }
198}
199
200impl Dialog for RootMenu {
201    #[allow(
202        clippy::unnecessary_literal_bound,
203        reason = "trait signature must remain &str"
204    )]
205    fn title(&self) -> &str {
206        "Configuration"
207    }
208
209    fn render(&self, area: Rect, buf: &mut Buffer) {
210        let block = Block::bordered().title("Configuration");
211        let inner = block.inner(area);
212        block.render(area, buf);
213
214        // Build one visual row per item, interleaving separators.
215        let mut lines: Vec<Line<'_>> =
216            Vec::with_capacity(self.items.len() + SEPARATORS_AFTER.len());
217        for (idx, item) in self.items.iter().enumerate() {
218            let style = if idx == self.selected {
219                Style::default().add_modifier(Modifier::REVERSED)
220            } else {
221                Style::default()
222            };
223            let prefix = if idx == self.selected { "> " } else { "  " };
224            lines.push(Line::from(vec![Span::styled(
225                format!("{prefix}{item}"),
226                style,
227            )]));
228            if SEPARATORS_AFTER.contains(&idx) {
229                let width = usize::from(inner.width);
230                lines.push(Line::from(Span::styled(
231                    "-".repeat(width),
232                    Style::default().add_modifier(Modifier::DIM),
233                )));
234            }
235        }
236
237        Paragraph::new(lines).render(inner, buf);
238    }
239
240    fn handle_key(&mut self, key: KeyEvent) -> DialogOutcome {
241        match key.code {
242            KeyCode::Up | KeyCode::Char('k') => {
243                self.move_up();
244                DialogOutcome::Consumed
245            }
246            KeyCode::Down | KeyCode::Char('j') => {
247                self.move_down();
248                DialogOutcome::Consumed
249            }
250            KeyCode::Esc => DialogOutcome::Close,
251            KeyCode::Enter => self.activate(),
252            _ => DialogOutcome::Consumed,
253        }
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
261
262    const fn key(code: KeyCode) -> KeyEvent {
263        KeyEvent::new(code, KeyModifiers::NONE)
264    }
265
266    fn menu() -> RootMenu {
267        RootMenu::new(
268            SerialConfig::default(),
269            LineEndingConfig::default(),
270            ModemLineSnapshot::default(),
271            ModalStyle::default(),
272            Vec::new(),
273        )
274    }
275
276    #[test]
277    fn root_menu_starts_on_first_item() {
278        let m = menu();
279        assert_eq!(m.selected(), 0);
280    }
281
282    #[test]
283    fn root_menu_down_moves_selection() {
284        let mut m = menu();
285        m.handle_key(key(KeyCode::Down));
286        assert_eq!(m.selected(), 1);
287    }
288
289    #[test]
290    fn root_menu_up_wraps_from_first() {
291        let mut m = menu();
292        m.handle_key(key(KeyCode::Up));
293        assert_eq!(m.selected(), 6);
294    }
295
296    #[test]
297    fn root_menu_down_wraps_from_last() {
298        let mut m = menu();
299        for _ in 0..6 {
300            m.handle_key(key(KeyCode::Down));
301        }
302        assert_eq!(m.selected(), 6);
303        m.handle_key(key(KeyCode::Down));
304        assert_eq!(m.selected(), 0);
305    }
306
307    #[test]
308    fn j_k_vim_bindings_work() {
309        let mut m = menu();
310        m.handle_key(key(KeyCode::Char('j')));
311        assert_eq!(m.selected(), 1);
312        m.handle_key(key(KeyCode::Char('k')));
313        assert_eq!(m.selected(), 0);
314    }
315
316    #[test]
317    fn enter_on_first_item_pushes_serial_setup_dialog() {
318        let mut m = menu();
319        let out = m.handle_key(key(KeyCode::Enter));
320        match out {
321            DialogOutcome::Push(d) => assert_eq!(d.title(), "Serial port setup"),
322            _ => panic!("expected Push"),
323        }
324    }
325
326    #[test]
327    fn enter_on_exit_closes_menu() {
328        let mut m = menu();
329        for _ in 0..6 {
330            m.handle_key(key(KeyCode::Down));
331        }
332        assert_eq!(m.selected(), 6);
333        let out = m.handle_key(key(KeyCode::Enter));
334        assert!(matches!(out, DialogOutcome::Close));
335    }
336
337    #[test]
338    fn esc_closes() {
339        let mut m = menu();
340        let out = m.handle_key(key(KeyCode::Esc));
341        assert!(matches!(out, DialogOutcome::Close));
342    }
343
344    #[test]
345    fn unknown_key_is_consumed_no_movement() {
346        let mut m = menu();
347        let out = m.handle_key(key(KeyCode::Char('x')));
348        assert!(matches!(out, DialogOutcome::Consumed));
349        assert_eq!(m.selected(), 0);
350    }
351
352    #[test]
353    fn new_takes_serial_config() {
354        // Compile-time check that RootMenu::new accepts a SerialConfig.
355        let cfg = SerialConfig {
356            baud_rate: 9600,
357            ..SerialConfig::default()
358        };
359        let m = RootMenu::new(
360            cfg,
361            LineEndingConfig::default(),
362            ModemLineSnapshot::default(),
363            ModalStyle::default(),
364            Vec::new(),
365        );
366        assert_eq!(m.selected(), 0);
367    }
368
369    #[test]
370    fn enter_on_line_endings_pushes_line_endings_dialog() {
371        let mut m = menu();
372        // cursor=0 is Serial port. Move to 1 (Line endings).
373        m.handle_key(key(KeyCode::Down));
374        let out = m.handle_key(key(KeyCode::Enter));
375        match out {
376            DialogOutcome::Push(d) => assert_eq!(d.title(), "Line endings"),
377            _ => panic!("expected Push"),
378        }
379    }
380
381    #[test]
382    fn enter_on_modem_control_pushes_modem_control_dialog() {
383        let mut m = menu();
384        for _ in 0..2 {
385            m.handle_key(key(KeyCode::Down));
386        }
387        let out = m.handle_key(key(KeyCode::Enter));
388        match out {
389            DialogOutcome::Push(d) => assert_eq!(d.title(), "Modem control"),
390            _ => panic!("expected Push"),
391        }
392    }
393
394    #[test]
395    fn enter_on_write_profile_pushes_confirm_dialog() {
396        let mut m = menu();
397        for _ in 0..3 {
398            m.handle_key(key(KeyCode::Down));
399        }
400        let out = m.handle_key(key(KeyCode::Enter));
401        match out {
402            DialogOutcome::Push(d) => assert_eq!(d.title(), "Write profile"),
403            _ => panic!("expected Push"),
404        }
405    }
406
407    #[test]
408    fn enter_on_read_profile_pushes_confirm_dialog() {
409        let mut m = menu();
410        for _ in 0..4 {
411            m.handle_key(key(KeyCode::Down));
412        }
413        let out = m.handle_key(key(KeyCode::Enter));
414        match out {
415            DialogOutcome::Push(d) => assert_eq!(d.title(), "Read profile"),
416            _ => panic!("expected Push"),
417        }
418    }
419
420    #[test]
421    fn enter_on_screen_options_pushes_screen_options_dialog() {
422        let mut m = menu();
423        for _ in 0..5 {
424            m.handle_key(key(KeyCode::Down));
425        }
426        let out = m.handle_key(key(KeyCode::Enter));
427        match out {
428            DialogOutcome::Push(d) => assert_eq!(d.title(), "Screen options"),
429            _ => panic!("expected Push"),
430        }
431    }
432}