Skip to main content

virtuoso_cli/tui/app/
overlay.rs

1/// Single-variant overlay — at most one active at a time. Push = assign a
2/// non-`None` variant, pop = assign `None`. No stack because business flows
3/// never nest (log, config form, cancel confirmation, help cheatsheet are
4/// mutually exclusive entry points).
5pub enum Overlay {
6    None,
7    Log(LogOverlay),
8    Confirm(ConfirmOverlay),
9    Form(ConfigFormState),
10    Help,
11}
12
13impl Overlay {
14    pub fn is_active(&self) -> bool {
15        !matches!(self, Overlay::None)
16    }
17}
18
19pub struct LogOverlay {
20    pub lines: Vec<String>,
21    pub scroll: usize,
22}
23
24impl LogOverlay {
25    pub fn new(lines: Vec<String>) -> Self {
26        let scroll = lines.len().saturating_sub(1);
27        Self { lines, scroll }
28    }
29}
30
31pub enum ConfirmAction {
32    CancelJob(usize),
33}
34
35pub struct ConfirmOverlay {
36    pub title: String,
37    pub message: String,
38    pub action: ConfirmAction,
39}
40
41pub struct ConfigFormState {
42    pub field_idx: usize,
43    pub key: String,
44    pub hint: &'static str,
45    pub value: TextInput,
46}
47
48/// Byte-safe cursor over a UTF-8 string. Borrowed pattern from cc-switch
49/// form.rs — `cursor` is a byte index, `move_left/right` walk char boundaries.
50#[derive(Default, Clone)]
51pub struct TextInput {
52    pub value: String,
53    pub cursor: usize,
54}
55
56impl TextInput {
57    pub fn new(s: &str) -> Self {
58        Self {
59            value: s.to_string(),
60            cursor: s.len(),
61        }
62    }
63
64    pub fn insert_char(&mut self, c: char) {
65        self.value.insert(self.cursor, c);
66        self.cursor += c.len_utf8();
67    }
68
69    pub fn backspace(&mut self) {
70        if self.cursor == 0 {
71            return;
72        }
73        let before = self.value[..self.cursor].chars().next_back();
74        if let Some(ch) = before {
75            let new_cursor = self.cursor - ch.len_utf8();
76            self.value.drain(new_cursor..self.cursor);
77            self.cursor = new_cursor;
78        }
79    }
80
81    pub fn move_left(&mut self) {
82        if let Some(ch) = self.value[..self.cursor].chars().next_back() {
83            self.cursor -= ch.len_utf8();
84        }
85    }
86
87    pub fn move_right(&mut self) {
88        if let Some(ch) = self.value[self.cursor..].chars().next() {
89            self.cursor += ch.len_utf8();
90        }
91    }
92
93    pub fn home(&mut self) {
94        self.cursor = 0;
95    }
96
97    pub fn end(&mut self) {
98        self.cursor = self.value.len();
99    }
100
101    pub fn as_str(&self) -> &str {
102        &self.value
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[test]
111    fn text_input_ascii_roundtrip() {
112        let mut ti = TextInput::default();
113        ti.insert_char('a');
114        ti.insert_char('b');
115        ti.insert_char('c');
116        assert_eq!(ti.as_str(), "abc");
117        assert_eq!(ti.cursor, 3);
118        ti.backspace();
119        assert_eq!(ti.as_str(), "ab");
120    }
121
122    #[test]
123    fn text_input_utf8_safe() {
124        let mut ti = TextInput::new("中文");
125        assert_eq!(ti.cursor, 6);
126        ti.move_left();
127        assert_eq!(ti.cursor, 3);
128        ti.backspace();
129        assert_eq!(ti.as_str(), "文");
130        assert_eq!(ti.cursor, 0);
131    }
132
133    #[test]
134    fn text_input_home_end() {
135        let mut ti = TextInput::new("hello");
136        ti.home();
137        assert_eq!(ti.cursor, 0);
138        ti.end();
139        assert_eq!(ti.cursor, 5);
140    }
141}