diff --git a/app/src/tui/mod.rs b/app/src/tui/mod.rs new file mode 100644 index 0000000..4462592 --- /dev/null +++ b/app/src/tui/mod.rs @@ -0,0 +1,673 @@ +use anyhow::Result; +use crossterm:: + event::{self, Event, KeyCode, KeyEvent, KeyModifiers}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType}, +}; +use ratatui:: + backend::CrosstermBackend, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style, Stylize}, + text::{Line, Span, Text}, + widgets::{ + Block, Borders, Cell, HighlightSpacing, List, ListItem, ListState, Paragraph, + Row, Table, Tabs, Widget, + }, + Terminal, +}; +use std::io::{self, Stdout}; +use std::time::{Duration, Instant}; + +use crate::config::{AppConfig, Config}; +use crate::parser::{Argument, CommandInfo, SubCommand}; + +enum ActiveTab { + Commands, + Arguments, + Wizard, + Help, +} + +enum InputMode { + Normal, + Editing, +} + +struct WizardState { + selected_args: Vec, + arg_values: Vec<(String, String)>, + current_arg_index: usize, + input_buffer: String, + input_mode: InputMode, + validation_errors: Vec, +} + +impl Default for WizardState { + fn default() -> Self { + Self { + selected_args: Vec::new(), + arg_values: Vec::new(), + current_arg_index: 0, + input_buffer: String::new(), + input_mode: InputMode::Normal, + validation_errors: Vec::new(), + } + } +} + +pub struct TuiApp { + command_info: CommandInfo, + config: AppConfig, + list_state: ListState, + tab_state: usize, + active_tab: ActiveTab, + search_query: String, + search_mode: bool, + wizard_state: WizardState, + filtered_commands: Vec, + should_quit: bool, + last_tick: Instant, + help_visible: bool, +} + +impl TuiApp { + pub fn new(command_info: CommandInfo, config: Config) -> Self { + let subcommand_names: Vec = command_info.subcommands + .iter() + .map(|s| s.name.clone()) + .collect(); + + Self { + command_info, + config: config.config, + list_state: ListState::default().with_selected(Some(0)), + tab_state: 0, + active_tab: ActiveTab::Commands, + search_query: String::new(), + search_mode: false, + wizard_state: WizardState::default(), + filtered_commands: subcommand_names.clone(), + should_quit: false, + last_tick: Instant::now(), + help_visible: false, + } + } + + pub async fn run(&mut self) -> Result<()> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, Clear(ClearType::All))?; + + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + loop { + terminal.draw(|f| self.render(f))?; + + if event::poll(Duration::from_millis(50))? { + if let Event::Key(key) = event::read()? { + self.handle_key(key)?; + } + } + + if self.should_quit { + break; + } + + if Instant::now().duration_since(self.last_tick) > Duration::from_secs(300) { + break; + } + } + + disable_raw_mode()?; + execute!(terminal.backend_mut(), Clear(ClearType::All))?; + Ok(()) + } + + fn handle_key(&mut self, key: KeyEvent) -> Result<()> { + match self.active_tab { + ActiveTab::Commands => self.handle_commands_tab(key), + ActiveTab::Arguments => self.handle_arguments_tab(key), + ActiveTab::Wizard => self.handle_wizard_tab(key), + ActiveTab::Help => self.handle_help_tab(key), + } + + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + self.should_quit = true; + } + + if key.code == KeyCode::Esc { + if self.search_mode { + self.search_mode = false; + } else if self.help_visible { + self.help_visible = false; + } else { + self.should_quit = true; + } + } + + if key.code == KeyCode::Char('/') && !self.search_mode { + self.search_mode = true; + self.search_query.clear(); + } + + if key.code == KeyCode::Char('?') { + self.help_visible = !self.help_visible; + } + + if !self.search_mode { + match key.code { + KeyCode::Tab => { + self.tab_state = (self.tab_state + 1) % 4; + self.active_tab = match self.tab_state { + 0 => ActiveTab::Commands, + 1 => ActiveTab::Arguments, + 2 => ActiveTab::Wizard, + 3 => ActiveTab::Help, + _ => ActiveTab::Commands, + }; + } + _ => {} + } + } + + Ok(()) + } + + fn handle_commands_tab(&mut self, key: KeyEvent) { + if self.search_mode { + match key.code { + KeyCode::Enter => { + self.search_mode = false; + } + KeyCode::Backspace => { + self.search_query.pop(); + self.update_filtered_commands(); + } + KeyCode::Char(c) => { + self.search_query.push(c); + self.update_filtered_commands(); + } + KeyCode::Esc => { + self.search_mode = false; + } + _ => {} + } + } else { + match key.code { + KeyCode::Up => { + if let Some(selected) = self.list_state.selected() { + let new_selected = if selected == 0 { + self.filtered_commands.len().saturating_sub(1) + } else { + selected - 1 + }; + self.list_state.select(Some(new_selected)); + } + } + KeyCode::Down => { + if let Some(selected) = self.list_state.selected() { + let new_selected = (selected + 1) % self.filtered_commands.len(); + self.list_state.select(Some(new_selected)); + } + } + KeyCode::Enter => { + if let Some(selected) = self.list_state.selected() { + if let Some(cmd_name) = self.filtered_commands.get(selected) { + self.build_command(cmd_name); + } + } + } + _ => {} + } + } + } + + fn handle_arguments_tab(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Up => { + if let Some(selected) = self.list_state.selected() { + let len = self.command_info.arguments.len(); + if len > 0 { + let new_selected = if selected == 0 { + len - 1 + } else { + selected - 1 + }; + self.list_state.select(Some(new_selected)); + } + } + } + KeyCode::Down => { + if let Some(selected) = self.list_state.selected() { + let len = self.command_info.arguments.len(); + if len > 0 { + let new_selected = (selected + 1) % len; + self.list_state.select(Some(new_selected)); + } + } + } + _ => {} + } + } + + fn handle_wizard_tab(&mut self, key: KeyEvent) { + match self.wizard_state.input_mode { + InputMode::Normal => match key.code { + KeyCode::Up => { + self.wizard_state.current_arg_index = + self.wizard_state.current_arg_index.saturating_sub(1); + } + KeyCode::Down => { + let len = self.command_info.arguments.len(); + if len > 0 { + self.wizard_state.current_arg_index = + (self.wizard_state.current_arg_index + 1) % len; + } + } + KeyCode::Enter => { + self.wizard_state.input_mode = InputMode::Editing; + self.wizard_state.input_buffer.clear(); + } + KeyCode::Backspace => { + if let Some(pos) = self.wizard_state.selected_args.iter().position(|s| { + s == self.command_info.arguments + .get(self.wizard_state.current_arg_index) + .map(|a| a.name.as_str()) + .unwrap_or("") + }) { + self.wizard_state.selected_args.remove(pos); + self.wizard_state.arg_values.remove(pos); + } + } + _ => {} + }, + InputMode::Editing => match key.code { + KeyCode::Enter => { + self.wizard_state.input_mode = InputMode::Normal; + if !self.wizard_state.input_buffer.is_empty() { + let arg = &self.command_info.arguments[self.wizard_state.current_arg_index]; + self.wizard_state.selected_args.push(arg.name.clone()); + self.wizard_state + .arg_values + .push((arg.name.clone(), self.wizard_state.input_buffer.clone())); + } + self.wizard_state.input_buffer.clear(); + } + KeyCode::Esc => { + self.wizard_state.input_mode = InputMode::Normal; + self.wizard_state.input_buffer.clear(); + } + KeyCode::Backspace => { + self.wizard_state.input_buffer.pop(); + } + KeyCode::Char(c) => { + self.wizard_state.input_buffer.push(c); + } + _ => {} + }, + } + } + + fn handle_help_tab(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Up => {} + KeyCode::Down => {} + _ => {} + } + } + + fn update_filtered_commands(&mut self) { + if self.search_query.is_empty() { + self.filtered_commands = self + .command_info + .subcommands + .iter() + .map(|s| s.name.clone()) + .collect(); + } else { + self.filtered_commands = self + .command_info + .subcommands + .iter() + .filter(|s| s.name.contains(&self.search_query)) + .map(|s| s.name.clone()) + .collect(); + } + } + + fn build_command(&self, subcommand: &str) -> String { + let mut cmd = vec![self.command_info.name.clone(), subcommand.to_string()]; + + for (arg, value) in &self.wizard_state.arg_values { + if let Some(long) = &self.command_info.arguments.iter().find(|a| &a.name == arg).and_then(|a| a.long.clone()) { + cmd.push(format!("--{}", long)); + cmd.push(value.clone()); + } else { + cmd.push(value.clone()); + } + } + + cmd.join(" ") + } + + fn render(&mut self, f: &mut ratatui::Frame) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(0), + Constraint::Length(1), + ]) + .split(f.size()); + + self.render_header(f, chunks[0]); + self.render_content(f, chunks[1]); + self.render_status_bar(f, chunks[2]); + + if self.help_visible { + self.render_help_popup(f); + } + + if self.search_mode { + self.render_search(f); + } + } + + fn render_header(&self, f: &mut ratatui::Frame, area: Rect) { + let tabs = vec![ + "Commands", + "Arguments", + "Wizard", + "Help", + ]; + + let tab_titles: Vec = tabs + .iter() + .map(|t| Line::from(*t).centered()) + .collect(); + + let tabs_widget = Tabs::new(tab_titles) + .select(self.tab_state) + .style(Style::default().fg(Color::Gray)) + .highlight_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + .divider(Span::raw(" | ")); + + f.render_widget(tabs_widget, area); + } + + fn render_content(&mut self, f: &mut ratatui::Frame, area: Rect) { + match self.active_tab { + ActiveTab::Commands => self.render_commands_tab(f, area), + ActiveTab::Arguments => self.render_arguments_tab(f, area), + ActiveTab::Wizard => self.render_wizard_tab(f, area), + ActiveTab::Help => self.render_help_tab(f, area), + } + } + + fn render_commands_tab(&self, f: &mut ratatui::Frame, area: Rect) { + let items: Vec = self + .filtered_commands + .iter() + .map(|cmd_name| { + let subcmd = self + .command_info + .subcommands + .iter() + .find(|s| &s.name == cmd_name); + + let description = subcmd.map(|s| s.description.clone()).unwrap_or_default(); + ListItem::new(Line::from(vec![ + Span::styled(cmd_name, Style::default().fg(Color::Cyan).bold()), + Span::raw(" - "), + Span::styled(description, Style::default().fg(Color::Gray)), + ])) + }) + .collect(); + + let list = List::new(items) + .block( + Block::default() + .title(format!("{} Commands", self.command_info.name)) + .borders(Borders::ALL), + ) + .highlight_style(Style::default().bg(Color::DarkGray).fg(Color::White)) + .highlight_spacing(HighlightSpacing::Always); + + f.render_stateful_widget(list, area, &mut self.list_state.clone()); + } + + fn render_arguments_tab(&self, f: &mut ratatui::Frame, area: Rect) { + let rows: Vec = self + .command_info + .arguments + .iter() + .map(|arg| { + let flags = vec![ + arg.short.map(|s| format!("-{}", s)).unwrap_or_default(), + arg.long.clone().unwrap_or_default(), + ] + .iter() + .filter(|s| !s.is_empty()) + .map(|s| s.as_str()) + .collect::>() + .join(", "); + + Row::new(vec![ + Cell::from(flags), + Cell::from(arg.description.clone()), + Cell::from(if arg.required { "Yes" } else { "No" }), + Cell::from(arg.default_value.clone().unwrap_or_default()), + ]) + }) + .collect(); + + let table = Table::new(rows) + .block( + Block::default() + .title("Arguments") + .borders(Borders::ALL), + ) + .header( + Row::new(vec!["Flags", "Description", "Required", "Default"]) + .style(Style::default().fg(Color::Yellow).bold()), + ) + .widths(&[ + Constraint::Percentage(30), + Constraint::Percentage(45), + Constraint::Percentage(10), + Constraint::Percentage(15), + ]); + + f.render_widget(table, area); + } + + fn render_wizard_tab(&mut self, f: &mut ratatui::Frame, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0), Constraint::Length(3)]) + .split(area); + + let args_list: Vec = self + .command_info + .arguments + .iter() + .enumerate() + .map(|(i, arg)| { + let is_selected = i == self.wizard_state.current_arg_index; + let style = if is_selected { + if let InputMode::Editing = self.wizard_state.input_mode { + Style::default().bg(Color::Blue).fg(Color::White) + } else { + Style::default().bg(Color::DarkGray).fg(Color::White) + } + } else { + Style::default() + }; + + let value = self + .wizard_state + .arg_values + .iter() + .find(|(n, _)| n == &arg.name) + .map(|(_, v)| format!(": {}", v)) + .unwrap_or_default(); + + ListItem::new(Line::from(vec![Span::styled( + format!("{} {}{}", arg.name, arg.description, value), + style, + )])) + }) + .collect(); + + let list = List::new(args_list) + .block(Block::default().title("Command Arguments").borders(Borders::ALL)) + .highlight_spacing(HighlightSpacing::Always); + + f.render_stateful_widget(list, chunks[0], &mut self.list_state.clone()); + + let input_block = Block::default() + .title("Input") + .borders(Borders::ALL) + .style(Style::default().bg(Color::Black)); + + let input_text = match self.wizard_state.input_mode { + InputMode::Normal => Paragraph::new("Press Enter to edit").style(Style::default().fg(Color::Gray)), + InputMode::Editing => { + Paragraph::new(&self.wizard_state.input_buffer).style(Style::default().fg(Color::Green)) + } + }; + + f.render_widget(input_block, chunks[1]); + f.render_widget(input_text, chunks[1]); + } + + fn render_help_tab(&self, f: &mut ratatui::Frame, area: Rect) { + let content = vec![ + Line::from(vec![Span::styled("Keyboard Shortcuts", Style::default().bold().fg(Color::Cyan))]), + Line::from(vec![Span::raw("")]), + Line::from(vec![Span::raw(" Tab - Switch between tabs")]), + Line::from(vec![Span::raw(" Up/Down - Navigate items")]), + Line::from(vec![Span::raw(" Enter - Select/Confirm")]), + Line::from(vec![Span::raw(" Esc - Exit/Close")]), + Line::from(vec![Span::raw(" / - Search")]), + Line::from(vec![Span::raw(" ? - Toggle help")]), + Line::from(vec![Span::raw(" Ctrl+C - Quit")]), + Line::from(vec![Span::raw("")]), + Line::from(vec![Span::styled("Examples", Style::default().bold().fg(Color::Cyan))]), + ]; + + for example in &self.command_info.examples { + content.push(Line::from(vec![Span::raw(format!(" {}", example))])); + } + + let paragraph = Paragraph::new(content) + .block(Block::default().title("Help & Documentation").borders(Borders::ALL)) + .wrap(ratatui::widgets::Wrap { trim: true }); + + f.render_widget(paragraph, area); + } + + fn render_status_bar(&self, f: &mut ratatui::Frame, area: Rect) { + let status_text = match self.active_tab { + ActiveTab::Commands => "Commands Tab - Use / to search", + ActiveTab::Arguments => "Arguments Tab - View all available arguments", + ActiveTab::Wizard => "Wizard Tab - Build commands interactively", + ActiveTab::Help => "Help Tab - Documentation and shortcuts", + }; + + let left = Span::styled(status_text, Style::default().fg(Color::Green)); + let right = Span::styled("Press ? for help", Style::default().fg(Color::Gray)); + + let line = Line::from(vec![left, Span::raw(" ").repeat(area.width as usize - status_text.len() - 18), right]); + + f.render_widget(Paragraph::new(line).style(Style::default().bg(Color::Black)), area); + } + + fn render_help_popup(&self, f: &mut ratatui::Frame) { + let area = centered_rect(50, 60, f.size()); + let block = Block::default() + .title("Keyboard Shortcuts") + .borders(Borders::ALL) + .style(Style::default().bg(Color::DarkGray)); + + let content = vec![ + Line::from(vec![Span::styled("Navigation", Style::default().bold())]), + Line::from(vec![Span::raw(" ↑/↓ Navigate lists")]), + Line::from(vec![Span::raw(" Tab Switch tabs")]), + Line::from(vec![Span::raw(" Enter Select item / Confirm")]), + Line::from(vec![Span::raw(" Esc Close / Back")]), + Line::from(vec![Span::raw("")]), + Line::from(vec![Span::styled("Search", Style::default().bold())]), + Line::from(vec![Span::raw(" / Start search")]), + Line::from(vec![Span::raw(" Type Filter results")]), + Line::from(vec![Span::raw(" Enter Exit search")]), + Line::from(vec![Span::raw("")]), + Line::from(vec![Span::styled("Wizard", Style::default().bold())]), + Line::from(vec![Span::raw(" Enter Edit argument value")]), + Line::from(vec![Span::raw(" Esc Cancel editing")]), + Line::from(vec![Span::raw(" Bksp Remove argument")]), + ]; + + let paragraph = Paragraph::new(content) + .block(block) + .wrap(ratatui::widgets::Wrap { trim: true }); + + f.render_widget(paragraph, area); + } + + fn render_search(&self, f: &mut ratatui::Frame) { + let area = Layout::default() + .constraints([Constraint::Length(3)]) + .split(f.size())[0]; + + let block = Block::default() + .title("Search") + .borders(Borders::ALL) + .style(Style::default().bg(Color::Black)); + + let input = Paragraph::new(format!("> {}", self.search_query)) + .style(Style::default().fg(Color::Yellow)); + + f.render_widget(block, area); + f.render_widget(input, area); + } +} + +fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r)[1]; + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout)[1] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_wizard_state_default() { + let state = WizardState::default(); + assert!(state.selected_args.is_empty()); + assert!(state.arg_values.is_empty()); + assert_eq!(state.current_arg_index, 0); + assert!(state.input_buffer.is_empty()); + } +}