Skip to main content

tui/
modal.rs

1//! Modal overlay stack.
2//!
3//! The TUI keeps a LIFO stack of modals. The topmost modal owns the
4//! keyboard focus and is rendered on top of the current tab. Key events
5//! are first offered to the topmost modal; only if it doesn't consume
6//! them do they fall through to the tab.
7
8use crate::widgets::Editor;
9
10/// A config-set modal: two input fields (name + value) plus a Save/
11/// Cancel action. Placeholder for richer forms later.
12#[derive(Debug)]
13pub struct ConfigSetModal {
14    pub name: Editor,
15    pub value: Editor,
16    pub focus: ConfigSetField,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum ConfigSetField {
21    Name,
22    Value,
23}
24
25/// Anything renderable-as-modal. Kept as an enum rather than a trait
26/// object so modal state stays `Clone`/`Debug` and survives through
27/// `Stack`'s `Vec`.
28#[derive(Debug)]
29pub enum Modal {
30    ConfigSet(ConfigSetModal),
31    Help,
32}
33
34/// Stack of modals. The topmost (last-pushed) modal is the active one.
35#[derive(Debug, Default)]
36pub struct Stack {
37    modals: Vec<Modal>,
38}
39
40impl Stack {
41    #[must_use]
42    pub const fn new() -> Self {
43        Self { modals: Vec::new() }
44    }
45
46    pub fn push(&mut self, modal: Modal) {
47        self.modals.push(modal);
48    }
49
50    pub fn pop(&mut self) -> Option<Modal> {
51        self.modals.pop()
52    }
53
54    #[must_use]
55    pub fn top(&self) -> Option<&Modal> {
56        self.modals.last()
57    }
58
59    #[must_use]
60    pub fn top_mut(&mut self) -> Option<&mut Modal> {
61        self.modals.last_mut()
62    }
63
64    #[must_use]
65    pub fn is_empty(&self) -> bool {
66        self.modals.is_empty()
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73    use crate::widgets::EditMode;
74
75    fn a_config_set_modal() -> Modal {
76        Modal::ConfigSet(ConfigSetModal {
77            name: Editor::new(EditMode::Emacs),
78            value: Editor::new(EditMode::Emacs),
79            focus: ConfigSetField::Name,
80        })
81    }
82
83    #[test]
84    fn empty_stack_has_no_top() {
85        let stack = Stack::new();
86        assert!(stack.is_empty());
87        assert!(stack.top().is_none());
88    }
89
90    #[test]
91    fn push_then_pop_yields_last_in_first_out() {
92        let mut stack = Stack::new();
93        stack.push(Modal::Help);
94        stack.push(a_config_set_modal());
95        let popped = stack.pop().expect("expected a modal");
96        assert!(matches!(popped, Modal::ConfigSet(_)));
97        assert!(matches!(stack.top(), Some(Modal::Help)));
98        assert!(!stack.is_empty());
99    }
100
101    #[test]
102    fn pop_on_empty_returns_none() {
103        let mut stack = Stack::new();
104        assert!(stack.pop().is_none());
105    }
106
107    #[test]
108    fn top_mut_allows_mutating_focus_field() {
109        let mut stack = Stack::new();
110        stack.push(a_config_set_modal());
111        if let Some(Modal::ConfigSet(m)) = stack.top_mut() {
112            m.focus = ConfigSetField::Value;
113        }
114        match stack.top() {
115            Some(Modal::ConfigSet(m)) => assert_eq!(m.focus, ConfigSetField::Value),
116            _ => panic!("expected ConfigSet modal"),
117        }
118    }
119}