use std::sync::atomic::Ordering;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap},
Frame,
};
use ratatui_themes::ThemeName;
use tui_term::widget::PseudoTerminal;
#[cfg(test)]
extern crate insta;
use crate::app::{flatten_command_tree, App, AppMode, FlagValue, Focus};
use crate::widgets::{
build_help_line, panel_block, panel_title, push_edit_cursor, push_highlighted_name,
push_selection_cursor, render_help_overlays, selection_bg, CommandPreview, HelpBar, Keybind,
ItemContext, PanelState, SelectList, SelectListScrollState, UiColors,
};
pub fn render(frame: &mut Frame, app: &mut App) {
let palette = app.palette();
let colors = UiColors::from_palette(&palette);
if app.mode == AppMode::Executing {
render_execution_view(frame, app, &colors);
return;
}
let area = frame.area();
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(6), Constraint::Length(1), ])
.split(area);
app.click_regions.clear();
render_preview(frame, app, outer[0], &colors);
render_main_content(frame, app, outer[1], &colors);
render_help_bar(frame, app, outer[2], &colors);
app.click_regions.register(outer[0], Focus::Preview);
if app.choice_select.is_some() {
render_choice_select(frame, app, area, &colors);
}
if app.theme_picker.is_some() {
render_theme_picker(frame, app, area, &colors);
}
}
fn render_execution_view(frame: &mut Frame, app: &App, colors: &UiColors) {
let area = frame.area();
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(4), Constraint::Length(1), ])
.split(area);
let command_display = app
.execution
.as_ref()
.map(|e| e.command_display.clone())
.unwrap_or_default();
let cmd_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(colors.active_border))
.title(" Command ")
.title_style(Style::default().fg(colors.active_border).bold());
let cmd_spans = vec![
Span::styled("$ ", Style::default().fg(colors.command)),
Span::styled(
command_display,
Style::default()
.fg(colors.preview_cmd)
.add_modifier(Modifier::BOLD),
),
];
let cmd_paragraph = Paragraph::new(Line::from(cmd_spans))
.block(cmd_block)
.wrap(Wrap { trim: false });
frame.render_widget(cmd_paragraph, outer[0]);
let exited = app
.execution
.as_ref()
.map(|e| e.exited.load(Ordering::Relaxed))
.unwrap_or(false);
let term_block = Block::default().borders(Borders::NONE);
if let Some(ref exec) = app.execution {
if let Ok(parser) = exec.parser.read() {
let pseudo_term = PseudoTerminal::new(parser.screen())
.block(term_block)
.style(Style::default().fg(colors.preview_cmd).bg(colors.bg));
frame.render_widget(pseudo_term, outer[1]);
} else {
let fallback = Paragraph::new("(terminal output unavailable)")
.block(term_block)
.style(Style::default().fg(colors.help));
frame.render_widget(fallback, outer[1]);
}
} else {
let fallback = Paragraph::new("(no execution state)")
.block(term_block)
.style(Style::default().fg(colors.help));
frame.render_widget(fallback, outer[1]);
}
let status_text = if exited {
let exit_code = app.execution_exit_status().unwrap_or_default();
format!(
" Exited ({}) — press Esc/⏎/q to close ",
if exit_code.is_empty() {
"unknown".to_string()
} else {
exit_code
}
)
} else {
" Running… (input is forwarded to the process) ".to_string()
};
let status_style = if exited {
Style::default()
.fg(colors.help)
.add_modifier(Modifier::BOLD | Modifier::REVERSED)
} else {
Style::default()
.fg(colors.command)
.add_modifier(Modifier::BOLD | Modifier::REVERSED)
};
let status = Paragraph::new(status_text).style(status_style);
frame.render_widget(status, outer[2]);
}
fn render_main_content(frame: &mut Frame, app: &mut App, area: Rect, colors: &UiColors) {
let has_commands = !app.command_tree_nodes.is_empty();
if has_commands {
let h_split = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(area);
render_command_list(frame, app, h_split[0], colors);
let v_split = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(h_split[1]);
render_flag_list(frame, app, v_split[0], colors);
render_arg_list(frame, app, v_split[1], colors);
} else {
let v_split = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(area);
render_flag_list(frame, app, v_split[0], colors);
render_arg_list(frame, app, v_split[1], colors);
}
}
fn render_command_list(frame: &mut Frame, app: &mut App, area: Rect, colors: &UiColors) {
app.click_regions.register(area, Focus::Commands);
let ps = {
let base = PanelState::from_app(app, Focus::Commands, colors);
let scores = if !base.filter_text.is_empty() {
app.compute_tree_match_scores()
} else {
std::collections::HashMap::new()
};
base.with_scores(scores)
};
let flat_commands = flatten_command_tree(&app.command_tree_nodes);
let title = panel_title("Commands", &ps);
let block = panel_block(title, &ps);
let inner_height = area.height.saturating_sub(2) as usize;
app.ensure_visible(Focus::Commands, inner_height);
let selected_index = app.command_index();
let hovered_index = if ps.is_focused { app.hovered_index(Focus::Commands) } else { None };
let mut help_entries: Vec<(usize, Line<'static>)> = Vec::new();
let items: Vec<ListItem> = flat_commands
.iter()
.enumerate()
.map(|(i, cmd)| {
let is_selected = i == selected_index;
let is_hovered = hovered_index == Some(i) && !is_selected;
let ctx = ItemContext::new(&cmd.id, is_selected, &ps);
let mut spans = Vec::new();
push_selection_cursor(&mut spans, is_selected, colors);
if cmd.depth > 0 {
let indent = " ".repeat(cmd.depth - 1);
spans.push(Span::styled(indent, Style::default().fg(colors.help)));
spans.push(Span::styled("│ ", Style::default().fg(colors.help)));
}
let name_text = if !cmd.aliases.is_empty() {
format!("{} ({})", cmd.name, cmd.aliases.join(", "))
} else {
cmd.name.clone()
};
push_highlighted_name(
&mut spans,
&name_text,
colors.command,
&ctx,
&ps,
colors,
);
if let Some(help) = &cmd.help {
help_entries.push((i, build_help_line(help, &ctx, &ps, colors)));
}
let mut item = ListItem::new(Line::from(spans));
if is_selected {
item = item.style(selection_bg(false, colors));
} else if is_hovered {
item = item.style(Style::default().bg(colors.hover_bg));
}
item
})
.collect();
let selected_index = app.command_index();
let mut state = ListState::default()
.with_selected(if ps.is_focused {
Some(selected_index)
} else {
None
})
.with_offset(app.command_scroll());
let list = List::new(items).block(block);
frame.render_stateful_widget(list, area, &mut state);
render_panel_scrollbar(frame, area, flat_commands.len(), app.command_scroll(), colors);
let inner = area.inner(ratatui::layout::Margin::new(1, 1));
render_help_overlays(frame.buffer_mut(), &help_entries, app.command_scroll(), inner);
}
fn render_flag_list(frame: &mut Frame, app: &mut App, area: Rect, colors: &UiColors) {
app.click_regions.register(area, Focus::Flags);
let ps = {
let base = PanelState::from_app(app, Focus::Flags, colors);
let scores = if !base.filter_text.is_empty() {
app.compute_flag_match_scores()
} else {
std::collections::HashMap::new()
};
base.with_scores(scores)
};
let flag_index = app.flag_index();
let title = panel_title("Flags", &ps);
let block = panel_block(title, &ps);
let inner_height = area.height.saturating_sub(2) as usize;
app.ensure_visible(Focus::Flags, inner_height);
let flags = app.visible_flags();
let flag_defaults: Vec<Option<String>> =
flags.iter().map(|f| f.default.first().cloned()).collect();
let inner_left = area.x + 1; let negate_cols: std::collections::HashMap<usize, u16> = flags
.iter()
.enumerate()
.filter(|(_, flag)| flag.negate.is_some())
.map(|(i, flag)| {
let display_len = flag_display_string(flag).len() as u16;
let mut col = inner_left + 2 + 2 + display_len;
if flag.global {
col += 4; }
if flag.required {
col += 2; }
col += 3; (i, col)
})
.collect();
drop(flags); app.flag_negate_cols = negate_cols;
let flags = app.visible_flags();
let flag_values = app.current_flag_values();
let hovered_index = if ps.is_focused { app.hovered_index(Focus::Flags) } else { None };
let mut help_entries: Vec<(usize, Line<'static>)> = Vec::new();
let items: Vec<ListItem> = flags
.iter()
.enumerate()
.map(|(i, flag)| {
let is_selected = ps.is_focused && i == flag_index;
let is_hovered = hovered_index == Some(i) && !is_selected;
let is_editing = is_selected && app.editing;
let value = flag_values.iter().find(|(n, _)| n == &flag.name);
let default_val = flag_defaults.get(i).and_then(|d| d.as_ref());
let ctx = ItemContext::new(&flag.name, is_selected, &ps);
let mut spans = Vec::new();
push_selection_cursor(&mut spans, is_selected, colors);
let indicator = match value.map(|(_, v)| v) {
Some(FlagValue::Bool(true)) => Span::styled(
"✓ ",
Style::default().fg(colors.arg).add_modifier(Modifier::BOLD),
),
Some(FlagValue::Bool(false)) => {
Span::styled("○ ", Style::default().fg(colors.help))
}
Some(FlagValue::NegBool(None)) => {
Span::styled("○ ", Style::default().fg(colors.help))
}
Some(FlagValue::NegBool(Some(true))) => Span::styled(
"✓ ",
Style::default().fg(colors.arg).add_modifier(Modifier::BOLD),
),
Some(FlagValue::NegBool(Some(false))) => Span::styled(
"✗ ",
Style::default()
.fg(colors.required)
.add_modifier(Modifier::BOLD),
),
Some(FlagValue::Count(n)) => {
if *n > 0 {
Span::styled(format!("[{}] ", n), Style::default().fg(colors.count))
} else {
Span::styled("[0] ", Style::default().fg(colors.help))
}
}
Some(FlagValue::String(s)) => {
if s.is_empty() {
Span::styled("[·] ", Style::default().fg(colors.help))
} else {
Span::styled("[•] ", Style::default().fg(colors.arg))
}
}
None => Span::styled("○ ", Style::default().fg(colors.help)),
};
spans.push(indicator);
let flag_display = flag_display_string(flag);
let is_negated = matches!(value.map(|(_, v)| v), Some(FlagValue::NegBool(Some(false))));
let flag_name_color = if is_negated { colors.help } else { colors.flag };
push_highlighted_name(
&mut spans,
&flag_display,
flag_name_color,
&ctx,
&ps,
colors,
);
if flag.global {
let global_style = if !ctx.is_match && !ps.match_scores.is_empty() {
Style::default().fg(colors.help).add_modifier(Modifier::DIM)
} else {
Style::default().fg(colors.help)
};
spans.push(Span::styled(" [G]", global_style));
}
if flag.required {
let required_style = if !ctx.is_match && !ps.match_scores.is_empty() {
Style::default().fg(colors.help).add_modifier(Modifier::DIM)
} else {
Style::default().fg(colors.required)
};
spans.push(Span::styled(" *", required_style));
}
if let Some(ref negate) = flag.negate {
let dim = !ctx.is_match && !ps.match_scores.is_empty();
let sep_style = if dim {
Style::default().fg(colors.help).add_modifier(Modifier::DIM)
} else {
Style::default().fg(colors.help)
};
spans.push(Span::styled(" / ", sep_style));
let negate_style = if is_negated {
if dim {
Style::default().fg(colors.flag).add_modifier(Modifier::DIM)
} else {
Style::default().fg(colors.flag)
}
} else if dim {
Style::default().fg(colors.help).add_modifier(Modifier::DIM)
} else {
Style::default().fg(colors.help)
};
spans.push(Span::styled(negate.clone(), negate_style));
}
if let Some((_, FlagValue::String(s))) = value {
spans.push(Span::styled(" = ", Style::default().fg(colors.help)));
let is_choice_selecting = app.choice_select.as_ref().is_some_and(|cs| {
cs.source_panel == Focus::Flags && cs.source_index == i
});
if is_choice_selecting || is_editing {
let before_cursor = app.edit_input.text_before_cursor();
let after_cursor = app.edit_input.text_after_cursor();
push_edit_cursor(&mut spans, before_cursor, after_cursor, colors);
} else if s.is_empty() {
if let Some(ref arg) = flag.arg {
if let Some(ref choices) = arg.choices {
let hint = choices.choices.join("|");
spans.push(Span::styled(
format!("<{}>", hint),
Style::default().fg(colors.choice),
));
} else {
spans.push(Span::styled(
format!("<{}>", arg.name),
Style::default().fg(colors.default_val),
));
}
}
} else {
spans.push(Span::styled(s.to_string(), Style::default().fg(colors.value)));
if let Some(def) = default_val {
if s == def {
spans.push(Span::styled(
" (default)",
Style::default().fg(colors.default_val).italic(),
));
}
}
}
}
if let Some(help) = &flag.help {
help_entries.push((i, build_help_line(help, &ctx, &ps, colors)));
}
let line = Line::from(spans);
let mut item = ListItem::new(line);
if is_selected {
item = item.style(selection_bg(is_editing, colors));
} else if is_hovered {
item = item.style(Style::default().bg(colors.hover_bg));
}
item
})
.collect();
let total_flags = flags.len();
let mut state = ListState::default()
.with_selected(if ps.is_focused { Some(flag_index) } else { None })
.with_offset(app.flag_scroll());
let list = List::new(items).block(block);
frame.render_stateful_widget(list, area, &mut state);
render_panel_scrollbar(frame, area, total_flags, app.flag_scroll(), colors);
let inner = area.inner(ratatui::layout::Margin::new(1, 1));
render_help_overlays(frame.buffer_mut(), &help_entries, app.flag_scroll(), inner);
}
fn render_arg_list(frame: &mut Frame, app: &mut App, area: Rect, colors: &UiColors) {
app.click_regions.register(area, Focus::Args);
let ps = {
let base = PanelState::from_app(app, Focus::Args, colors);
let scores = if !base.filter_text.is_empty() {
app.compute_arg_match_scores()
} else {
std::collections::HashMap::new()
};
base.with_scores(scores)
};
let arg_index = app.arg_index();
let title = panel_title("Arguments", &ps);
let block = panel_block(title, &ps);
let inner_height = area.height.saturating_sub(2) as usize;
app.ensure_visible(Focus::Args, inner_height);
let hovered_index = if ps.is_focused { app.hovered_index(Focus::Args) } else { None };
let mut help_entries: Vec<(usize, Line<'static>)> = Vec::new();
let items: Vec<ListItem> = app
.arg_values
.iter()
.enumerate()
.map(|(i, arg_val)| {
let is_selected = ps.is_focused && i == arg_index;
let is_hovered = hovered_index == Some(i) && !is_selected;
let is_editing = is_selected && app.editing;
let ctx = ItemContext::new(&arg_val.name, is_selected, &ps);
let mut spans = Vec::new();
push_selection_cursor(&mut spans, is_selected, colors);
if arg_val.required {
spans.push(Span::styled("● ", Style::default().fg(colors.required)));
} else {
spans.push(Span::styled("○ ", Style::default().fg(colors.help)));
}
let bracket = if arg_val.required { "<>" } else { "[]" };
let arg_display = format!("{}{}{}", &bracket[..1], arg_val.name, &bracket[1..]);
push_highlighted_name(
&mut spans,
&arg_display,
colors.arg,
&ctx,
&ps,
colors,
);
spans.push(Span::styled(" = ", Style::default().fg(colors.help)));
let is_choice_selecting = app.choice_select.as_ref().is_some_and(|cs| {
cs.source_panel == Focus::Args && cs.source_index == i
});
if is_choice_selecting || is_editing {
let before_cursor = app.edit_input.text_before_cursor();
let after_cursor = app.edit_input.text_after_cursor();
push_edit_cursor(&mut spans, before_cursor, after_cursor, colors);
} else if arg_val.value.is_empty() {
if !arg_val.choices.is_empty() {
let hint = arg_val.choices.join("|");
spans.push(Span::styled(
format!("<{}>", hint),
Style::default().fg(colors.choice),
));
} else {
spans.push(Span::styled(
"(empty)",
Style::default().fg(colors.default_val),
));
}
} else {
spans.push(Span::styled(
arg_val.value.clone(),
Style::default().fg(colors.value),
));
}
if !arg_val.choices.is_empty() && !arg_val.value.is_empty() && !is_editing && !is_choice_selecting {
spans.push(Span::styled(
format!(" [{}]", arg_val.choices.join("|")),
Style::default().fg(colors.choice),
));
}
if let Some(ref help) = arg_val.help {
if !help.is_empty() {
help_entries.push((i, build_help_line(help, &ctx, &ps, colors)));
}
}
let line = Line::from(spans);
let mut item = ListItem::new(line);
if is_selected {
item = item.style(selection_bg(is_editing, colors));
} else if is_hovered {
item = item.style(Style::default().bg(colors.hover_bg));
}
item
})
.collect();
let total_args = app.arg_values.len();
let mut state = ListState::default()
.with_selected(if ps.is_focused { Some(arg_index) } else { None })
.with_offset(app.arg_scroll());
let list = List::new(items).block(block);
frame.render_stateful_widget(list, area, &mut state);
render_panel_scrollbar(frame, area, total_args, app.arg_scroll(), colors);
let inner = area.inner(ratatui::layout::Margin::new(1, 1));
render_help_overlays(frame.buffer_mut(), &help_entries, app.arg_scroll(), inner);
}
fn render_panel_scrollbar(
frame: &mut Frame,
area: Rect,
total_items: usize,
scroll_offset: usize,
colors: &UiColors,
) {
let inner_height = area.height.saturating_sub(2) as usize; if total_items <= inner_height || inner_height == 0 {
return;
}
let inner = area.inner(ratatui::layout::Margin::new(0, 1));
let mut scrollbar_state = ScrollbarState::new(total_items.saturating_sub(inner_height))
.position(scroll_offset);
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None)
.track_symbol(Some("│"))
.thumb_symbol("┃")
.track_style(Style::default().fg(colors.inactive_border))
.thumb_style(Style::default().fg(colors.active_border));
frame.render_stateful_widget(scrollbar, inner, &mut scrollbar_state);
}
fn render_choice_select(frame: &mut Frame, app: &mut App, terminal_area: Rect, colors: &UiColors) {
let Some(ref cs) = app.choice_select else {
return;
};
let source_panel = cs.source_panel;
let source_index = cs.source_index;
let value_column = cs.value_column;
let selected_index = cs.selected_index;
let filtered = app.filtered_choices();
let panel_area = match source_panel {
Focus::Flags => app
.click_regions
.regions()
.iter()
.find(|r| r.data == Focus::Flags)
.map(|r| r.area),
Focus::Args => app
.click_regions
.regions()
.iter()
.find(|r| r.data == Focus::Args)
.map(|r| r.area),
_ => None,
};
let Some(panel_area) = panel_area else {
return;
};
let scroll_offset = match source_panel {
Focus::Flags => app.flag_scroll(),
Focus::Args => app.arg_scroll(),
_ => 0,
};
let inner_y = panel_area.y + 1; let item_y = inner_y + (source_index as u16).saturating_sub(scroll_offset as u16);
let overlay_y = item_y + 1;
let max_choice_len = filtered
.iter()
.map(|(_, c)| c.chars().count())
.max()
.unwrap_or(10) as u16;
let max_visible = 10u16;
let space_below = terminal_area.height.saturating_sub(overlay_y).saturating_sub(1);
let max_items_that_fit = space_below.saturating_sub(1).max(1); let visible_count = if filtered.is_empty() {
1
} else {
(filtered.len() as u16).min(max_visible).min(max_items_that_fit)
};
let overlay_height = visible_count + 1;
let labels: Vec<String> = filtered.iter().map(|(_, c)| c.clone()).collect();
let descs: Vec<Option<String>> = filtered
.iter()
.map(|(orig_idx, _)| app.choice_description(*orig_idx).map(|s| s.to_string()))
.collect();
let max_desc_len = descs
.iter()
.map(|d| d.as_ref().map(|s| s.chars().count() + 2).unwrap_or(0))
.max()
.unwrap_or(0) as u16;
let overlay_width =
(max_choice_len + max_desc_len + 4).min(terminal_area.width.saturating_sub(2));
let overlay_x = (panel_area.x + value_column.saturating_sub(1))
.min(terminal_area.width.saturating_sub(overlay_width));
let overlay_y = overlay_y.min(terminal_area.height.saturating_sub(overlay_height));
let overlay_rect = Rect::new(overlay_x, overlay_y, overlay_width, overlay_height);
if let Some(ref mut cs) = app.choice_select {
cs.overlay_rect = Some(overlay_rect);
}
let hovered_index = app.mouse_position.and_then(|(col, row)| {
let inner_top = overlay_rect.y; let inner_bottom = overlay_rect.y + overlay_rect.height.saturating_sub(1);
if col >= overlay_rect.x && col < overlay_rect.x + overlay_rect.width
&& row >= inner_top && row < inner_bottom
{
let scroll_offset = app.choice_select.as_ref().map_or(0, |cs| cs.scroll_offset);
let idx = (row - inner_top) as usize + scroll_offset;
if idx < filtered.len() { Some(idx) } else { None }
} else {
None
}
});
let widget = SelectList::new(
String::new(),
&labels,
selected_index,
colors.choice,
colors.choice,
colors,
)
.with_descriptions(&descs)
.with_borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM)
.with_hovered(hovered_index);
let mut scroll_state = SelectListScrollState::default();
frame.render_stateful_widget(widget, overlay_rect, &mut scroll_state);
if let Some(ref mut cs) = app.choice_select {
cs.scroll_offset = scroll_state.scroll_offset;
cs.visible_items = scroll_state.visible_items;
}
}
fn render_theme_picker(frame: &mut Frame, app: &mut App, terminal_area: Rect, colors: &UiColors) {
let Some(ref tp) = app.theme_picker else {
return;
};
let selected_index = tp.selected_index;
let all = ThemeName::all();
let max_name_len = all
.iter()
.map(|t| t.display_name().len())
.max()
.unwrap_or(10) as u16;
let overlay_width = max_name_len + 6; let num_themes = all.len() as u16;
let overlay_height = (num_themes + 2).min(terminal_area.height.saturating_sub(2));
let overlay_x = terminal_area
.right()
.saturating_sub(overlay_width)
.max(terminal_area.x);
let help_bar_y = terminal_area.bottom().saturating_sub(1);
let overlay_y = help_bar_y.saturating_sub(overlay_height).max(terminal_area.y);
let overlay_rect = Rect::new(overlay_x, overlay_y, overlay_width, overlay_height);
if let Some(ref mut tp) = app.theme_picker {
tp.overlay_rect = Some(overlay_rect);
}
let labels: Vec<String> = all.iter().map(|t| t.display_name().to_string()).collect();
let widget = SelectList::new(
" Theme ".to_string(),
&labels,
Some(selected_index),
colors.choice,
colors.value,
colors,
)
.with_cursor();
frame.render_widget(widget, overlay_rect);
}
fn render_help_bar(frame: &mut Frame, app: &mut App, area: Rect, colors: &UiColors) {
let keybinds: &[Keybind] = if app.is_theme_picking() {
&[
Keybind { key: "↑↓", desc: "navigate" },
Keybind { key: "⏎", desc: "confirm" },
Keybind { key: "Esc", desc: "cancel" },
]
} else if app.is_choosing() {
&[
Keybind { key: "↑↓", desc: "select" },
Keybind { key: "⏎", desc: "confirm" },
Keybind { key: "Esc", desc: "keep text" },
]
} else if app.editing {
&[
Keybind { key: "⏎", desc: "confirm" },
Keybind { key: "Esc", desc: "cancel" },
]
} else if app.filtering {
&[
Keybind { key: "⏎", desc: "apply" },
Keybind { key: "Esc", desc: "clear" },
Keybind { key: "↑↓", desc: "navigate" },
]
} else if app.filter_active() {
&[
Keybind { key: "↑↓/jk", desc: "next match" },
Keybind { key: "/", desc: "new filter" },
Keybind { key: "Esc", desc: "clear filter" },
]
} else {
match app.focus() {
Focus::Commands => &[
Keybind { key: "↑↓", desc: "navigate" },
Keybind { key: "⇥", desc: "next" },
Keybind { key: "/", desc: "filter" },
Keybind { key: "^r", desc: "run" },
Keybind { key: "q", desc: "quit" },
],
Focus::Flags => &[
Keybind { key: "⏎/Space", desc: "toggle" },
Keybind { key: "↑↓", desc: "navigate" },
Keybind { key: "⇥", desc: "next" },
Keybind { key: "/", desc: "filter" },
Keybind { key: "^r", desc: "run" },
Keybind { key: "q", desc: "quit" },
],
Focus::Args => &[
Keybind { key: "⏎", desc: "edit" },
Keybind { key: "↑↓", desc: "navigate" },
Keybind { key: "⇥", desc: "next" },
Keybind { key: "/", desc: "filter" },
Keybind { key: "^r", desc: "run" },
Keybind { key: "q", desc: "quit" },
],
Focus::Preview => &[
Keybind { key: "⏎", desc: "run" },
Keybind { key: "⇥", desc: "next" },
Keybind { key: "q", desc: "quit" },
],
}
};
let theme_display = app.theme_name.display_name();
let widget = HelpBar::new(keybinds, theme_display, colors);
app.theme_indicator_rect = Some(widget.theme_indicator_rect(area));
frame.render_widget(widget, area);
}
fn render_preview(frame: &mut Frame, app: &App, area: Rect, colors: &UiColors) {
let is_focused = app.focus() == Focus::Preview;
let command = app.build_command();
let bin = if app.spec.bin.is_empty() {
&app.spec.name
} else {
&app.spec.bin
};
let widget = CommandPreview::new(&command, bin, &app.command_path, is_focused, colors);
frame.render_widget(widget, area);
}
fn flag_display_string(flag: &usage::SpecFlag) -> String {
let mut parts = Vec::new();
for s in &flag.short {
parts.push(format!("-{s}"));
}
for l in &flag.long {
parts.push(format!("--{l}"));
}
if parts.is_empty() {
flag.name.clone()
} else {
parts.join(", ")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::FlagValue;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{backend::TestBackend, Terminal};
use ratatui_themes::ThemeName;
fn sample_spec() -> usage::Spec {
let input = include_str!("../fixtures/sample.usage.kdl");
input
.parse::<usage::Spec>()
.expect("Failed to parse sample spec")
}
fn parse_spec(input: &str) -> usage::Spec {
input.parse::<usage::Spec>().expect("Failed to parse spec")
}
fn render_to_string(app: &mut App, width: u16, height: u16) -> String {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|frame| render(frame, app)).unwrap();
let buffer = terminal.backend().buffer().clone();
let mut output = String::new();
for y in 0..buffer.area.height {
for x in 0..buffer.area.width {
let cell = &buffer[(x, y)];
output.push_str(cell.symbol());
}
let trimmed = output.trim_end();
output = trimmed.to_string();
output.push('\n');
}
output
}
#[test]
fn snapshot_root_view() {
let mut app = App::new(sample_spec());
let output = render_to_string(&mut app, 100, 24);
insta::assert_snapshot!(output);
}
#[test]
fn snapshot_config_subcommands() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["config"]);
app.command_tree_state.expand("config");
let output = render_to_string(&mut app, 100, 24);
insta::assert_snapshot!(output);
}
#[test]
fn snapshot_deploy_leaf() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
let output = render_to_string(&mut app, 100, 24);
insta::assert_snapshot!(output);
}
#[test]
fn snapshot_run_command() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["run"]);
let output = render_to_string(&mut app, 100, 24);
insta::assert_snapshot!(output);
}
#[test]
fn snapshot_flags_toggled() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Flags);
let flag_values = app.current_flag_values();
let rollback_idx = flag_values
.iter()
.position(|(n, _)| n == "rollback")
.unwrap();
app.set_flag_index(rollback_idx);
let fidx = app.flag_index();
let vals = app.current_flag_values_mut();
if let Some((_, FlagValue::Bool(ref mut b))) = vals.get_mut(fidx) {
*b = true;
}
let tag_idx = app
.current_flag_values()
.iter()
.position(|(n, _)| n == "tag")
.unwrap();
app.set_flag_index(tag_idx);
let fidx = app.flag_index();
let vals = app.current_flag_values_mut();
if let Some((_, FlagValue::String(ref mut s))) = vals.get_mut(fidx) {
*s = "v1.2.3".to_string();
}
app.arg_values[0].value = "prod".to_string();
let output = render_to_string(&mut app, 100, 24);
insta::assert_snapshot!(output);
}
#[test]
fn snapshot_editing_arg() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["init"]);
app.set_focus(Focus::Args);
app.set_arg_index(0);
app.start_editing();
app.edit_input.set_text("my-project");
app.arg_values[0].value = "my-project".to_string();
let output = render_to_string(&mut app, 100, 24);
insta::assert_snapshot!(output);
}
#[test]
fn snapshot_filter_active() {
let mut app = App::new(sample_spec());
let slash = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('/'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(slash);
for c in "pl".chars() {
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(c),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(key);
}
let output = render_to_string(&mut app, 100, 24);
insta::assert_snapshot!(output);
}
#[test]
fn snapshot_preview_focused() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["run"]);
app.arg_values[0].value = "lint".to_string();
app.set_focus(Focus::Preview);
let output = render_to_string(&mut app, 100, 24);
insta::assert_snapshot!(output);
}
#[test]
fn snapshot_deep_navigation() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["plugin", "install"]);
let output = render_to_string(&mut app, 100, 24);
insta::assert_snapshot!(output);
}
#[test]
fn snapshot_choice_select_open() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Args);
app.set_arg_index(0);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
assert!(app.is_choosing());
let output = render_to_string(&mut app, 100, 24);
insta::assert_snapshot!(output);
}
#[test]
fn snapshot_choice_select_filtered() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Args);
app.set_arg_index(0);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
for c in "st".chars() {
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(c),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(key);
}
let output = render_to_string(&mut app, 100, 24);
insta::assert_snapshot!(output);
}
#[test]
fn snapshot_simple_flags_only() {
let spec = parse_spec(
r#"
bin "mytool"
flag "-v --verbose" help="Verbose output"
flag "-f --force" help="Force operation"
flag "--dry-run" help="Show what would happen"
arg "<input>" help="Input file"
arg "[output]" help="Output file"
"#,
);
let mut app = App::new(spec);
let output = render_to_string(&mut app, 80, 20);
insta::assert_snapshot!(output);
}
#[test]
fn snapshot_choices_flag() {
let spec = parse_spec(
r#"
bin "mycli"
cmd "format" help="Format code" {
flag "--style <style>" help="Code style" {
arg "<style>" {
choices "compact" "expanded" "default"
}
}
arg "<file>" help="File to format"
}
"#,
);
let mut app = App::new(spec);
app.navigate_to_command(&["format"]);
let output = render_to_string(&mut app, 80, 20);
insta::assert_snapshot!(output);
}
#[test]
fn snapshot_no_subcommands() {
let spec = parse_spec(
r#"
bin "simple"
about "A simple tool"
arg "<file>" help="File to process"
flag "-o --output <path>" help="Output path"
"#,
);
let mut app = App::new(spec);
let output = render_to_string(&mut app, 80, 20);
insta::assert_snapshot!(output);
}
#[test]
fn snapshot_count_flag_incremented() {
let spec = parse_spec(
r#"
bin "mycli"
flag "-v --verbose" help="Increase verbosity" count=#true
flag "-q --quiet" help="Quiet mode"
"#,
);
let mut app = App::new(spec);
let key = app.command_path.join(" ");
if let Some(flags) = app.flag_values.get_mut(&key) {
for (name, value) in flags.iter_mut() {
if name == "verbose" {
*value = FlagValue::Count(3);
}
}
}
let output = render_to_string(&mut app, 80, 20);
insta::assert_snapshot!(output);
}
#[test]
fn snapshot_flag_filter_active() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Flags);
app.filtering = true;
app.filter_input.set_text("roll");
let output = render_to_string(&mut app, 100, 24);
insta::assert_snapshot!(output);
}
#[test]
fn snapshot_flag_filter_verbose() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Flags);
app.filtering = true;
app.filter_input.set_text("verb");
let output = render_to_string(&mut app, 100, 24);
insta::assert_snapshot!(output);
}
#[test]
fn snapshot_flag_filter_selected_item() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Flags);
app.filtering = true;
app.filter_input.set_text("tag");
app.flag_list_state.selected_index = 0;
let output = render_to_string(&mut app, 100, 24);
insta::assert_snapshot!(output);
}
#[test]
fn snapshot_nord_theme() {
let mut app = App::with_theme(sample_spec(), ThemeName::Nord);
let output = render_to_string(&mut app, 100, 24);
insta::assert_snapshot!(output);
}
#[test]
fn snapshot_catppuccin_mocha_theme() {
let mut app = App::with_theme(sample_spec(), ThemeName::CatppuccinMocha);
let output = render_to_string(&mut app, 100, 24);
insta::assert_snapshot!(output);
}
#[test]
fn snapshot_tokyo_night_theme() {
let mut app = App::with_theme(sample_spec(), ThemeName::TokyoNight);
let output = render_to_string(&mut app, 100, 24);
insta::assert_snapshot!(output);
}
#[test]
fn snapshot_gruvbox_dark_theme() {
let mut app = App::with_theme(sample_spec(), ThemeName::GruvboxDark);
let output = render_to_string(&mut app, 100, 24);
insta::assert_snapshot!(output);
}
#[test]
fn test_render_root() {
let mut app = App::new(sample_spec());
let output = render_to_string(&mut app, 100, 30);
assert!(output.contains("mycli"));
assert!(output.contains("init"));
assert!(output.contains("config"));
assert!(output.contains("run"));
assert!(output.contains("deploy"));
assert!(output.contains("Command"));
}
#[test]
fn test_render_with_subcommand() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["config"]);
app.command_tree_state.expand("config");
let output = render_to_string(&mut app, 100, 30);
assert!(output.contains("config"));
assert!(output.contains("set"));
assert!(output.contains("get"));
assert!(output.contains("list"));
assert!(output.contains("remove"));
}
#[test]
fn test_render_leaf_command() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
let output = render_to_string(&mut app, 100, 30);
assert!(output.contains("Flags"));
assert!(output.contains("Arguments"));
assert!(output.contains("environment"));
}
#[test]
fn test_render_flag_display() {
let flag = usage::SpecFlag {
name: "verbose".to_string(),
short: vec!['v'],
long: vec!["verbose".to_string()],
..Default::default()
};
assert_eq!(flag_display_string(&flag), "-v, --verbose");
let flag2 = usage::SpecFlag {
name: "force".to_string(),
short: vec!['f'],
long: vec!["force".to_string()],
..Default::default()
};
assert_eq!(flag_display_string(&flag2), "-f, --force");
let flag3 = usage::SpecFlag {
name: "json".to_string(),
short: vec![],
long: vec!["json".to_string()],
..Default::default()
};
assert_eq!(flag_display_string(&flag3), "--json");
}
#[test]
fn test_render_command_preview_shows_built_command() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["init"]);
app.arg_values[0].value = "hello".to_string();
let output = render_to_string(&mut app, 100, 24);
assert!(output.contains("mycli init hello"));
}
#[test]
fn test_render_aliases_shown() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["config"]);
app.command_tree_state.expand("config");
let output = render_to_string(&mut app, 100, 30);
assert!(output.contains("add"));
assert!(output.contains("ls"));
assert!(output.contains("rm"));
}
#[test]
fn test_render_global_flag_indicator() {
let mut app = App::new(sample_spec());
let output = render_to_string(&mut app, 100, 30);
assert!(output.contains("[G]"));
}
#[test]
fn test_render_required_arg_indicator() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
let output = render_to_string(&mut app, 100, 24);
assert!(output.contains("<environment>"));
}
#[test]
fn test_render_choices_display() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
let output = render_to_string(&mut app, 100, 24);
assert!(output.contains("dev"));
assert!(output.contains("staging"));
assert!(output.contains("prod"));
}
#[test]
fn test_render_after_flag_toggle() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Flags);
let flag_values = app.current_flag_values();
let yes_idx = flag_values.iter().position(|(n, _)| n == "yes").unwrap();
app.set_flag_index(yes_idx);
app.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
let output = render_to_string(&mut app, 100, 24);
assert!(output.contains("✓"));
assert!(output.contains("--yes"));
}
#[test]
fn test_render_narrow_terminal() {
let mut app = App::new(sample_spec());
let output = render_to_string(&mut app, 60, 16);
assert!(output.contains("mycli"));
assert!(output.contains("Commands"));
}
#[test]
fn test_theme_cycling() {
let mut app = App::new(sample_spec());
assert_eq!(app.theme_name, ThemeName::Dracula);
app.next_theme();
assert_eq!(app.theme_name, ThemeName::OneDarkPro);
app.next_theme();
assert_eq!(app.theme_name, ThemeName::Nord);
app.prev_theme();
assert_eq!(app.theme_name, ThemeName::OneDarkPro);
}
#[test]
fn test_theme_key_binding() {
let mut app = App::new(sample_spec());
assert_eq!(app.theme_name, ThemeName::Dracula);
let key = KeyEvent::new(KeyCode::Char('T'), KeyModifiers::NONE);
app.handle_key(key);
assert!(app.is_theme_picking(), "T should open theme picker");
assert_eq!(app.theme_name, ThemeName::Dracula, "Theme should not change yet");
}
#[test]
fn test_theme_name_displayed_in_status_bar() {
let mut app = App::new(sample_spec());
let output = render_to_string(&mut app, 100, 24);
assert!(output.contains("Dracula"));
}
#[test]
fn test_with_theme_constructor() {
let app = App::with_theme(sample_spec(), ThemeName::Nord);
assert_eq!(app.theme_name, ThemeName::Nord);
assert_eq!(app.palette().accent, ThemeName::Nord.palette().accent);
}
#[test]
fn test_binary_name_visible_in_preview() {
let mut app = App::new(sample_spec());
let output = render_to_string(&mut app, 100, 24);
assert!(output.contains("mycli"));
}
#[test]
fn test_command_path_visible_in_ui() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["config"]);
let output = render_to_string(&mut app, 100, 24);
assert!(output.contains("mycli"));
assert!(output.contains("config"));
}
#[test]
fn test_preview_shows_flags_and_args() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
let fidx = app
.current_flag_values()
.iter()
.position(|(n, _)| n == "tag")
.unwrap();
app.set_flag_index(fidx);
let vals = app.current_flag_values_mut();
if let Some((_, FlagValue::String(ref mut s))) = vals.get_mut(fidx) {
*s = "latest".to_string();
}
app.arg_values[0].value = "prod".to_string();
let output = render_to_string(&mut app, 100, 24);
assert!(output.contains("mycli"));
assert!(output.contains("deploy"));
assert!(output.contains("--tag"));
assert!(output.contains("latest"));
assert!(output.contains("prod"));
}
#[test]
fn test_preview_focused_shows_run_indicator() {
let mut app = App::new(sample_spec());
app.set_focus(Focus::Preview);
let output = render_to_string(&mut app, 100, 24);
assert!(output.contains("▶"));
}
#[test]
fn test_preview_unfocused_shows_dollar() {
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
let output = render_to_string(&mut app, 100, 24);
assert!(output.contains("$ mycli"));
}
#[test]
fn test_focused_panel_shows_title() {
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
app.command_tree_state.selected_index = 2;
app.sync_command_path_from_tree();
let output = render_to_string(&mut app, 100, 24);
assert!(output.contains("Commands"));
}
#[test]
fn test_unfocused_panel_shows_title() {
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
let output = render_to_string(&mut app, 100, 24);
assert!(output.contains("Flags"));
app.navigate_to_command(&["init"]);
let output = render_to_string(&mut app, 100, 24);
assert!(output.contains("Arguments"));
}
#[test]
fn test_selection_cursor_visible() {
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
let output = render_to_string(&mut app, 100, 24);
assert!(output.contains("▶"));
}
#[test]
fn test_filter_shows_query_in_title() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Flags);
app.filtering = true;
app.filter_input.set_text("roll");
let output = render_to_string(&mut app, 100, 24);
assert!(
output.contains("Flags 🔍") && output.contains("roll"),
"Expected 'Flags 🔍' and 'roll' in output"
);
}
#[test]
fn test_filter_mode_shows_slash_immediately() {
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
app.filtering = true;
let output = render_to_string(&mut app, 100, 24);
assert!(
output.contains("Commands 🔍"),
"Panel title should show '🔍' immediately when filter mode is activated, got:\n{output}"
);
}
#[test]
fn test_filter_mode_shows_slash_in_flags_panel() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Flags);
app.filtering = true;
let output = render_to_string(&mut app, 100, 24);
assert!(
output.contains("Flags 🔍"),
"Flags title should show '🔍' immediately when filter mode is activated, got:\n{output}"
);
}
#[test]
fn test_checked_flag_shows_checked_box() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Flags);
let fidx = app
.current_flag_values()
.iter()
.position(|(n, _)| n == "rollback")
.unwrap();
app.set_flag_index(fidx);
let vals = app.current_flag_values_mut();
if let Some((_, FlagValue::Bool(ref mut b))) = vals.get_mut(fidx) {
*b = true;
}
let output = render_to_string(&mut app, 100, 24);
assert!(output.contains("✓"));
}
#[test]
fn test_unchecked_flag_shows_empty_box() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
let output = render_to_string(&mut app, 100, 24);
assert!(output.contains("○"));
}
#[test]
fn test_all_themes_render_without_panic() {
for theme in ThemeName::all() {
let mut app = App::with_theme(sample_spec(), *theme);
app.navigate_to_command(&["deploy"]);
let output = render_to_string(&mut app, 100, 24);
assert!(
output.contains("mycli"),
"Theme {:?} failed to render binary name",
theme
);
assert!(
output.contains("deploy"),
"Theme {:?} failed to render subcommand",
theme
);
assert!(
output.contains(theme.display_name()),
"Theme {:?} name not shown in status bar",
theme
);
}
}
#[test]
fn test_light_theme_renders() {
let mut app = App::with_theme(sample_spec(), ThemeName::CatppuccinLatte);
let output = render_to_string(&mut app, 100, 24);
assert!(output.contains("mycli"));
assert!(output.contains("Catppuccin Latte"));
}
#[test]
fn test_ui_colors_from_palette_consistency() {
let dracula_palette = ThemeName::Dracula.palette();
let nord_palette = ThemeName::Nord.palette();
let dracula_colors = super::UiColors::from_palette(&dracula_palette);
let nord_colors = super::UiColors::from_palette(&nord_palette);
assert_eq!(dracula_colors.command, dracula_palette.info);
assert_eq!(nord_colors.command, nord_palette.info);
assert_eq!(dracula_colors.flag, dracula_palette.warning);
assert_eq!(nord_colors.flag, nord_palette.warning);
assert_eq!(dracula_colors.required, dracula_palette.error);
assert_eq!(nord_colors.required, nord_palette.error);
}
#[test]
fn test_checkmark_style_checked() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Flags);
let fidx = app
.current_flag_values()
.iter()
.position(|(n, _)| n == "rollback")
.unwrap();
app.set_flag_index(fidx);
let vals = app.current_flag_values_mut();
if let Some((_, FlagValue::Bool(ref mut b))) = vals.get_mut(fidx) {
*b = true;
}
let output = render_to_string(&mut app, 100, 24);
assert!(
output.contains('✓'),
"Checked flag should show ✓ checkmark symbol"
);
assert!(
!output.contains('☑'),
"Should NOT use small ☑ symbol anymore"
);
}
#[test]
fn test_checkmark_style_unchecked() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
let output = render_to_string(&mut app, 100, 24);
assert!(
output.contains('○'),
"Unchecked flag should show ○ circle symbol"
);
assert!(
!output.contains('☐'),
"Should NOT use small ☐ symbol anymore"
);
}
#[test]
fn test_prominent_selection_caret() {
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
let output = render_to_string(&mut app, 100, 24);
assert!(
output.contains('▶'),
"Selection caret should be the prominent ▶ triangle"
);
}
#[test]
fn test_prominent_caret_in_flags_panel() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Flags);
let output = render_to_string(&mut app, 100, 24);
assert!(
output.contains('▶'),
"Flags panel should use prominent ▶ caret for selected item"
);
}
#[test]
fn test_prominent_caret_in_args_panel() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["run"]);
app.set_focus(Focus::Args);
let output = render_to_string(&mut app, 100, 24);
assert!(
output.contains('▶'),
"Args panel should use prominent ▶ caret for selected item"
);
}
#[test]
fn test_count_flag_decrement_renders() {
let mut app = App::new(sample_spec());
app.set_focus(Focus::Flags);
let fidx = app
.current_flag_values()
.iter()
.position(|(n, _)| n == "verbose")
.unwrap();
app.set_flag_index(fidx);
for _ in 0..3 {
app.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
}
let output = render_to_string(&mut app, 100, 24);
assert!(
output.contains("[3]"),
"Count flag at 3 should show [3] in the UI"
);
app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
let output = render_to_string(&mut app, 100, 24);
assert!(
output.contains("[2]"),
"After backspace, count flag should show [2]"
);
app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
let output = render_to_string(&mut app, 100, 24);
assert!(output.contains("[0]"), "Count flag at 0 should show [0]");
app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
let output = render_to_string(&mut app, 100, 24);
assert!(output.contains("[0]"), "Count flag should not go below 0");
}
#[test]
fn test_editing_arg_then_switching_does_not_bleed() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["run"]);
app.set_focus(Focus::Args);
app.set_arg_index(0);
app.start_editing();
for c in ['h', 'e', 'l', 'l', 'o'] {
app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
}
let output = render_to_string(&mut app, 100, 24);
assert!(
output.contains("hello"),
"Editing first arg should show 'hello'"
);
app.finish_editing();
app.set_arg_index(1);
let output = render_to_string(&mut app, 100, 24);
let args_line = output.lines().find(|l| l.contains("args")).unwrap_or("");
assert!(
!args_line.contains("hello"),
"Second arg should not contain text from first arg. Line: {}",
args_line
);
}
#[test]
fn test_count_flag_preview_after_decrement() {
let mut app = App::new(sample_spec());
app.set_focus(Focus::Flags);
let fidx = app
.current_flag_values()
.iter()
.position(|(n, _)| n == "verbose")
.unwrap();
app.set_flag_index(fidx);
app.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
app.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
let output = render_to_string(&mut app, 100, 24);
assert!(
output.contains("-vv"),
"Preview should show -vv for count 2"
);
app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
let output = render_to_string(&mut app, 100, 24);
assert!(
output.contains("-v") && !output.contains("-vv"),
"Preview should show -v for count 1"
);
app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
let output = render_to_string(&mut app, 100, 24);
assert!(
!output.contains("-v ") && !output.contains("-vv"),
"Preview should not contain -v when count is 0"
);
}
#[test]
fn test_cursor_position_in_editing() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["init"]);
app.set_focus(Focus::Args);
app.set_arg_index(0);
app.start_editing();
app.edit_input.insert_char('h');
app.edit_input.insert_char('e');
app.edit_input.insert_char('l');
app.edit_input.insert_char('l');
app.edit_input.insert_char('o');
let output = render_to_string(&mut app, 100, 24);
assert!(
output.contains("hello▎"),
"Should show cursor at end of text"
);
app.edit_input.move_home();
let output = render_to_string(&mut app, 100, 24);
assert!(
output.contains("▎hello"),
"Should show cursor at beginning of text"
);
app.edit_input.move_right();
app.edit_input.move_right();
let output = render_to_string(&mut app, 100, 24);
assert!(
output.contains("he▎llo"),
"Should show cursor in middle of text"
);
}
#[test]
fn snapshot_theme_picker_open() {
let mut app = App::new(sample_spec());
app.open_theme_picker();
let output = render_to_string(&mut app, 100, 24);
insta::assert_snapshot!(output);
}
#[test]
fn snapshot_theme_picker_navigated() {
let mut app = App::new(sample_spec());
app.open_theme_picker();
let down = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
app.handle_key(down);
app.handle_key(down);
let output = render_to_string(&mut app, 100, 24);
insta::assert_snapshot!(output);
}
#[test]
fn test_theme_picker_shows_all_themes() {
let mut app = App::new(sample_spec());
app.open_theme_picker();
let output = render_to_string(&mut app, 100, 24);
for theme in ThemeName::all() {
assert!(
output.contains(theme.display_name()),
"Theme picker should show '{}' but output was:\n{}",
theme.display_name(),
output
);
}
}
#[test]
fn test_theme_picker_help_bar_shows_picker_keybinds() {
let mut app = App::new(sample_spec());
app.open_theme_picker();
let output = render_to_string(&mut app, 100, 24);
assert!(
output.contains("⏎ confirm") && output.contains("Esc cancel"),
"Help bar should show theme picker keybinds"
);
}
#[test]
fn test_theme_picker_previews_theme() {
let mut app = App::new(sample_spec());
app.open_theme_picker();
let down = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
app.handle_key(down);
app.handle_key(down);
let output = render_to_string(&mut app, 100, 24);
assert!(
output.contains("[Nord]"),
"Theme indicator should show the previewed theme"
);
}
#[test]
fn test_theme_indicator_rect_set_after_render() {
let mut app = App::new(sample_spec());
assert!(app.theme_indicator_rect.is_none());
let _output = render_to_string(&mut app, 100, 24);
assert!(
app.theme_indicator_rect.is_some(),
"theme_indicator_rect should be set after rendering"
);
}
#[test]
fn snapshot_commands_scrollbar() {
let mut app = App::new(sample_spec());
let down = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
);
for _ in 0..7 {
app.handle_key(down);
}
let output = render_to_string(&mut app, 100, 12);
insta::assert_snapshot!(output);
}
#[test]
fn snapshot_select_scrollbar() {
let spec_text = r#"
name "test"
bin "test"
cmd "test" {
arg "<item>" {
choices "opt01" "opt02" "opt03" "opt04" "opt05" "opt06" "opt07" "opt08" "opt09" "opt10" "opt11" "opt12" "opt13" "opt14" "opt15"
}
}
"#;
let mut app = App::new(parse_spec(spec_text));
app.set_focus(Focus::Args);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
let down = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
);
for _ in 0..5 {
app.handle_key(down);
}
let output = render_to_string(&mut app, 100, 24);
insta::assert_snapshot!(output);
}
}