Initial upload with CI/CD workflow
Some checks failed
CI / test (push) Has been cancelled
CI / build (push) Has been cancelled

This commit is contained in:
2026-02-03 09:41:19 +00:00
parent 60f5260487
commit 4a110980db

673
app/src/tui/mod.rs Normal file
View File

@@ -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<String>,
arg_values: Vec<(String, String)>,
current_arg_index: usize,
input_buffer: String,
input_mode: InputMode,
validation_errors: Vec<String>,
}
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<String>,
should_quit: bool,
last_tick: Instant,
help_visible: bool,
}
impl TuiApp {
pub fn new(command_info: CommandInfo, config: Config) -> Self {
let subcommand_names: Vec<String> = 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<Line> = 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<ListItem> = 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<Row> = 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::<Vec<_>>()
.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<ListItem> = 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());
}
}