1use crossterm::event::KeyEvent;
6use ratatui::{buffer::Buffer, layout::Rect};
7
8use rtcom_config::ModalStyle;
9use rtcom_core::{LineEndingConfig, SerialConfig};
10
11pub enum DialogOutcome {
14 Consumed,
16 Close,
18 Action(DialogAction),
21 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#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum DialogAction {
43 ApplyLive(SerialConfig),
45 ApplyAndSave(SerialConfig),
48 ApplyLineEndingsLive(LineEndingConfig),
51 ApplyLineEndingsAndSave(LineEndingConfig),
54 SetDtr(bool),
56 SetRts(bool),
58 SendBreak,
60 WriteProfile,
62 ReadProfile,
64 ApplyModalStyleLive(ModalStyle),
67 ApplyModalStyleAndSave(ModalStyle),
70}
71
72pub trait Dialog {
79 fn title(&self) -> &str;
81 fn render(&self, area: Rect, buf: &mut Buffer);
83 fn handle_key(&mut self, key: KeyEvent) -> DialogOutcome;
85
86 fn preferred_size(&self, outer: Rect) -> Rect {
94 centred_rect(outer, 30, 12)
95 }
96}
97
98#[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
117pub 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 #[must_use]
136 pub const fn new() -> Self {
137 Self { stack: Vec::new() }
138 }
139
140 #[must_use]
142 pub fn is_empty(&self) -> bool {
143 self.stack.is_empty()
144 }
145
146 #[must_use]
148 pub fn depth(&self) -> usize {
149 self.stack.len()
150 }
151
152 #[must_use]
154 pub fn top(&self) -> Option<&(dyn Dialog + Send)> {
155 self.stack.last().map(AsRef::as_ref)
156 }
157
158 pub fn push(&mut self, dialog: Box<dyn Dialog + Send>) {
160 self.stack.push(dialog);
161 }
162
163 pub fn pop(&mut self) -> Option<Box<dyn Dialog + Send>> {
165 self.stack.pop()
166 }
167
168 pub fn clear(&mut self) {
171 self.stack.clear();
172 }
173
174 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 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 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 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}