Skip to main content

rtcom_tui/
modal.rs

1//! Modal dialog trait + a stack that routes input to the topmost
2//! dialog. T10 defines the abstraction; T11+ wire actual dialogs
3//! (root menu, serial port setup, ...) on top of it.
4
5use crossterm::event::KeyEvent;
6use ratatui::{buffer::Buffer, layout::Rect};
7
8use rtcom_config::ModalStyle;
9use rtcom_core::{LineEndingConfig, SerialConfig};
10
11/// What a [`Dialog`] wants the surrounding [`ModalStack`] to do after
12/// it has processed an input event.
13pub enum DialogOutcome {
14    /// Dialog handled the key; stack stays as-is.
15    Consumed,
16    /// Dialog wants to close itself (Esc, Cancel, action complete).
17    Close,
18    /// Dialog produced a user-level action for the outer app to
19    /// apply (e.g. save the profile, push a config change).
20    Action(DialogAction),
21    /// Dialog wants to push a child dialog onto the stack.
22    /// [`ModalStack::handle_key`] performs the push automatically
23    /// and reports [`DialogOutcome::Consumed`] to the caller.
24    Push(Box<dyn Dialog + Send>),
25}
26
27impl core::fmt::Debug for DialogOutcome {
28    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
29        match self {
30            Self::Consumed => f.write_str("Consumed"),
31            Self::Close => f.write_str("Close"),
32            Self::Action(a) => f.debug_tuple("Action").field(a).finish(),
33            Self::Push(d) => f.debug_tuple("Push").field(&d.title()).finish(),
34        }
35    }
36}
37
38/// User-level actions emitted by dialogs. The `TuiApp` orchestrator
39/// consumes these and calls into `rtcom-core` / `rtcom-config` to
40/// apply them.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum DialogAction {
43    /// Apply `SerialConfig` to the live session immediately (F2 path).
44    ApplyLive(SerialConfig),
45    /// Apply `SerialConfig` to the live session *and* persist to
46    /// profile (F10 path).
47    ApplyAndSave(SerialConfig),
48    /// Apply the given [`LineEndingConfig`] to the live session
49    /// immediately (F2 path).
50    ApplyLineEndingsLive(LineEndingConfig),
51    /// Apply [`LineEndingConfig`] to the live session *and* persist
52    /// it to profile (F10 path).
53    ApplyLineEndingsAndSave(LineEndingConfig),
54    /// Assert (`true`) or de-assert (`false`) the DTR output line.
55    SetDtr(bool),
56    /// Assert (`true`) or de-assert (`false`) the RTS output line.
57    SetRts(bool),
58    /// Send a line break (~250ms).
59    SendBreak,
60    /// Persist the current profile as-is.
61    WriteProfile,
62    /// Reload profile from disk (discards unsaved live changes).
63    ReadProfile,
64    /// Apply the given [`ModalStyle`] to the live session immediately
65    /// (F2 path from the Screen-options dialog).
66    ApplyModalStyleLive(ModalStyle),
67    /// Apply the given [`ModalStyle`] to the live session *and* persist
68    /// it to profile (F10 path from the Screen-options dialog).
69    ApplyModalStyleAndSave(ModalStyle),
70}
71
72/// A full-screen or modal dialog rendered over the main TUI chrome.
73///
74/// Implementors typically hold their own local state (cursor, field
75/// values, ...), draw themselves inside the provided area, and emit
76/// a [`DialogOutcome`] per key event to tell the surrounding
77/// [`ModalStack`] how to react.
78pub trait Dialog {
79    /// Human-readable title, used for decoration.
80    fn title(&self) -> &str;
81    /// Render the dialog into the given area.
82    fn render(&self, area: Rect, buf: &mut Buffer);
83    /// Handle a key event and report back how the stack should react.
84    fn handle_key(&mut self, key: KeyEvent) -> DialogOutcome;
85
86    /// Preferred size of the dialog when rendered inside `outer`.
87    ///
88    /// The default implementation returns a `30x12` rectangle centred
89    /// inside `outer` — enough for a typical seven-item menu. Dialogs
90    /// with more fields (e.g. the serial-port setup dialog) override
91    /// this to return a wider rect. The [`crate::app::TuiApp`] render
92    /// loop consults this method to position the modal overlay.
93    fn preferred_size(&self, outer: Rect) -> Rect {
94        centred_rect(outer, 30, 12)
95    }
96}
97
98/// Centre a `width x height` rectangle inside `outer`, clipping if
99/// the outer is smaller than the requested size.
100///
101/// Shared helper used by [`Dialog::preferred_size`] default impl and
102/// by individual dialog implementations that override it.
103#[must_use]
104pub fn centred_rect(outer: Rect, width: u16, height: u16) -> Rect {
105    let clamped_w = width.min(outer.width);
106    let clamped_h = height.min(outer.height);
107    let x = outer.x + (outer.width.saturating_sub(clamped_w)) / 2;
108    let y = outer.y + (outer.height.saturating_sub(clamped_h)) / 2;
109    Rect {
110        x,
111        y,
112        width: clamped_w,
113        height: clamped_h,
114    }
115}
116
117/// Stack of [`Dialog`]s. The topmost dialog receives keys first;
118/// [`DialogOutcome::Close`] pops it.
119///
120/// The `Send` bound on the contained trait objects keeps
121/// [`ModalStack`] usable inside an async task that may be moved
122/// between tokio worker threads.
123pub struct ModalStack {
124    stack: Vec<Box<dyn Dialog + Send>>,
125}
126
127impl Default for ModalStack {
128    fn default() -> Self {
129        Self::new()
130    }
131}
132
133impl ModalStack {
134    /// Empty stack.
135    #[must_use]
136    pub const fn new() -> Self {
137        Self { stack: Vec::new() }
138    }
139
140    /// True if no dialog is on the stack.
141    #[must_use]
142    pub fn is_empty(&self) -> bool {
143        self.stack.is_empty()
144    }
145
146    /// Number of dialogs on the stack.
147    #[must_use]
148    pub fn depth(&self) -> usize {
149        self.stack.len()
150    }
151
152    /// Reference to the topmost dialog, if any.
153    #[must_use]
154    pub fn top(&self) -> Option<&(dyn Dialog + Send)> {
155        self.stack.last().map(AsRef::as_ref)
156    }
157
158    /// Push a dialog onto the stack. It becomes the new top.
159    pub fn push(&mut self, dialog: Box<dyn Dialog + Send>) {
160        self.stack.push(dialog);
161    }
162
163    /// Pop the topmost dialog off the stack.
164    pub fn pop(&mut self) -> Option<Box<dyn Dialog + Send>> {
165        self.stack.pop()
166    }
167
168    /// Clear the entire stack — used on forced-quit /
169    /// device-disconnect.
170    pub fn clear(&mut self) {
171        self.stack.clear();
172    }
173
174    /// Route a key event to the topmost dialog. Empty stack returns
175    /// [`DialogOutcome::Consumed`] (nothing to do).
176    ///
177    /// Automatically handles two stack-management outcomes:
178    /// - [`DialogOutcome::Close`] pops the top dialog.
179    /// - [`DialogOutcome::Push`] pushes the returned dialog onto
180    ///   the stack and reports [`DialogOutcome::Consumed`] to the
181    ///   caller (the push is an internal transition).
182    pub fn handle_key(&mut self, key: KeyEvent) -> DialogOutcome {
183        let Some(top) = self.stack.last_mut() else {
184            return DialogOutcome::Consumed;
185        };
186        let outcome = top.handle_key(key);
187        match outcome {
188            DialogOutcome::Close => {
189                self.stack.pop();
190                DialogOutcome::Close
191            }
192            DialogOutcome::Push(dialog) => {
193                self.stack.push(dialog);
194                DialogOutcome::Consumed
195            }
196            other => other,
197        }
198    }
199}
200
201#[cfg(test)]
202#[allow(
203    clippy::doc_markdown,
204    clippy::unnecessary_literal_bound,
205    reason = "test code mirrors the T10 spec verbatim"
206)]
207mod tests {
208    use super::*;
209    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
210    use ratatui::{buffer::Buffer, layout::Rect};
211    use std::sync::atomic::{AtomicUsize, Ordering};
212    use std::sync::Arc;
213
214    /// Counts calls to handle_key.
215    struct CountingDialog {
216        count: Arc<AtomicUsize>,
217    }
218
219    impl Dialog for CountingDialog {
220        fn title(&self) -> &str {
221            "counting"
222        }
223        fn render(&self, _area: Rect, _buf: &mut Buffer) {}
224        fn handle_key(&mut self, _key: KeyEvent) -> DialogOutcome {
225            self.count.fetch_add(1, Ordering::SeqCst);
226            DialogOutcome::Consumed
227        }
228    }
229
230    /// Closes on Esc, consumes everything else.
231    struct ClosingDialog;
232
233    impl Dialog for ClosingDialog {
234        fn title(&self) -> &str {
235            "closing"
236        }
237        fn render(&self, _area: Rect, _buf: &mut Buffer) {}
238        fn handle_key(&mut self, key: KeyEvent) -> DialogOutcome {
239            if key.code == KeyCode::Esc {
240                DialogOutcome::Close
241            } else {
242                DialogOutcome::Consumed
243            }
244        }
245    }
246
247    #[test]
248    fn modal_stack_starts_empty() {
249        let stack = ModalStack::new();
250        assert!(stack.is_empty());
251        assert!(stack.top().is_none());
252    }
253
254    #[test]
255    fn modal_stack_push_pop() {
256        let mut stack = ModalStack::new();
257        stack.push(Box::new(ClosingDialog));
258        assert!(!stack.is_empty());
259        assert_eq!(stack.top().map(Dialog::title), Some("closing"));
260        let popped = stack.pop();
261        assert!(popped.is_some());
262        assert!(stack.is_empty());
263    }
264
265    #[test]
266    fn modal_stack_routes_keys_to_top() {
267        let count = Arc::new(AtomicUsize::new(0));
268        let mut stack = ModalStack::new();
269        stack.push(Box::new(CountingDialog {
270            count: count.clone(),
271        }));
272        let _ = stack.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
273        assert_eq!(count.load(Ordering::SeqCst), 1);
274    }
275
276    #[test]
277    fn modal_stack_close_outcome_pops_top() {
278        let mut stack = ModalStack::new();
279        stack.push(Box::new(ClosingDialog));
280        stack.push(Box::new(ClosingDialog));
281        assert_eq!(stack.depth(), 2);
282        let _ = stack.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
283        assert_eq!(stack.depth(), 1);
284    }
285
286    #[test]
287    fn modal_stack_handle_key_on_empty_is_noop() {
288        let mut stack = ModalStack::new();
289        let outcome = stack.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
290        assert!(matches!(outcome, DialogOutcome::Consumed));
291    }
292
293    #[test]
294    fn dialog_action_apply_live_carries_config() {
295        use rtcom_core::SerialConfig;
296        let cfg = SerialConfig::default();
297        let action = DialogAction::ApplyLive(cfg);
298        match action {
299            DialogAction::ApplyLive(_) => {}
300            _ => panic!("wrong variant"),
301        }
302    }
303
304    #[test]
305    fn dialog_preferred_size_default_is_30x12_centred() {
306        let d = ClosingDialog;
307        let outer = Rect {
308            x: 0,
309            y: 0,
310            width: 80,
311            height: 24,
312        };
313        let pref = d.preferred_size(outer);
314        assert_eq!(pref.width, 30);
315        assert_eq!(pref.height, 12);
316        // centred inside 80x24: x = (80 - 30) / 2 = 25, y = (24 - 12) / 2 = 6
317        assert_eq!(pref.x, 25);
318        assert_eq!(pref.y, 6);
319    }
320
321    #[test]
322    fn centred_rect_clips_to_outer() {
323        let outer = Rect {
324            x: 0,
325            y: 0,
326            width: 20,
327            height: 5,
328        };
329        let r = centred_rect(outer, 30, 12);
330        assert_eq!(r.width, 20);
331        assert_eq!(r.height, 5);
332        assert_eq!(r.x, 0);
333        assert_eq!(r.y, 0);
334    }
335}