1use 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
25const FIELD_BAUD: usize = 0;
27const FIELD_DATA_BITS: usize = 1;
29const FIELD_STOP_BITS: usize = 2;
31const FIELD_PARITY: usize = 3;
33const FIELD_FLOW: usize = 4;
35
36const ACTION_APPLY_LIVE: usize = 5;
38const ACTION_APPLY_SAVE: usize = 6;
40const ACTION_CANCEL: usize = 7;
42
43const CURSOR_MAX: usize = 8;
45
46#[derive(Debug, Clone)]
52enum EditState {
53 Idle,
55 EditingNumeric(String),
59}
60
61pub 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 cli_overrides: Vec<&'static str>,
85}
86
87impl SerialPortSetupDialog {
88 #[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 #[must_use]
108 pub const fn has_cli_override_hint(&self) -> bool {
109 !self.cli_overrides.is_empty()
110 }
111
112 #[must_use]
117 pub const fn cursor(&self) -> usize {
118 self.cursor
119 }
120
121 #[must_use]
123 pub const fn is_editing(&self) -> bool {
124 matches!(self.edit_state, EditState::EditingNumeric(_))
125 }
126
127 #[must_use]
130 pub const fn pending(&self) -> &SerialConfig {
131 &self.pending
132 }
133
134 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 const fn move_down(&mut self) {
145 self.cursor = (self.cursor + 1) % CURSOR_MAX;
146 }
147
148 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 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 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 self.edit_state = EditState::Idle;
229 DialogOutcome::Consumed
230 }
231 _ => DialogOutcome::Consumed,
232 }
233 }
234
235 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 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 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 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 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
398const 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
409const 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
418const 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
429const 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
438const fn stop_bits_label(s: StopBits) -> &'static str {
440 match s {
441 StopBits::One => "1",
442 StopBits::Two => "2",
443 }
444}
445
446const 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
457const 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 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 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)); 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)); 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)); d.handle_key(key(KeyCode::Char('4'))); let before = d.pending().baud_rate;
569 d.handle_key(key(KeyCode::Esc)); assert!(!d.is_editing());
571 assert_eq!(d.pending().baud_rate, before); }
573
574 #[test]
575 fn enum_field_cycles_with_space() {
576 let mut d = default_dialog();
577 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(' '))); 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 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)); d.handle_key(key(KeyCode::Enter)); 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 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 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)); 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 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}