Skip to main content

rtcom_tui/menu/
modem_control.rs

1//! Modem-control dialog — immediate-action menu for toggling the
2//! DTR / RTS output lines and sending a line break.
3//!
4//! Unlike the [`SerialPortSetupDialog`](crate::menu::SerialPortSetupDialog)
5//! (T12) and [`LineEndingsDialog`](crate::menu::LineEndingsDialog) (T13),
6//! this dialog does not edit a pending configuration struct: every row
7//! fires an action the moment the user presses `Enter`. The read-only
8//! "current output lines" display at the top is seeded from whatever
9//! [`ModemLineSnapshot`] the outer app passes in at construction time
10//! and is not refreshed while the dialog is open (v0.2 scope limit —
11//! proper live polling is follow-up work).
12//!
13//! The dialog stays open after an action: this matches minicom's
14//! modem-control menu behaviour and lets the user fire several actions
15//! in a row without re-opening. `Esc` / `Enter` on `[Close]` dismisses.
16
17use crossterm::event::{KeyCode, KeyEvent};
18use ratatui::{
19    buffer::Buffer,
20    layout::Rect,
21    style::{Modifier, Style},
22    text::{Line, Span},
23    widgets::{Block, Paragraph, Widget},
24};
25
26use rtcom_core::ModemLineSnapshot;
27
28use crate::modal::{centred_rect, Dialog, DialogAction, DialogOutcome};
29
30/// Index of the `Raise DTR` action row.
31const ACTION_RAISE_DTR: usize = 0;
32/// Index of the `Lower DTR` action row.
33const ACTION_LOWER_DTR: usize = 1;
34/// Index of the `Raise RTS` action row.
35const ACTION_RAISE_RTS: usize = 2;
36/// Index of the `Lower RTS` action row.
37const ACTION_LOWER_RTS: usize = 3;
38/// Index of the `Send break (250 ms)` action row.
39const ACTION_SEND_BREAK: usize = 4;
40/// Index of the `[Close]` row.
41const ACTION_CLOSE: usize = 5;
42
43/// Total cursor slots (5 actions + close).
44const CURSOR_MAX: usize = 6;
45
46/// Immediate-action dialog for toggling DTR / RTS and sending a line
47/// break.
48///
49/// Stores a read-only [`ModemLineSnapshot`] for the header display and
50/// an integer cursor covering six action rows. Emits
51/// [`DialogAction::SetDtr`] / [`DialogAction::SetRts`] /
52/// [`DialogAction::SendBreak`] on `Enter` over an action row; emits
53/// [`DialogOutcome::Close`] on `Esc` or `Enter` over `[Close]`.
54pub struct ModemControlDialog {
55    current: ModemLineSnapshot,
56    cursor: usize,
57}
58
59impl ModemControlDialog {
60    /// Construct a dialog displaying `current` as the read-only
61    /// "current output lines" snapshot, cursor on the first action
62    /// (`Raise DTR`).
63    #[must_use]
64    pub const fn new(current: ModemLineSnapshot) -> Self {
65        Self {
66            current,
67            cursor: ACTION_RAISE_DTR,
68        }
69    }
70
71    /// Current cursor position. Valid range is `0..6`: `0..=4` select
72    /// an action row, `5` selects the `[Close]` button.
73    #[must_use]
74    pub const fn cursor(&self) -> usize {
75        self.cursor
76    }
77
78    /// Read-only snapshot of the modem output lines as known to rtcom
79    /// at the time this dialog was constructed.
80    #[must_use]
81    pub const fn current_lines(&self) -> &ModemLineSnapshot {
82        &self.current
83    }
84
85    /// Move the cursor up one row (wraps).
86    const fn move_up(&mut self) {
87        self.cursor = if self.cursor == 0 {
88            CURSOR_MAX - 1
89        } else {
90            self.cursor - 1
91        };
92    }
93
94    /// Move the cursor down one row (wraps).
95    const fn move_down(&mut self) {
96        self.cursor = (self.cursor + 1) % CURSOR_MAX;
97    }
98
99    /// Handle `Enter` by dispatching the action under the cursor.
100    const fn activate(&self) -> DialogOutcome {
101        match self.cursor {
102            ACTION_RAISE_DTR => DialogOutcome::Action(DialogAction::SetDtr(true)),
103            ACTION_LOWER_DTR => DialogOutcome::Action(DialogAction::SetDtr(false)),
104            ACTION_RAISE_RTS => DialogOutcome::Action(DialogAction::SetRts(true)),
105            ACTION_LOWER_RTS => DialogOutcome::Action(DialogAction::SetRts(false)),
106            ACTION_SEND_BREAK => DialogOutcome::Action(DialogAction::SendBreak),
107            ACTION_CLOSE => DialogOutcome::Close,
108            _ => DialogOutcome::Consumed,
109        }
110    }
111
112    /// Build the rendered row for an action, applying the reversed
113    /// style when the cursor is on it.
114    fn action_line(&self, idx: usize, label: &'static str) -> Line<'_> {
115        let selected = self.cursor == idx;
116        let prefix = if selected { "> " } else { "  " };
117        let text = format!("{prefix}{label}");
118        if selected {
119            Line::from(Span::styled(
120                text,
121                Style::default().add_modifier(Modifier::REVERSED),
122            ))
123        } else {
124            Line::from(Span::raw(text))
125        }
126    }
127
128    /// Build the rendered row for the `[Close]` button.
129    fn close_line(&self) -> Line<'_> {
130        let selected = self.cursor == ACTION_CLOSE;
131        let prefix = if selected { "> " } else { "  " };
132        let text = format!("{prefix}{:<18} {}", "[Close]", "(Esc)");
133        if selected {
134            Line::from(Span::styled(
135                text,
136                Style::default().add_modifier(Modifier::REVERSED),
137            ))
138        } else {
139            Line::from(Span::raw(text))
140        }
141    }
142}
143
144impl Dialog for ModemControlDialog {
145    #[allow(
146        clippy::unnecessary_literal_bound,
147        reason = "trait signature must remain &str"
148    )]
149    fn title(&self) -> &str {
150        "Modem control"
151    }
152
153    fn preferred_size(&self, outer: Rect) -> Rect {
154        centred_rect(outer, 40, 18)
155    }
156
157    fn render(&self, area: Rect, buf: &mut Buffer) {
158        let block = Block::bordered().title("Modem control");
159        let inner = block.inner(area);
160        block.render(area, buf);
161
162        let sep_width = usize::from(inner.width);
163        let sep_line = Line::from(Span::styled(
164            "-".repeat(sep_width),
165            Style::default().add_modifier(Modifier::DIM),
166        ));
167
168        let dtr_mark = if self.current.dtr { "*" } else { "o" };
169        let rts_mark = if self.current.rts { "*" } else { "o" };
170
171        let lines = vec![
172            Line::from(Span::raw("")),
173            Line::from(Span::raw("  Current output lines:")),
174            Line::from(Span::raw(format!("    DTR: {dtr_mark}"))),
175            Line::from(Span::raw(format!("    RTS: {rts_mark}"))),
176            Line::from(Span::raw("")),
177            sep_line,
178            Line::from(Span::raw("")),
179            self.action_line(ACTION_RAISE_DTR, "Raise DTR"),
180            self.action_line(ACTION_LOWER_DTR, "Lower DTR"),
181            self.action_line(ACTION_RAISE_RTS, "Raise RTS"),
182            self.action_line(ACTION_LOWER_RTS, "Lower RTS"),
183            self.action_line(ACTION_SEND_BREAK, "Send break (250 ms)"),
184            Line::from(Span::raw("")),
185            self.close_line(),
186        ];
187
188        Paragraph::new(lines).render(inner, buf);
189    }
190
191    fn handle_key(&mut self, key: KeyEvent) -> DialogOutcome {
192        match key.code {
193            KeyCode::Up | KeyCode::Char('k') => {
194                self.move_up();
195                DialogOutcome::Consumed
196            }
197            KeyCode::Down | KeyCode::Char('j') => {
198                self.move_down();
199                DialogOutcome::Consumed
200            }
201            KeyCode::Esc => DialogOutcome::Close,
202            KeyCode::Enter => self.activate(),
203            _ => DialogOutcome::Consumed,
204        }
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::modal::DialogAction;
212    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
213    use rtcom_core::ModemLineSnapshot;
214
215    const fn key(code: KeyCode) -> KeyEvent {
216        KeyEvent::new(code, KeyModifiers::NONE)
217    }
218
219    fn default_dialog() -> ModemControlDialog {
220        ModemControlDialog::new(ModemLineSnapshot::default())
221    }
222
223    #[test]
224    fn starts_with_raise_dtr_selected() {
225        assert_eq!(default_dialog().cursor(), 0);
226    }
227
228    #[test]
229    fn enter_raise_dtr_emits_set_dtr_true() {
230        let mut d = default_dialog();
231        let out = d.handle_key(key(KeyCode::Enter));
232        assert!(matches!(
233            out,
234            DialogOutcome::Action(DialogAction::SetDtr(true))
235        ));
236    }
237
238    #[test]
239    fn enter_lower_dtr_emits_set_dtr_false() {
240        let mut d = default_dialog();
241        d.handle_key(key(KeyCode::Down));
242        let out = d.handle_key(key(KeyCode::Enter));
243        assert!(matches!(
244            out,
245            DialogOutcome::Action(DialogAction::SetDtr(false))
246        ));
247    }
248
249    #[test]
250    fn enter_raise_rts_emits_set_rts_true() {
251        let mut d = default_dialog();
252        for _ in 0..2 {
253            d.handle_key(key(KeyCode::Down));
254        }
255        let out = d.handle_key(key(KeyCode::Enter));
256        assert!(matches!(
257            out,
258            DialogOutcome::Action(DialogAction::SetRts(true))
259        ));
260    }
261
262    #[test]
263    fn enter_lower_rts_emits_set_rts_false() {
264        let mut d = default_dialog();
265        for _ in 0..3 {
266            d.handle_key(key(KeyCode::Down));
267        }
268        let out = d.handle_key(key(KeyCode::Enter));
269        assert!(matches!(
270            out,
271            DialogOutcome::Action(DialogAction::SetRts(false))
272        ));
273    }
274
275    #[test]
276    fn enter_send_break_emits_send_break() {
277        let mut d = default_dialog();
278        for _ in 0..4 {
279            d.handle_key(key(KeyCode::Down));
280        }
281        let out = d.handle_key(key(KeyCode::Enter));
282        assert!(matches!(
283            out,
284            DialogOutcome::Action(DialogAction::SendBreak)
285        ));
286    }
287
288    #[test]
289    fn enter_on_close_closes() {
290        let mut d = default_dialog();
291        for _ in 0..5 {
292            d.handle_key(key(KeyCode::Down));
293        }
294        let out = d.handle_key(key(KeyCode::Enter));
295        assert!(matches!(out, DialogOutcome::Close));
296    }
297
298    #[test]
299    fn esc_closes() {
300        let mut d = default_dialog();
301        let out = d.handle_key(key(KeyCode::Esc));
302        assert!(matches!(out, DialogOutcome::Close));
303    }
304
305    #[test]
306    fn cursor_wraps() {
307        let mut d = default_dialog();
308        d.handle_key(key(KeyCode::Up));
309        assert_eq!(d.cursor(), 5);
310        d.handle_key(key(KeyCode::Down));
311        assert_eq!(d.cursor(), 0);
312    }
313
314    #[test]
315    fn preferred_size_40x18() {
316        use ratatui::layout::Rect;
317        let d = default_dialog();
318        let outer = Rect {
319            x: 0,
320            y: 0,
321            width: 80,
322            height: 24,
323        };
324        let pref = d.preferred_size(outer);
325        assert_eq!(pref.width, 40);
326        assert_eq!(pref.height, 18);
327    }
328
329    #[test]
330    fn dialog_shows_current_dtr_rts_in_title_area() {
331        // Just verify constructor stores the passed snapshot.
332        let snap = ModemLineSnapshot {
333            dtr: true,
334            rts: false,
335        };
336        let d = ModemControlDialog::new(snap);
337        assert!(d.current_lines().dtr);
338        assert!(!d.current_lines().rts);
339    }
340}