1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum ConfirmResult {
14 Yes,
16 No,
18 Ignore,
20}
21
22#[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#[derive(Debug, Clone)]
34pub struct TextInput {
35 title: String,
36 buffer: String,
37}
38
39#[derive(Debug, Clone)]
41pub struct SecretInput {
42 title: String,
43 buffer: String,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum TextInputResult {
49 Continue,
51 Submit(String),
53 Cancel,
55}
56
57impl TextInput {
58 #[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 pub fn handle_key(&mut self, key: KeyEvent) -> TextInputResult {
69 edit_buffer(&mut self.buffer, key)
70 }
71}
72
73impl SecretInput {
74 #[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 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
121pub 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
132pub 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
143pub 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
159pub 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}