Skip to main content

rtcom_tui/menu/
serial_port.rs

1//! Serial-port setup dialog — the first real configuration sub-dialog.
2//!
3//! Lets the user edit the five link parameters of a [`SerialConfig`]
4//! (baud / data bits / stop bits / parity / flow control) and either
5//! apply them to the live session (`F2`), apply + persist to profile
6//! (`F10`), or cancel. Pushed by [`crate::menu::RootMenu`] when the
7//! user selects "Serial port setup".
8//!
9//! T12 focuses on the state machine + outcomes; the visual polish
10//! (inline edit cursor, per-field validation toasts) arrives in T22.
11
12use crossterm::event::{KeyCode, KeyEvent};
13use ratatui::{
14    buffer::Buffer,
15    layout::Rect,
16    style::{Modifier, Style},
17    text::{Line, Span},
18    widgets::{Block, Paragraph, Widget},
19};
20
21use rtcom_core::{DataBits, FlowControl, Parity, SerialConfig, StopBits};
22
23use crate::modal::{centred_rect, Dialog, DialogAction, DialogOutcome};
24
25/// Index of the first field row (baud rate).
26const FIELD_BAUD: usize = 0;
27/// Index of the data-bits field row.
28const FIELD_DATA_BITS: usize = 1;
29/// Index of the stop-bits field row.
30const FIELD_STOP_BITS: usize = 2;
31/// Index of the parity field row.
32const FIELD_PARITY: usize = 3;
33/// Index of the flow-control field row.
34const FIELD_FLOW: usize = 4;
35
36/// Index of the `[Apply live]` action button.
37const ACTION_APPLY_LIVE: usize = 5;
38/// Index of the `[Apply + Save]` action button.
39const ACTION_APPLY_SAVE: usize = 6;
40/// Index of the `[Cancel]` action button.
41const ACTION_CANCEL: usize = 7;
42
43/// Total cursor slots (5 fields + 3 actions).
44const CURSOR_MAX: usize = 8;
45
46/// Edit mode for the dialog.
47///
48/// When [`EditState::Idle`] the dialog is navigating fields. When
49/// [`EditState::EditingNumeric`] the user is typing digits into a
50/// numeric field; `Enter` commits, `Esc` cancels.
51#[derive(Debug, Clone)]
52enum EditState {
53    /// Not editing — arrow / vim keys move the field cursor.
54    Idle,
55    /// Typing digits into the numeric field identified by the dialog's
56    /// current `cursor` position. The buffer holds the raw keystrokes;
57    /// parsing happens on commit.
58    EditingNumeric(String),
59}
60
61/// Serial port setup dialog.
62///
63/// Holds a snapshot of the initial [`SerialConfig`] and a mutable
64/// `pending` copy that tracks the user's edits. Emits
65/// [`DialogAction::ApplyLive`] on `F2` / `Enter` on `[Apply live]`,
66/// [`DialogAction::ApplyAndSave`] on `F10` / `Enter` on
67/// `[Apply + Save]`, and [`DialogOutcome::Close`] on `Esc` / `Enter`
68/// on `[Cancel]`.
69///
70/// After emitting an `Action`, the dialog stays open — T17 wires the
71/// outer `TuiApp` to pop the stack once the action has been applied.
72pub struct SerialPortSetupDialog {
73    #[allow(dead_code, reason = "reserved for T17 revert-on-cancel path")]
74    initial: SerialConfig,
75    pending: SerialConfig,
76    cursor: usize,
77    edit_state: EditState,
78    /// Flag labels (`-b`, `-d`, `-s`, `-p`, `-f`,
79    /// `--omap/--imap/--emap`) for every CLI argument that overrode a
80    /// profile value at startup. When non-empty, the dialog renders a
81    /// DIM hint line below the action buttons explaining why the
82    /// on-screen values may not match the saved profile. Empty
83    /// suppresses the hint entirely.
84    cli_overrides: Vec<&'static str>,
85}
86
87impl SerialPortSetupDialog {
88    /// Construct a dialog seeded with `initial_config`. The cursor
89    /// starts on the baud-rate row in field-navigation (idle) mode.
90    ///
91    /// `cli_overrides` carries flag labels for CLI args that
92    /// overrode a profile value at startup; when non-empty a hint
93    /// line renders below the action buttons.
94    #[must_use]
95    pub const fn new(initial_config: SerialConfig, cli_overrides: Vec<&'static str>) -> Self {
96        Self {
97            initial: initial_config,
98            pending: initial_config,
99            cursor: FIELD_BAUD,
100            edit_state: EditState::Idle,
101            cli_overrides,
102        }
103    }
104
105    /// Whether the dialog will render a CLI-override hint line at the
106    /// bottom (i.e. its `cli_overrides` list is non-empty).
107    #[must_use]
108    pub const fn has_cli_override_hint(&self) -> bool {
109        !self.cli_overrides.is_empty()
110    }
111
112    /// Current cursor position. Valid range is `0..8`: indices `0..=4`
113    /// select a field (baud / data bits / stop bits / parity / flow
114    /// control), and `5..=7` select one of the action buttons
115    /// (Apply live / Apply + Save / Cancel).
116    #[must_use]
117    pub const fn cursor(&self) -> usize {
118        self.cursor
119    }
120
121    /// True while the user is typing into a numeric field.
122    #[must_use]
123    pub const fn is_editing(&self) -> bool {
124        matches!(self.edit_state, EditState::EditingNumeric(_))
125    }
126
127    /// The currently pending [`SerialConfig`]; reflects every committed
128    /// edit since construction.
129    #[must_use]
130    pub const fn pending(&self) -> &SerialConfig {
131        &self.pending
132    }
133
134    /// Move the cursor up one row (wraps).
135    const fn move_up(&mut self) {
136        self.cursor = if self.cursor == 0 {
137            CURSOR_MAX - 1
138        } else {
139            self.cursor - 1
140        };
141    }
142
143    /// Move the cursor down one row (wraps).
144    const fn move_down(&mut self) {
145        self.cursor = (self.cursor + 1) % CURSOR_MAX;
146    }
147
148    /// Handle `Enter` while in field-navigation mode.
149    fn activate(&mut self) -> DialogOutcome {
150        match self.cursor {
151            FIELD_BAUD | FIELD_DATA_BITS | FIELD_STOP_BITS => {
152                self.edit_state = EditState::EditingNumeric(String::new());
153                DialogOutcome::Consumed
154            }
155            FIELD_PARITY => {
156                self.pending.parity = next_parity(self.pending.parity);
157                DialogOutcome::Consumed
158            }
159            FIELD_FLOW => {
160                self.pending.flow_control = next_flow(self.pending.flow_control);
161                DialogOutcome::Consumed
162            }
163            ACTION_APPLY_LIVE => DialogOutcome::Action(DialogAction::ApplyLive(self.pending)),
164            ACTION_APPLY_SAVE => DialogOutcome::Action(DialogAction::ApplyAndSave(self.pending)),
165            ACTION_CANCEL => DialogOutcome::Close,
166            _ => DialogOutcome::Consumed,
167        }
168    }
169
170    /// Attempt to commit the in-progress numeric edit into `pending`.
171    /// On parse failure the pending value is left unchanged.
172    fn commit_numeric_edit(&mut self) {
173        let EditState::EditingNumeric(ref buf) = self.edit_state else {
174            return;
175        };
176        let buf = buf.clone();
177        self.edit_state = EditState::Idle;
178        if buf.is_empty() {
179            return;
180        }
181        match self.cursor {
182            FIELD_BAUD => {
183                if let Ok(n) = buf.parse::<u32>() {
184                    if n > 0 {
185                        self.pending.baud_rate = n;
186                    }
187                }
188            }
189            FIELD_DATA_BITS => {
190                if let Ok(n) = buf.parse::<u8>() {
191                    if let Some(bits) = data_bits_from_u8(n) {
192                        self.pending.data_bits = bits;
193                    }
194                }
195            }
196            FIELD_STOP_BITS => {
197                if let Ok(n) = buf.parse::<u8>() {
198                    if let Some(bits) = stop_bits_from_u8(n) {
199                        self.pending.stop_bits = bits;
200                    }
201                }
202            }
203            _ => {}
204        }
205    }
206
207    /// Handle a key while in [`EditState::EditingNumeric`].
208    fn handle_key_editing(&mut self, key: KeyEvent) -> DialogOutcome {
209        match key.code {
210            KeyCode::Char(c) if c.is_ascii_digit() => {
211                if let EditState::EditingNumeric(ref mut buf) = self.edit_state {
212                    buf.push(c);
213                }
214                DialogOutcome::Consumed
215            }
216            KeyCode::Backspace => {
217                if let EditState::EditingNumeric(ref mut buf) = self.edit_state {
218                    buf.pop();
219                }
220                DialogOutcome::Consumed
221            }
222            KeyCode::Enter => {
223                self.commit_numeric_edit();
224                DialogOutcome::Consumed
225            }
226            KeyCode::Esc => {
227                // Discard the buffered keystrokes; pending stays untouched.
228                self.edit_state = EditState::Idle;
229                DialogOutcome::Consumed
230            }
231            _ => DialogOutcome::Consumed,
232        }
233    }
234
235    /// Build the rendered field row text for `cursor == field_idx`.
236    fn field_line(&self, field_idx: usize, label: &'static str, value: String) -> Line<'_> {
237        let selected = self.cursor == field_idx;
238        let prefix = if selected { "> " } else { "  " };
239        let value_display = if selected && self.is_editing() {
240            if let EditState::EditingNumeric(ref buf) = self.edit_state {
241                format!("[{buf}_]")
242            } else {
243                value
244            }
245        } else {
246            value
247        };
248        let text = format!("{prefix}{label:<12} {value_display}");
249        if selected {
250            Line::from(Span::styled(
251                text,
252                Style::default().add_modifier(Modifier::REVERSED),
253            ))
254        } else {
255            Line::from(Span::raw(text))
256        }
257    }
258
259    /// Build the rendered action-button row for `cursor == action_idx`.
260    fn action_line(
261        &self,
262        action_idx: usize,
263        label: &'static str,
264        shortcut: &'static str,
265    ) -> Line<'_> {
266        let selected = self.cursor == action_idx;
267        let prefix = if selected { "> " } else { "  " };
268        let text = format!("{prefix}{label:<18} {shortcut}");
269        if selected {
270            Line::from(Span::styled(
271                text,
272                Style::default().add_modifier(Modifier::REVERSED),
273            ))
274        } else {
275            Line::from(Span::raw(text))
276        }
277    }
278}
279
280impl Dialog for SerialPortSetupDialog {
281    #[allow(
282        clippy::unnecessary_literal_bound,
283        reason = "trait signature must remain &str"
284    )]
285    fn title(&self) -> &str {
286        "Serial port setup"
287    }
288
289    fn preferred_size(&self, outer: Rect) -> Rect {
290        // Reserve one extra row when the CLI-override hint is active
291        // so the bottom line doesn't collide with the dialog border.
292        let height = if self.has_cli_override_hint() { 19 } else { 18 };
293        centred_rect(outer, 44, height)
294    }
295
296    fn render(&self, area: Rect, buf: &mut Buffer) {
297        let block = Block::bordered().title("Serial port setup");
298        let inner = block.inner(area);
299        block.render(area, buf);
300
301        let cfg = &self.pending;
302        let sep_width = usize::from(inner.width);
303        let sep_line = Line::from(Span::styled(
304            "-".repeat(sep_width),
305            Style::default().add_modifier(Modifier::DIM),
306        ));
307
308        let mut lines = vec![
309            Line::from(Span::raw("")),
310            self.field_line(FIELD_BAUD, "Baud rate", cfg.baud_rate.to_string()),
311            self.field_line(
312                FIELD_DATA_BITS,
313                "Data bits",
314                cfg.data_bits.bits().to_string(),
315            ),
316            self.field_line(
317                FIELD_STOP_BITS,
318                "Stop bits",
319                stop_bits_label(cfg.stop_bits).to_string(),
320            ),
321            self.field_line(FIELD_PARITY, "Parity", parity_label(cfg.parity).to_string()),
322            self.field_line(
323                FIELD_FLOW,
324                "Flow ctrl",
325                flow_label(cfg.flow_control).to_string(),
326            ),
327            Line::from(Span::raw("")),
328            sep_line,
329            Line::from(Span::raw("")),
330            self.action_line(ACTION_APPLY_LIVE, "[Apply live]", "(F2)"),
331            self.action_line(ACTION_APPLY_SAVE, "[Apply + Save]", "(F10)"),
332            self.action_line(ACTION_CANCEL, "[Cancel]", "(Esc)"),
333        ];
334
335        if self.has_cli_override_hint() {
336            let flags = self.cli_overrides.join("/");
337            let hint = format!(
338                " * {} field(s) overridden by CLI; relaunch without {} to use saved value *",
339                self.cli_overrides.len(),
340                flags,
341            );
342            lines.push(Line::from(Span::styled(
343                hint,
344                Style::default().add_modifier(Modifier::DIM),
345            )));
346        }
347
348        Paragraph::new(lines).render(inner, buf);
349    }
350
351    fn handle_key(&mut self, key: KeyEvent) -> DialogOutcome {
352        // F2 / F10 fire from anywhere, including edit mode — treat them
353        // as explicit "commit and apply" shortcuts.
354        match key.code {
355            KeyCode::F(2) => {
356                self.commit_numeric_edit();
357                return DialogOutcome::Action(DialogAction::ApplyLive(self.pending));
358            }
359            KeyCode::F(10) => {
360                self.commit_numeric_edit();
361                return DialogOutcome::Action(DialogAction::ApplyAndSave(self.pending));
362            }
363            _ => {}
364        }
365
366        if self.is_editing() {
367            return self.handle_key_editing(key);
368        }
369
370        match key.code {
371            KeyCode::Up | KeyCode::Char('k') => {
372                self.move_up();
373                DialogOutcome::Consumed
374            }
375            KeyCode::Down | KeyCode::Char('j') => {
376                self.move_down();
377                DialogOutcome::Consumed
378            }
379            KeyCode::Esc => DialogOutcome::Close,
380            KeyCode::Enter => self.activate(),
381            // Space cycles enum fields; on numeric fields it's a no-op.
382            KeyCode::Char(' ') => match self.cursor {
383                FIELD_PARITY => {
384                    self.pending.parity = next_parity(self.pending.parity);
385                    DialogOutcome::Consumed
386                }
387                FIELD_FLOW => {
388                    self.pending.flow_control = next_flow(self.pending.flow_control);
389                    DialogOutcome::Consumed
390                }
391                _ => DialogOutcome::Consumed,
392            },
393            _ => DialogOutcome::Consumed,
394        }
395    }
396}
397
398/// Next parity value in the canonical cycle order (wraps).
399const fn next_parity(p: Parity) -> Parity {
400    match p {
401        Parity::None => Parity::Even,
402        Parity::Even => Parity::Odd,
403        Parity::Odd => Parity::Mark,
404        Parity::Mark => Parity::Space,
405        Parity::Space => Parity::None,
406    }
407}
408
409/// Next flow-control value (wraps).
410const fn next_flow(f: FlowControl) -> FlowControl {
411    match f {
412        FlowControl::None => FlowControl::Hardware,
413        FlowControl::Hardware => FlowControl::Software,
414        FlowControl::Software => FlowControl::None,
415    }
416}
417
418/// Human-readable label for a [`Parity`].
419const fn parity_label(p: Parity) -> &'static str {
420    match p {
421        Parity::None => "none",
422        Parity::Even => "even",
423        Parity::Odd => "odd",
424        Parity::Mark => "mark",
425        Parity::Space => "space",
426    }
427}
428
429/// Human-readable label for a [`FlowControl`].
430const fn flow_label(f: FlowControl) -> &'static str {
431    match f {
432        FlowControl::None => "none",
433        FlowControl::Hardware => "hw",
434        FlowControl::Software => "sw",
435    }
436}
437
438/// Human-readable label for a [`StopBits`].
439const fn stop_bits_label(s: StopBits) -> &'static str {
440    match s {
441        StopBits::One => "1",
442        StopBits::Two => "2",
443    }
444}
445
446/// Convert `5|6|7|8` into the matching [`DataBits`] variant.
447const fn data_bits_from_u8(n: u8) -> Option<DataBits> {
448    match n {
449        5 => Some(DataBits::Five),
450        6 => Some(DataBits::Six),
451        7 => Some(DataBits::Seven),
452        8 => Some(DataBits::Eight),
453        _ => None,
454    }
455}
456
457/// Convert `1|2` into the matching [`StopBits`] variant.
458const fn stop_bits_from_u8(n: u8) -> Option<StopBits> {
459    match n {
460        1 => Some(StopBits::One),
461        2 => Some(StopBits::Two),
462        _ => None,
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
470    use rtcom_core::SerialConfig;
471
472    const fn key(code: KeyCode) -> KeyEvent {
473        KeyEvent::new(code, KeyModifiers::NONE)
474    }
475
476    fn default_dialog() -> SerialPortSetupDialog {
477        SerialPortSetupDialog::new(SerialConfig::default(), Vec::new())
478    }
479
480    #[test]
481    fn dialog_starts_with_baud_field_selected() {
482        let d = default_dialog();
483        assert_eq!(d.cursor(), 0);
484    }
485
486    #[test]
487    fn down_moves_field_cursor() {
488        let mut d = default_dialog();
489        d.handle_key(key(KeyCode::Down));
490        assert_eq!(d.cursor(), 1);
491    }
492
493    #[test]
494    fn cursor_reaches_apply_live_at_index_5() {
495        let mut d = default_dialog();
496        for _ in 0..5 {
497            d.handle_key(key(KeyCode::Down));
498        }
499        assert_eq!(d.cursor(), 5);
500    }
501
502    #[test]
503    fn esc_from_field_view_closes() {
504        let mut d = default_dialog();
505        let out = d.handle_key(key(KeyCode::Esc));
506        assert!(matches!(out, DialogOutcome::Close));
507    }
508
509    #[test]
510    fn enter_on_cancel_closes() {
511        let mut d = default_dialog();
512        for _ in 0..7 {
513            d.handle_key(key(KeyCode::Down));
514        }
515        // cursor on Cancel (idx 7)
516        let out = d.handle_key(key(KeyCode::Enter));
517        assert!(matches!(out, DialogOutcome::Close));
518    }
519
520    #[test]
521    fn f2_emits_apply_live_with_current_pending() {
522        let mut d = default_dialog();
523        let out = d.handle_key(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE));
524        match out {
525            DialogOutcome::Action(DialogAction::ApplyLive(cfg)) => {
526                assert_eq!(cfg, SerialConfig::default());
527            }
528            _ => panic!("expected Action(ApplyLive)"),
529        }
530    }
531
532    #[test]
533    fn f10_emits_apply_and_save() {
534        let mut d = default_dialog();
535        let out = d.handle_key(KeyEvent::new(KeyCode::F(10), KeyModifiers::NONE));
536        assert!(matches!(
537            out,
538            DialogOutcome::Action(DialogAction::ApplyAndSave(_))
539        ));
540    }
541
542    #[test]
543    fn enter_on_baud_enters_edit_mode() {
544        let mut d = default_dialog();
545        // cursor is on Baud (idx 0) by default
546        d.handle_key(key(KeyCode::Enter));
547        assert!(d.is_editing());
548    }
549
550    #[test]
551    fn typing_digits_updates_pending_baud_on_commit() {
552        let mut d = default_dialog();
553        d.handle_key(key(KeyCode::Enter)); // enter edit mode
554        d.handle_key(key(KeyCode::Char('9')));
555        d.handle_key(key(KeyCode::Char('6')));
556        d.handle_key(key(KeyCode::Char('0')));
557        d.handle_key(key(KeyCode::Char('0')));
558        d.handle_key(key(KeyCode::Enter)); // commit
559        assert!(!d.is_editing());
560        assert_eq!(d.pending().baud_rate, 9600);
561    }
562
563    #[test]
564    fn esc_during_edit_cancels_and_preserves_pending() {
565        let mut d = default_dialog();
566        d.handle_key(key(KeyCode::Enter)); // enter edit mode on baud
567        d.handle_key(key(KeyCode::Char('4'))); // typing '4'
568        let before = d.pending().baud_rate;
569        d.handle_key(key(KeyCode::Esc)); // cancel edit, return to field view
570        assert!(!d.is_editing());
571        assert_eq!(d.pending().baud_rate, before); // unchanged
572    }
573
574    #[test]
575    fn enum_field_cycles_with_space() {
576        let mut d = default_dialog();
577        // move cursor to parity (idx 3)
578        for _ in 0..3 {
579            d.handle_key(key(KeyCode::Down));
580        }
581        let initial_parity = d.pending().parity;
582        d.handle_key(key(KeyCode::Char(' '))); // cycle
583        assert_ne!(d.pending().parity, initial_parity);
584    }
585
586    #[test]
587    fn preferred_size_is_wider_than_default() {
588        use ratatui::layout::Rect;
589        let d = default_dialog();
590        let outer = Rect {
591            x: 0,
592            y: 0,
593            width: 80,
594            height: 24,
595        };
596        let pref = d.preferred_size(outer);
597        // Expect wider than the default 30x12
598        assert!(pref.width >= 40, "expected >=40 cols, got {}", pref.width);
599        assert!(pref.height >= 14, "expected >=14 rows, got {}", pref.height);
600    }
601
602    #[test]
603    fn enter_on_parity_cycles_without_edit_mode() {
604        let mut d = default_dialog();
605        for _ in 0..3 {
606            d.handle_key(key(KeyCode::Down));
607        }
608        let initial = d.pending().parity;
609        d.handle_key(key(KeyCode::Enter));
610        assert_ne!(d.pending().parity, initial);
611        assert!(!d.is_editing());
612    }
613
614    #[test]
615    fn up_wraps_to_last_action() {
616        let mut d = default_dialog();
617        d.handle_key(key(KeyCode::Up));
618        assert_eq!(d.cursor(), CURSOR_MAX - 1);
619    }
620
621    #[test]
622    fn down_wraps_from_last_to_first() {
623        let mut d = default_dialog();
624        for _ in 0..CURSOR_MAX {
625            d.handle_key(key(KeyCode::Down));
626        }
627        assert_eq!(d.cursor(), 0);
628    }
629
630    #[test]
631    fn invalid_baud_commit_leaves_pending_unchanged() {
632        let mut d = default_dialog();
633        let before = d.pending().baud_rate;
634        d.handle_key(key(KeyCode::Enter)); // edit
635        d.handle_key(key(KeyCode::Enter)); // commit empty buffer
636        assert_eq!(d.pending().baud_rate, before);
637    }
638
639    #[test]
640    fn enter_on_apply_live_emits_action() {
641        let mut d = default_dialog();
642        for _ in 0..5 {
643            d.handle_key(key(KeyCode::Down));
644        }
645        // cursor now on [Apply live]
646        let out = d.handle_key(key(KeyCode::Enter));
647        assert!(matches!(
648            out,
649            DialogOutcome::Action(DialogAction::ApplyLive(_))
650        ));
651    }
652
653    #[test]
654    fn dialog_without_cli_overrides_has_no_hint_row() {
655        let d = SerialPortSetupDialog::new(SerialConfig::default(), Vec::new());
656        assert!(!d.has_cli_override_hint());
657    }
658
659    #[test]
660    fn dialog_with_cli_overrides_renders_hint() {
661        use ratatui::{backend::TestBackend, layout::Rect, Terminal};
662        let d = SerialPortSetupDialog::new(SerialConfig::default(), vec!["-b", "-d"]);
663        assert!(d.has_cli_override_hint());
664
665        // Render into a sizable test backend and confirm the hint text
666        // reaches the on-screen buffer.
667        let backend = TestBackend::new(80, 24);
668        let mut terminal = Terminal::new(backend).unwrap();
669        terminal
670            .draw(|f| {
671                let area = Rect {
672                    x: 0,
673                    y: 0,
674                    width: 80,
675                    height: 24,
676                };
677                d.render(area, f.buffer_mut());
678            })
679            .unwrap();
680        let rendered = format!("{}", terminal.backend());
681        assert!(
682            rendered.contains("2 field(s) overridden by CLI"),
683            "expected hint in rendered buffer, got:\n{rendered}"
684        );
685        assert!(
686            rendered.contains("-b/-d"),
687            "expected flag list in rendered buffer, got:\n{rendered}"
688        );
689    }
690
691    #[test]
692    fn pending_carries_edits_through_f2() {
693        let mut d = default_dialog();
694        d.handle_key(key(KeyCode::Enter)); // edit baud
695        d.handle_key(key(KeyCode::Char('1')));
696        d.handle_key(key(KeyCode::Char('9')));
697        d.handle_key(key(KeyCode::Char('2')));
698        d.handle_key(key(KeyCode::Char('0')));
699        d.handle_key(key(KeyCode::Char('0')));
700        // F2 commits the in-progress edit and emits Action.
701        let out = d.handle_key(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE));
702        match out {
703            DialogOutcome::Action(DialogAction::ApplyLive(cfg)) => {
704                assert_eq!(cfg.baud_rate, 19_200);
705            }
706            _ => panic!("expected ApplyLive"),
707        }
708    }
709}