doum_cli/cli/
tui.rs

1// Ratatui-based Select UI with dynamic description
2
3use 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
20/// Enhanced select with ratatui
21pub fn ratatui_select(
22    title: &str,
23    items: &[MenuItem],
24    subtitle: Option<&str>,
25    current_value: Option<&str>,
26) -> Result<Option<MenuItem>> {
27    // Setup terminal
28    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    // Run the app
37    let result = run_select_app(&mut terminal, title, items, subtitle, current_value);
38
39    // Restore terminal
40    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
49/// App state
50struct 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
105/// Run the select app
106fn 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
135/// Draw the UI
136fn ui(f: &mut Frame, app: &mut SelectApp) {
137    // Create layout with more breathing room
138    let chunks = Layout::default()
139        .direction(Direction::Vertical)
140        .margin(1)
141        .constraints([
142            Constraint::Length(2),  // Title (no border)
143            Constraint::Min(3),     // List
144            Constraint::Length(7),  // Description (increased for better visibility)
145            Constraint::Length(1),  // Help
146        ])
147        .split(f.area());
148
149    // Title - clean, no border
150    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    // List items with minimal styling
162    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        // Selection indicator
170        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        // Current value indicator - more prominent
181        if is_current {
182            label.push_str(" ✓");
183            if !is_selected {
184                style = style.fg(Color::Green);
185            }
186        }
187        
188        // Special colors for back/exit - more visible
189        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    // Description - more prominent with better spacing
213    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    // Help text - minimal
240    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
253/// Text input with ratatui
254pub fn ratatui_input(
255    prompt: &str,
256    default: Option<&str>,
257    help: Option<&str>,
258) -> Result<String> {
259    // Setup terminal
260    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    // Run the input app
269    let result = run_input_app(&mut terminal, prompt, default, help);
270
271    // Restore terminal
272    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
281/// Input app state
282struct 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
329/// Run input app
330fn 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
358/// Draw input UI
359fn ui_input(f: &mut Frame, app: &InputApp) {
360    let chunks = Layout::default()
361        .direction(Direction::Vertical)
362        .constraints([
363            Constraint::Length(3),  // Prompt
364            Constraint::Length(3),  // Input
365            Constraint::Length(2),  // Help
366        ])
367        .split(f.area());
368
369    // Prompt
370    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    // Input
378    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    // Set cursor position
384    f.set_cursor_position((chunks[1].x + app.cursor_position as u16 + 1, chunks[1].y + 1));
385
386    // Help
387    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
400/// Password input with ratatui
401pub fn ratatui_password(
402    prompt: &str,
403    help: Option<&str>,
404) -> Result<String> {
405    // Setup terminal
406    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    // Run the password app
415    let result = run_password_app(&mut terminal, prompt, help);
416
417    // Restore terminal
418    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
427/// Run password app
428fn 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
453/// Draw password UI
454fn ui_password(f: &mut Frame, app: &InputApp) {
455    let chunks = Layout::default()
456        .direction(Direction::Vertical)
457        .constraints([
458            Constraint::Length(3),  // Prompt
459            Constraint::Length(3),  // Input (masked)
460            Constraint::Length(2),  // Help
461        ])
462        .split(f.area());
463
464    // Prompt
465    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    // Input (masked)
473    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    // Help
480    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}