Skip to main content

rtcom_tui/menu/
line_endings.rs

1//! Line-endings dialog — edits the three [`LineEnding`]
2//! rules that govern a session's byte streams.
3//!
4//! Structurally the simpler cousin of T12's
5//! [`SerialPortSetupDialog`](crate::menu::SerialPortSetupDialog): three
6//! enum fields (`omap` / `imap` / `emap`) plus three action buttons
7//! (`Apply live` / `Apply + Save` / `Cancel`) — six cursor positions
8//! total. Because every field is an enum, cycling is immediate
9//! (`Space` or `Enter` on a field advances to the next variant) and
10//! there is no numeric-edit state machine.
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::{LineEnding, LineEndingConfig};
22
23use crate::modal::{centred_rect, Dialog, DialogAction, DialogOutcome};
24
25/// Index of the `omap` (outbound) field row.
26const FIELD_OMAP: usize = 0;
27/// Index of the `imap` (inbound) field row.
28const FIELD_IMAP: usize = 1;
29/// Index of the `emap` (echo) field row.
30const FIELD_EMAP: usize = 2;
31
32/// Index of the `[Apply live]` action button.
33const ACTION_APPLY_LIVE: usize = 3;
34/// Index of the `[Apply + Save]` action button.
35const ACTION_APPLY_SAVE: usize = 4;
36/// Index of the `[Cancel]` action button.
37const ACTION_CANCEL: usize = 5;
38
39/// Total cursor slots (3 fields + 3 actions).
40const CURSOR_MAX: usize = 6;
41
42/// Line-endings dialog.
43///
44/// Holds a snapshot of the initial [`LineEndingConfig`] and a mutable
45/// `pending` copy that tracks the user's edits. Emits
46/// [`DialogAction::ApplyLineEndingsLive`] on `F2` / `Enter` on
47/// `[Apply live]`, [`DialogAction::ApplyLineEndingsAndSave`] on `F10`
48/// / `Enter` on `[Apply + Save]`, and [`DialogOutcome::Close`] on
49/// `Esc` / `Enter` on `[Cancel]`.
50///
51/// After emitting an `Action`, the dialog stays open — T17 wires the
52/// outer `TuiApp` to pop the stack once the action has been applied.
53pub struct LineEndingsDialog {
54    #[allow(dead_code, reason = "reserved for T17 revert-on-cancel path")]
55    initial: LineEndingConfig,
56    pending: LineEndingConfig,
57    cursor: usize,
58}
59
60impl LineEndingsDialog {
61    /// Construct a dialog seeded with `initial_config`. The cursor
62    /// starts on the `omap` row.
63    #[must_use]
64    pub const fn new(initial_config: LineEndingConfig) -> Self {
65        Self {
66            initial: initial_config,
67            pending: initial_config,
68            cursor: FIELD_OMAP,
69        }
70    }
71
72    /// Current cursor position. Valid range is `0..6`: indices `0..=2`
73    /// select a mapper field (omap / imap / emap), and `3..=5` select
74    /// one of the action buttons (Apply live / Apply + Save / Cancel).
75    #[must_use]
76    pub const fn cursor(&self) -> usize {
77        self.cursor
78    }
79
80    /// The currently pending [`LineEndingConfig`]; reflects every
81    /// committed edit since construction.
82    #[must_use]
83    pub const fn pending(&self) -> &LineEndingConfig {
84        &self.pending
85    }
86
87    /// Move the cursor up one row (wraps).
88    const fn move_up(&mut self) {
89        self.cursor = if self.cursor == 0 {
90            CURSOR_MAX - 1
91        } else {
92            self.cursor - 1
93        };
94    }
95
96    /// Move the cursor down one row (wraps).
97    const fn move_down(&mut self) {
98        self.cursor = (self.cursor + 1) % CURSOR_MAX;
99    }
100
101    /// Cycle the enum value at the current field cursor.
102    /// No-op when the cursor is on an action button.
103    const fn cycle_current_field(&mut self) {
104        match self.cursor {
105            FIELD_OMAP => self.pending.omap = cycle_line_ending(self.pending.omap),
106            FIELD_IMAP => self.pending.imap = cycle_line_ending(self.pending.imap),
107            FIELD_EMAP => self.pending.emap = cycle_line_ending(self.pending.emap),
108            _ => {}
109        }
110    }
111
112    /// Handle `Enter` in field-navigation mode.
113    const fn activate(&mut self) -> DialogOutcome {
114        match self.cursor {
115            FIELD_OMAP | FIELD_IMAP | FIELD_EMAP => {
116                self.cycle_current_field();
117                DialogOutcome::Consumed
118            }
119            ACTION_APPLY_LIVE => {
120                DialogOutcome::Action(DialogAction::ApplyLineEndingsLive(self.pending))
121            }
122            ACTION_APPLY_SAVE => {
123                DialogOutcome::Action(DialogAction::ApplyLineEndingsAndSave(self.pending))
124            }
125            ACTION_CANCEL => DialogOutcome::Close,
126            _ => DialogOutcome::Consumed,
127        }
128    }
129
130    /// Build the rendered field row for `cursor == field_idx`.
131    fn field_line(&self, field_idx: usize, label: &'static str, value: LineEnding) -> Line<'_> {
132        let selected = self.cursor == field_idx;
133        let prefix = if selected { "> " } else { "  " };
134        let text = format!("{prefix}{label:<6} {}", line_ending_label(value));
135        if selected {
136            Line::from(Span::styled(
137                text,
138                Style::default().add_modifier(Modifier::REVERSED),
139            ))
140        } else {
141            Line::from(Span::raw(text))
142        }
143    }
144
145    /// Build the rendered action-button row for `cursor == action_idx`.
146    fn action_line(
147        &self,
148        action_idx: usize,
149        label: &'static str,
150        shortcut: &'static str,
151    ) -> Line<'_> {
152        let selected = self.cursor == action_idx;
153        let prefix = if selected { "> " } else { "  " };
154        let text = format!("{prefix}{label:<18} {shortcut}");
155        if selected {
156            Line::from(Span::styled(
157                text,
158                Style::default().add_modifier(Modifier::REVERSED),
159            ))
160        } else {
161            Line::from(Span::raw(text))
162        }
163    }
164}
165
166impl Dialog for LineEndingsDialog {
167    #[allow(
168        clippy::unnecessary_literal_bound,
169        reason = "trait signature must remain &str"
170    )]
171    fn title(&self) -> &str {
172        "Line endings"
173    }
174
175    fn preferred_size(&self, outer: Rect) -> Rect {
176        centred_rect(outer, 46, 20)
177    }
178
179    fn render(&self, area: Rect, buf: &mut Buffer) {
180        let block = Block::bordered().title("Line endings");
181        let inner = block.inner(area);
182        block.render(area, buf);
183
184        let cfg = &self.pending;
185        let sep_width = usize::from(inner.width);
186        let sep_line = Line::from(Span::styled(
187            "-".repeat(sep_width),
188            Style::default().add_modifier(Modifier::DIM),
189        ));
190
191        // Static recipe hint — shown after the action buttons. The
192        // minicom / picocom rule names (`crlf`, `lfcr`, `igncr`,
193        // `ignlf`) are compact but non-obvious; users routinely pick
194        // the wrong one on first try because the names describe the
195        // *transformation* rather than the *device behaviour* they
196        // match. Surfacing the common mapping here saves a trip to
197        // `docs/tui.md`.
198        let recipe_header = Line::from(Span::styled(
199            "  Recipes:",
200            Style::default().add_modifier(Modifier::BOLD),
201        ));
202        let recipe_lines = [
203            Line::from(Span::styled(
204                "    imap = crlf   device sends \\n only",
205                Style::default().add_modifier(Modifier::DIM),
206            )),
207            Line::from(Span::styled(
208                "           lfcr   device sends \\r only",
209                Style::default().add_modifier(Modifier::DIM),
210            )),
211            Line::from(Span::styled(
212                "           none   device sends \\r\\n",
213                Style::default().add_modifier(Modifier::DIM),
214            )),
215        ];
216
217        let mut lines = vec![
218            Line::from(Span::raw("")),
219            self.field_line(FIELD_OMAP, "OMAP", cfg.omap),
220            self.field_line(FIELD_IMAP, "IMAP", cfg.imap),
221            self.field_line(FIELD_EMAP, "EMAP", cfg.emap),
222            Line::from(Span::raw("")),
223            sep_line,
224            Line::from(Span::raw("")),
225            self.action_line(ACTION_APPLY_LIVE, "[Apply live]", "(F2)"),
226            self.action_line(ACTION_APPLY_SAVE, "[Apply + Save]", "(F10)"),
227            self.action_line(ACTION_CANCEL, "[Cancel]", "(Esc)"),
228            Line::from(Span::raw("")),
229            recipe_header,
230        ];
231        lines.extend(recipe_lines);
232
233        Paragraph::new(lines).render(inner, buf);
234    }
235
236    fn handle_key(&mut self, key: KeyEvent) -> DialogOutcome {
237        // F2 / F10 act as global "apply now" shortcuts regardless of
238        // cursor position.
239        match key.code {
240            KeyCode::F(2) => {
241                return DialogOutcome::Action(DialogAction::ApplyLineEndingsLive(self.pending));
242            }
243            KeyCode::F(10) => {
244                return DialogOutcome::Action(DialogAction::ApplyLineEndingsAndSave(self.pending));
245            }
246            _ => {}
247        }
248
249        match key.code {
250            KeyCode::Up | KeyCode::Char('k') => {
251                self.move_up();
252                DialogOutcome::Consumed
253            }
254            KeyCode::Down | KeyCode::Char('j') => {
255                self.move_down();
256                DialogOutcome::Consumed
257            }
258            KeyCode::Esc => DialogOutcome::Close,
259            KeyCode::Enter => self.activate(),
260            KeyCode::Char(' ') => {
261                self.cycle_current_field();
262                DialogOutcome::Consumed
263            }
264            _ => DialogOutcome::Consumed,
265        }
266    }
267}
268
269/// Next [`LineEnding`] in the canonical cycle order (wraps).
270///
271/// Order chosen to match the declaration order of the enum so the cycle
272/// is predictable to read: `None` → `AddCrToLf` → `AddLfToCr` →
273/// `DropCr` → `DropLf` → `None`.
274const fn cycle_line_ending(le: LineEnding) -> LineEnding {
275    match le {
276        LineEnding::None => LineEnding::AddCrToLf,
277        LineEnding::AddCrToLf => LineEnding::AddLfToCr,
278        LineEnding::AddLfToCr => LineEnding::DropCr,
279        LineEnding::DropCr => LineEnding::DropLf,
280        LineEnding::DropLf => LineEnding::None,
281    }
282}
283
284/// Human-readable label for a [`LineEnding`].
285const fn line_ending_label(le: LineEnding) -> &'static str {
286    match le {
287        LineEnding::None => "none",
288        LineEnding::AddCrToLf => "crlf",
289        LineEnding::AddLfToCr => "lfcr",
290        LineEnding::DropCr => "igncr",
291        LineEnding::DropLf => "ignlf",
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use crate::modal::DialogAction;
299    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
300    use rtcom_core::{LineEnding, LineEndingConfig};
301
302    const fn key(code: KeyCode) -> KeyEvent {
303        KeyEvent::new(code, KeyModifiers::NONE)
304    }
305
306    fn default_dialog() -> LineEndingsDialog {
307        LineEndingsDialog::new(LineEndingConfig::default())
308    }
309
310    #[test]
311    fn starts_on_omap() {
312        let d = default_dialog();
313        assert_eq!(d.cursor(), 0);
314    }
315
316    #[test]
317    fn cursor_span_is_six() {
318        let mut d = default_dialog();
319        for _ in 0..5 {
320            d.handle_key(key(KeyCode::Down));
321        }
322        assert_eq!(d.cursor(), 5);
323        d.handle_key(key(KeyCode::Down));
324        assert_eq!(d.cursor(), 0); // wrap
325    }
326
327    #[test]
328    fn space_cycles_current_field() {
329        let mut d = default_dialog();
330        let before = d.pending().omap;
331        d.handle_key(key(KeyCode::Char(' ')));
332        assert_ne!(d.pending().omap, before);
333    }
334
335    #[test]
336    fn enter_on_field_cycles() {
337        let mut d = default_dialog();
338        let before = d.pending().omap;
339        d.handle_key(key(KeyCode::Enter));
340        assert_ne!(d.pending().omap, before);
341    }
342
343    #[test]
344    fn f2_emits_apply_line_endings_live() {
345        let mut d = default_dialog();
346        let out = d.handle_key(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE));
347        assert!(matches!(
348            out,
349            DialogOutcome::Action(DialogAction::ApplyLineEndingsLive(_))
350        ));
351    }
352
353    #[test]
354    fn f10_emits_apply_line_endings_and_save() {
355        let mut d = default_dialog();
356        let out = d.handle_key(KeyEvent::new(KeyCode::F(10), KeyModifiers::NONE));
357        assert!(matches!(
358            out,
359            DialogOutcome::Action(DialogAction::ApplyLineEndingsAndSave(_))
360        ));
361    }
362
363    #[test]
364    fn esc_closes() {
365        let mut d = default_dialog();
366        let out = d.handle_key(key(KeyCode::Esc));
367        assert!(matches!(out, DialogOutcome::Close));
368    }
369
370    #[test]
371    fn enter_on_apply_live_button_emits_action() {
372        let mut d = default_dialog();
373        for _ in 0..3 {
374            d.handle_key(key(KeyCode::Down));
375        } // cursor=3 -> Apply live button
376        let out = d.handle_key(key(KeyCode::Enter));
377        assert!(matches!(
378            out,
379            DialogOutcome::Action(DialogAction::ApplyLineEndingsLive(_))
380        ));
381    }
382
383    #[test]
384    fn enter_on_cancel_closes() {
385        let mut d = default_dialog();
386        for _ in 0..5 {
387            d.handle_key(key(KeyCode::Down));
388        } // cursor=5 -> Cancel
389        let out = d.handle_key(key(KeyCode::Enter));
390        assert!(matches!(out, DialogOutcome::Close));
391    }
392
393    #[test]
394    fn j_k_nav() {
395        let mut d = default_dialog();
396        d.handle_key(key(KeyCode::Char('j')));
397        assert_eq!(d.cursor(), 1);
398        d.handle_key(key(KeyCode::Char('k')));
399        assert_eq!(d.cursor(), 0);
400    }
401
402    #[test]
403    fn preferred_size_accommodates_recipe() {
404        use ratatui::layout::Rect;
405        let d = default_dialog();
406        let outer = Rect {
407            x: 0,
408            y: 0,
409            width: 80,
410            height: 24,
411        };
412        let pref = d.preferred_size(outer);
413        assert!(pref.width >= 46, "expected >=46 cols, got {}", pref.width);
414        assert!(pref.height >= 20, "expected >=20 rows, got {}", pref.height);
415    }
416
417    #[test]
418    fn dialog_renders_recipe_hint() {
419        use ratatui::{backend::TestBackend, Terminal};
420        let d = default_dialog();
421        let backend = TestBackend::new(60, 24);
422        let mut terminal = Terminal::new(backend).unwrap();
423        terminal
424            .draw(|f| {
425                let area = d.preferred_size(f.area());
426                d.render(area, f.buffer_mut());
427            })
428            .unwrap();
429        let buf_dump = format!("{}", terminal.backend());
430        assert!(
431            buf_dump.contains("Recipes:"),
432            "missing recipe header in:\n{buf_dump}"
433        );
434        assert!(
435            buf_dump.contains("crlf"),
436            "missing 'crlf' mention in:\n{buf_dump}"
437        );
438    }
439
440    #[test]
441    fn cycle_order_covers_every_variant() {
442        // 5 variants × cycle once each returns to start.
443        let mut le = LineEnding::None;
444        for _ in 0..5 {
445            le = cycle_line_ending(le);
446        }
447        assert_eq!(le, LineEnding::None);
448    }
449
450    #[test]
451    fn cycling_imap_does_not_touch_omap_or_emap() {
452        let mut d = default_dialog();
453        // Move cursor to IMAP (idx 1).
454        d.handle_key(key(KeyCode::Down));
455        assert_eq!(d.cursor(), 1);
456        d.handle_key(key(KeyCode::Char(' ')));
457        assert_ne!(d.pending().imap, LineEnding::None);
458        assert_eq!(d.pending().omap, LineEnding::None);
459        assert_eq!(d.pending().emap, LineEnding::None);
460    }
461}