1use crate::system::error::{DoumError, Result};
4use crate::cli::menu::MenuItem;
5use crossterm::{
6 event::{self, Event, KeyCode, KeyEventKind},
7 execute,
8 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
9};
10use ratatui::{
11 backend::CrosstermBackend,
12 layout::{Constraint, Direction, Layout},
13 style::{Color, Modifier, Style},
14 text::{Line, Span},
15 widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
16 Frame, Terminal,
17};
18use std::io;
19
20pub fn ratatui_select(
22 title: &str,
23 items: &[MenuItem],
24 subtitle: Option<&str>,
25 current_value: Option<&str>,
26) -> Result<Option<MenuItem>> {
27 enable_raw_mode().map_err(|e| DoumError::Config(format!("Failed to enable raw mode: {}", e)))?;
29 let mut stdout = io::stdout();
30 execute!(stdout, EnterAlternateScreen)
31 .map_err(|e| DoumError::Config(format!("Failed to enter alternate screen: {}", e)))?;
32 let backend = CrosstermBackend::new(stdout);
33 let mut terminal = Terminal::new(backend)
34 .map_err(|e| DoumError::Config(format!("Failed to create terminal: {}", e)))?;
35
36 let result = run_select_app(&mut terminal, title, items, subtitle, current_value);
38
39 disable_raw_mode().map_err(|e| DoumError::Config(format!("Failed to disable raw mode: {}", e)))?;
41 execute!(terminal.backend_mut(), LeaveAlternateScreen)
42 .map_err(|e| DoumError::Config(format!("Failed to leave alternate screen: {}", e)))?;
43 terminal.show_cursor()
44 .map_err(|e| DoumError::Config(format!("Failed to show cursor: {}", e)))?;
45
46 result
47}
48
49struct SelectApp<'a> {
51 items: &'a [MenuItem],
52 state: ListState,
53 title: String,
54 subtitle: Option<String>,
55 current_value: Option<String>,
56}
57
58impl<'a> SelectApp<'a> {
59 fn new(title: &str, items: &'a [MenuItem], subtitle: Option<&str>, current_value: Option<&str>) -> Self {
60 let mut state = ListState::default();
61 state.select(Some(0));
62
63 Self {
64 items,
65 state,
66 title: title.to_string(),
67 subtitle: subtitle.map(|s| s.to_string()),
68 current_value: current_value.map(|s| s.to_string()),
69 }
70 }
71
72 fn next(&mut self) {
73 let i = match self.state.selected() {
74 Some(i) => {
75 if i >= self.items.len() - 1 {
76 0
77 } else {
78 i + 1
79 }
80 }
81 None => 0,
82 };
83 self.state.select(Some(i));
84 }
85
86 fn previous(&mut self) {
87 let i = match self.state.selected() {
88 Some(i) => {
89 if i == 0 {
90 self.items.len() - 1
91 } else {
92 i - 1
93 }
94 }
95 None => 0,
96 };
97 self.state.select(Some(i));
98 }
99
100 fn get_selected(&self) -> Option<&MenuItem> {
101 self.state.selected().and_then(|i| self.items.get(i))
102 }
103}
104
105fn run_select_app(
107 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
108 title: &str,
109 items: &[MenuItem],
110 subtitle: Option<&str>,
111 current_value: Option<&str>,
112) -> Result<Option<MenuItem>> {
113 let mut app = SelectApp::new(title, items, subtitle, current_value);
114
115 loop {
116 terminal.draw(|f| ui(f, &mut app))
117 .map_err(|e| DoumError::Config(format!("Failed to draw: {}", e)))?;
118
119 if let Event::Key(key) = event::read()
120 .map_err(|e| DoumError::Config(format!("Failed to read event: {}", e)))?
121 && key.kind == KeyEventKind::Press {
122 match key.code {
123 KeyCode::Char('q') | KeyCode::Esc => return Ok(None),
124 KeyCode::Down | KeyCode::Char('j') => app.next(),
125 KeyCode::Up | KeyCode::Char('k') => app.previous(),
126 KeyCode::Enter => {
127 return Ok(app.get_selected().cloned());
128 }
129 _ => {}
130 }
131 }
132 }
133}
134
135fn ui(f: &mut Frame, app: &mut SelectApp) {
137 let chunks = Layout::default()
139 .direction(Direction::Vertical)
140 .margin(1)
141 .constraints([
142 Constraint::Length(2), Constraint::Min(3), Constraint::Length(7), Constraint::Length(1), ])
147 .split(f.area());
148
149 let title_text = vec![
151 Line::from(Span::styled(
152 &app.title,
153 Style::default()
154 .fg(Color::White)
155 .add_modifier(Modifier::BOLD)
156 )),
157 ];
158 let title_widget = Paragraph::new(title_text);
159 f.render_widget(title_widget, chunks[0]);
160
161 let items: Vec<ListItem> = app.items.iter().enumerate().map(|(idx, item)| {
163 let is_selected = Some(idx) == app.state.selected();
164 let is_current = app.current_value.as_ref() == Some(&item.id);
165
166 let mut label = String::new();
167 let mut style = Style::default();
168
169 if is_selected {
171 label.push_str(" › ");
172 style = style.fg(Color::Cyan).add_modifier(Modifier::BOLD);
173 } else {
174 label.push_str(" ");
175 style = style.fg(Color::Gray);
176 }
177
178 label.push_str(&item.label);
179
180 if is_current {
182 label.push_str(" ✓");
183 if !is_selected {
184 style = style.fg(Color::Green);
185 }
186 }
187
188 if item.id == "back" {
190 style = if is_selected {
191 style.fg(Color::Yellow).add_modifier(Modifier::BOLD)
192 } else {
193 style.fg(Color::Yellow)
194 };
195 } else if item.id == "exit" {
196 style = if is_selected {
197 style.fg(Color::Red).add_modifier(Modifier::BOLD)
198 } else {
199 style.fg(Color::Red)
200 };
201 }
202
203 ListItem::new(Line::from(Span::styled(label, style)))
204 }).collect();
205
206 let list = List::new(items)
207 .block(Block::default()
208 .borders(Borders::NONE));
209
210 f.render_stateful_widget(list, chunks[1], &mut app.state);
211
212 let description = if let Some(selected) = app.get_selected() {
214 selected.description.clone()
215 } else {
216 String::from("")
217 };
218
219 let desc_lines = vec![
220 Line::from(""),
221 Line::from(Span::styled(
222 "Description",
223 Style::default().fg(Color::DarkGray).add_modifier(Modifier::DIM)
224 )),
225 Line::from(""),
226 Line::from(Span::styled(
227 description,
228 Style::default().fg(Color::White)
229 )),
230 ];
231
232 let desc_widget = Paragraph::new(desc_lines)
233 .block(Block::default()
234 .borders(Borders::TOP)
235 .border_style(Style::default().fg(Color::DarkGray)));
236
237 f.render_widget(desc_widget, chunks[2]);
238
239 let help_text = if let Some(ref subtitle) = app.subtitle {
241 subtitle.clone()
242 } else {
243 String::from("↑↓ j/k • ↵ select • esc cancel")
244 };
245
246 let help = Paragraph::new(Line::from(Span::styled(
247 help_text,
248 Style::default().fg(Color::DarkGray)
249 )));
250 f.render_widget(help, chunks[3]);
251}
252
253pub fn ratatui_input(
255 prompt: &str,
256 default: Option<&str>,
257 help: Option<&str>,
258) -> Result<String> {
259 enable_raw_mode().map_err(|e| DoumError::Config(format!("Failed to enable raw mode: {}", e)))?;
261 let mut stdout = io::stdout();
262 execute!(stdout, EnterAlternateScreen)
263 .map_err(|e| DoumError::Config(format!("Failed to enter alternate screen: {}", e)))?;
264 let backend = CrosstermBackend::new(stdout);
265 let mut terminal = Terminal::new(backend)
266 .map_err(|e| DoumError::Config(format!("Failed to create terminal: {}", e)))?;
267
268 let result = run_input_app(&mut terminal, prompt, default, help);
270
271 disable_raw_mode().map_err(|e| DoumError::Config(format!("Failed to disable raw mode: {}", e)))?;
273 execute!(terminal.backend_mut(), LeaveAlternateScreen)
274 .map_err(|e| DoumError::Config(format!("Failed to leave alternate screen: {}", e)))?;
275 terminal.show_cursor()
276 .map_err(|e| DoumError::Config(format!("Failed to show cursor: {}", e)))?;
277
278 result
279}
280
281struct InputApp {
283 input: String,
284 prompt: String,
285 help: Option<String>,
286 cursor_position: usize,
287}
288
289impl InputApp {
290 fn new(prompt: &str, default: Option<&str>, help: Option<&str>) -> Self {
291 let input = default.unwrap_or("").to_string();
292 let cursor_position = input.len();
293
294 Self {
295 input,
296 prompt: prompt.to_string(),
297 help: help.map(|s| s.to_string()),
298 cursor_position,
299 }
300 }
301
302 fn move_cursor_left(&mut self) {
303 let cursor_moved_left = self.cursor_position.saturating_sub(1);
304 self.cursor_position = self.clamp_cursor(cursor_moved_left);
305 }
306
307 fn move_cursor_right(&mut self) {
308 let cursor_moved_right = self.cursor_position.saturating_add(1);
309 self.cursor_position = self.clamp_cursor(cursor_moved_right);
310 }
311
312 fn enter_char(&mut self, c: char) {
313 self.input.insert(self.cursor_position, c);
314 self.move_cursor_right();
315 }
316
317 fn delete_char(&mut self) {
318 if self.cursor_position > 0 {
319 self.input.remove(self.cursor_position - 1);
320 self.move_cursor_left();
321 }
322 }
323
324 fn clamp_cursor(&self, new_cursor_pos: usize) -> usize {
325 new_cursor_pos.min(self.input.len())
326 }
327}
328
329fn run_input_app(
331 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
332 prompt: &str,
333 default: Option<&str>,
334 help: Option<&str>,
335) -> Result<String> {
336 let mut app = InputApp::new(prompt, default, help);
337
338 loop {
339 terminal.draw(|f| ui_input(f, &app))
340 .map_err(|e| DoumError::Config(format!("Failed to draw: {}", e)))?;
341
342 if let Event::Key(key) = event::read()
343 .map_err(|e| DoumError::Config(format!("Failed to read event: {}", e)))?
344 && key.kind == KeyEventKind::Press {
345 match key.code {
346 KeyCode::Enter => return Ok(app.input.clone()),
347 KeyCode::Char(c) => app.enter_char(c),
348 KeyCode::Backspace => app.delete_char(),
349 KeyCode::Left => app.move_cursor_left(),
350 KeyCode::Right => app.move_cursor_right(),
351 KeyCode::Esc => return Err(DoumError::Config("Input cancelled".to_string())),
352 _ => {}
353 }
354 }
355 }
356}
357
358fn ui_input(f: &mut Frame, app: &InputApp) {
360 let chunks = Layout::default()
361 .direction(Direction::Vertical)
362 .constraints([
363 Constraint::Length(3), Constraint::Length(3), Constraint::Length(2), ])
367 .split(f.area());
368
369 let prompt_text = vec![
371 Line::from(Span::styled(&app.prompt, Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))),
372 ];
373 let prompt_widget = Paragraph::new(prompt_text)
374 .block(Block::default().borders(Borders::ALL));
375 f.render_widget(prompt_widget, chunks[0]);
376
377 let input_widget = Paragraph::new(app.input.as_str())
379 .style(Style::default().fg(Color::White))
380 .block(Block::default().borders(Borders::ALL).title("Input"));
381 f.render_widget(input_widget, chunks[1]);
382
383 f.set_cursor_position((chunks[1].x + app.cursor_position as u16 + 1, chunks[1].y + 1));
385
386 let help_text = if let Some(ref help) = app.help {
388 help.clone()
389 } else {
390 String::from("Enter: Submit | Esc: Cancel")
391 };
392
393 let help = Paragraph::new(Line::from(Span::styled(
394 help_text,
395 Style::default().fg(Color::DarkGray)
396 )));
397 f.render_widget(help, chunks[2]);
398}
399
400pub fn ratatui_password(
402 prompt: &str,
403 help: Option<&str>,
404) -> Result<String> {
405 enable_raw_mode().map_err(|e| DoumError::Config(format!("Failed to enable raw mode: {}", e)))?;
407 let mut stdout = io::stdout();
408 execute!(stdout, EnterAlternateScreen)
409 .map_err(|e| DoumError::Config(format!("Failed to enter alternate screen: {}", e)))?;
410 let backend = CrosstermBackend::new(stdout);
411 let mut terminal = Terminal::new(backend)
412 .map_err(|e| DoumError::Config(format!("Failed to create terminal: {}", e)))?;
413
414 let result = run_password_app(&mut terminal, prompt, help);
416
417 disable_raw_mode().map_err(|e| DoumError::Config(format!("Failed to disable raw mode: {}", e)))?;
419 execute!(terminal.backend_mut(), LeaveAlternateScreen)
420 .map_err(|e| DoumError::Config(format!("Failed to leave alternate screen: {}", e)))?;
421 terminal.show_cursor()
422 .map_err(|e| DoumError::Config(format!("Failed to show cursor: {}", e)))?;
423
424 result
425}
426
427fn run_password_app(
429 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
430 prompt: &str,
431 help: Option<&str>,
432) -> Result<String> {
433 let mut app = InputApp::new(prompt, None, help);
434
435 loop {
436 terminal.draw(|f| ui_password(f, &app))
437 .map_err(|e| DoumError::Config(format!("Failed to draw: {}", e)))?;
438
439 if let Event::Key(key) = event::read()
440 .map_err(|e| DoumError::Config(format!("Failed to read event: {}", e)))?
441 && key.kind == KeyEventKind::Press {
442 match key.code {
443 KeyCode::Enter => return Ok(app.input.clone()),
444 KeyCode::Char(c) => app.enter_char(c),
445 KeyCode::Backspace => app.delete_char(),
446 KeyCode::Esc => return Err(DoumError::Config("Input cancelled".to_string())),
447 _ => {}
448 }
449 }
450 }
451}
452
453fn ui_password(f: &mut Frame, app: &InputApp) {
455 let chunks = Layout::default()
456 .direction(Direction::Vertical)
457 .constraints([
458 Constraint::Length(3), Constraint::Length(3), Constraint::Length(2), ])
462 .split(f.area());
463
464 let prompt_text = vec![
466 Line::from(Span::styled(&app.prompt, Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))),
467 ];
468 let prompt_widget = Paragraph::new(prompt_text)
469 .block(Block::default().borders(Borders::ALL));
470 f.render_widget(prompt_widget, chunks[0]);
471
472 let masked = "*".repeat(app.input.len());
474 let input_widget = Paragraph::new(masked.as_str())
475 .style(Style::default().fg(Color::White))
476 .block(Block::default().borders(Borders::ALL).title("Password"));
477 f.render_widget(input_widget, chunks[1]);
478
479 let help_text = if let Some(ref help) = app.help {
481 help.clone()
482 } else {
483 String::from("Enter: Submit | Esc: Cancel")
484 };
485
486 let help = Paragraph::new(Line::from(Span::styled(
487 help_text,
488 Style::default().fg(Color::DarkGray)
489 )));
490 f.render_widget(help, chunks[2]);
491}