Skip to main content

horto_os_ui_tui/
prompt.rs

1//! Ratatui modals: y/N confirm, free-text, and masked secret input.
2
3use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
4use ratatui::{
5    layout::Rect,
6    style::{Color, Modifier, Style},
7    widgets::{Block, Borders, Clear, Paragraph},
8    Frame,
9};
10
11/// Outcome of a confirm modal key.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum ConfirmResult {
14    /// Enter / y
15    Yes,
16    /// Esc / n
17    No,
18    /// Key ignored
19    Ignore,
20}
21
22/// Handle y/N / Enter / Esc for a confirm overlay.
23#[must_use]
24pub const fn confirm_key(key: KeyEvent) -> ConfirmResult {
25    match key.code {
26        KeyCode::Enter | KeyCode::Char('y' | 'Y') => ConfirmResult::Yes,
27        KeyCode::Esc | KeyCode::Char('n' | 'N') => ConfirmResult::No,
28        _ => ConfirmResult::Ignore,
29    }
30}
31
32/// Free-text modal (non-secret). Global shortcuts must not fire while open.
33#[derive(Debug, Clone)]
34pub struct TextInput {
35    title: String,
36    buffer: String,
37}
38
39/// Masked secret modal (sudo password). Same keys as [`TextInput`]; display is `*`.
40#[derive(Debug, Clone)]
41pub struct SecretInput {
42    title: String,
43    buffer: String,
44}
45
46/// Result of handling a key in [`TextInput`] / [`SecretInput`].
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum TextInputResult {
49    /// Still editing
50    Continue,
51    /// Enter: submit current buffer (trimmed by caller if desired)
52    Submit(String),
53    /// Esc: cancel
54    Cancel,
55}
56
57impl TextInput {
58    /// Create a text modal with an optional initial value.
59    #[must_use]
60    pub fn new(title: impl Into<String>, initial: impl Into<String>) -> Self {
61        Self {
62            title: title.into(),
63            buffer: initial.into(),
64        }
65    }
66
67    /// Apply a key. Printable chars edit; Backspace deletes; Enter/Esc finish.
68    pub fn handle_key(&mut self, key: KeyEvent) -> TextInputResult {
69        edit_buffer(&mut self.buffer, key)
70    }
71}
72
73impl SecretInput {
74    /// Create a masked secret modal (empty buffer).
75    #[must_use]
76    pub fn new(title: impl Into<String>) -> Self {
77        Self {
78            title: title.into(),
79            buffer: String::new(),
80        }
81    }
82
83    /// Apply a key (same as text; buffer is never shown).
84    pub fn handle_key(&mut self, key: KeyEvent) -> TextInputResult {
85        edit_buffer(&mut self.buffer, key)
86    }
87}
88
89fn edit_buffer(buffer: &mut String, key: KeyEvent) -> TextInputResult {
90    if key.modifiers.contains(KeyModifiers::CONTROL) {
91        return TextInputResult::Continue;
92    }
93    match key.code {
94        KeyCode::Enter => TextInputResult::Submit(std::mem::take(buffer)),
95        KeyCode::Esc => TextInputResult::Cancel,
96        KeyCode::Backspace => {
97            buffer.pop();
98            TextInputResult::Continue
99        }
100        KeyCode::Char(c) if !c.is_control() => {
101            buffer.push(c);
102            TextInputResult::Continue
103        }
104        _ => TextInputResult::Continue,
105    }
106}
107
108fn modal_block(title: &str, border: Color) -> Block<'static> {
109    Block::default()
110        .borders(Borders::ALL)
111        .title(title.to_owned())
112        .style(Style::default().bg(Color::Black).fg(Color::White))
113        .border_style(
114            Style::default()
115                .fg(border)
116                .bg(Color::Black)
117                .add_modifier(Modifier::BOLD),
118        )
119}
120
121/// Draw a compact centered confirm dialog (opaque so the panel behind cannot bleed).
122pub fn draw_confirm(f: &mut Frame, title: &str, body: &str) {
123    let area = centered_fixed(64, 7, f.area());
124    f.render_widget(Clear, area);
125    let text = format!("{body}\n\nEnter/y confirm · Esc/n cancel");
126    let p = Paragraph::new(text)
127        .style(Style::default().bg(Color::Black).fg(Color::White))
128        .block(modal_block(title, Color::Yellow));
129    f.render_widget(p, area);
130}
131
132/// Draw a centered text-input dialog (opaque).
133pub fn draw_text_input(f: &mut Frame, input: &TextInput) {
134    let area = centered_fixed(70, 8, f.area());
135    f.render_widget(Clear, area);
136    let text = format!("{}\n\nEnter submit · Esc cancel", input.buffer);
137    let p = Paragraph::new(text)
138        .style(Style::default().bg(Color::Black).fg(Color::White))
139        .block(modal_block(&input.title, Color::Cyan));
140    f.render_widget(p, area);
141}
142
143/// Draw a centered masked secret dialog (opaque).
144pub fn draw_secret_input(f: &mut Frame, input: &SecretInput) {
145    let area = centered_fixed(56, 7, f.area());
146    f.render_widget(Clear, area);
147    let masked = if input.buffer.is_empty() {
148        " ".to_owned()
149    } else {
150        std::iter::repeat_n('*', input.buffer.chars().count()).collect()
151    };
152    let text = format!("{masked}\n\nEnter submit · Esc cancel");
153    let p = Paragraph::new(text)
154        .style(Style::default().bg(Color::Black).fg(Color::White))
155        .block(modal_block(&input.title, Color::Yellow));
156    f.render_widget(p, area);
157}
158
159/// Draw opaque "Rebooting…" wait dialog (SSH runs on a background thread).
160pub fn draw_rebooting(f: &mut Frame) {
161    let area = centered_fixed(48, 5, f.area());
162    f.render_widget(Clear, area);
163    let p = Paragraph::new("Rebooting…")
164        .style(Style::default().bg(Color::Black).fg(Color::White))
165        .block(modal_block("Reboot", Color::Yellow));
166    f.render_widget(p, area);
167}
168
169fn centered_fixed(width: u16, height: u16, area: Rect) -> Rect {
170    let width = width.min(area.width);
171    let height = height.min(area.height);
172    let x = area.x + (area.width.saturating_sub(width)) / 2;
173    let y = area.y + (area.height.saturating_sub(height)) / 2;
174    Rect {
175        x,
176        y,
177        width,
178        height,
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use crossterm::event::KeyModifiers;
186
187    #[test]
188    fn confirm_yes_no() {
189        let y = KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE);
190        let n = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
191        assert_eq!(confirm_key(y), ConfirmResult::Yes);
192        assert_eq!(confirm_key(n), ConfirmResult::No);
193    }
194
195    #[test]
196    fn text_input_type_backspace_submit_cancel() {
197        let mut t = TextInput::new("Host", "ho");
198        assert_eq!(
199            t.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)),
200            TextInputResult::Continue
201        );
202        assert_eq!(t.buffer, "hor");
203        assert_eq!(
204            t.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)),
205            TextInputResult::Continue
206        );
207        assert_eq!(t.buffer, "ho");
208        assert_eq!(
209            t.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
210            TextInputResult::Submit("ho".into())
211        );
212        let mut t2 = TextInput::new("Host", "x");
213        assert_eq!(
214            t2.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
215            TextInputResult::Cancel
216        );
217    }
218
219    #[test]
220    fn text_input_ignores_control_chars_as_shortcuts() {
221        let mut t = TextInput::new("Host", String::new());
222        let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
223        assert_eq!(t.handle_key(ctrl_c), TextInputResult::Continue);
224        assert_eq!(t.buffer, "");
225    }
226
227    #[test]
228    fn secret_input_masks_and_submits() {
229        let mut s = SecretInput::new("Sudo password");
230        assert_eq!(
231            s.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)),
232            TextInputResult::Continue
233        );
234        assert_eq!(
235            s.handle_key(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE)),
236            TextInputResult::Continue
237        );
238        assert_eq!(s.buffer, "ab");
239        assert_eq!(
240            s.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
241            TextInputResult::Submit("ab".into())
242        );
243        assert_eq!(s.buffer, "");
244    }
245}