use std::io::Write;
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
use nucleo_matcher::{Config, Matcher};
use portable_pty::{MasterPty, PtySize};
use ratatui::layout::Rect;
use ratatui_interact::components::{InputState, ListPickerState, TreeNode, TreeViewState};
use ratatui_interact::state::FocusManager;
use ratatui_interact::traits::ClickRegionRegistry;
use ratatui_themes::{ThemeName, ThemePalette};
use usage::{Spec, SpecCommand, SpecFlag};
#[derive(Debug, Clone, Copy)]
pub struct MatchScores {
pub name_score: u32,
pub help_score: u32,
}
impl MatchScores {
pub fn overall(&self) -> u32 {
self.name_score.max(self.help_score)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
None,
Quit,
Execute,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppMode {
Builder,
Executing,
}
pub struct ExecutionState {
pub command_display: String,
pub parser: Arc<RwLock<vt100::Parser>>,
pub pty_writer: Arc<Mutex<Option<Box<dyn Write + Send>>>>,
pub pty_master: Arc<Mutex<Option<Box<dyn MasterPty + Send>>>>,
pub exited: Arc<AtomicBool>,
pub exit_status: Arc<Mutex<Option<String>>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Focus {
Commands,
Flags,
Args,
Preview,
}
#[derive(Debug, Clone, PartialEq)]
pub enum FlagValue {
Bool(bool),
NegBool(Option<bool>),
String(String),
Count(u32),
}
#[derive(Debug, Clone)]
pub struct ArgValue {
pub name: String,
pub value: String,
pub required: bool,
pub choices: Vec<String>,
pub help: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ThemePickerState {
pub original_theme: ThemeName,
pub selected_index: usize,
pub overlay_rect: Option<Rect>,
}
#[derive(Debug, Clone)]
pub struct ChoiceSelectState {
pub choices: Vec<String>,
pub descriptions: Vec<Option<String>>,
pub selected_index: Option<usize>,
pub source_panel: Focus,
pub source_index: usize,
pub value_column: u16,
pub overlay_rect: Option<Rect>,
pub filter_active: bool,
pub scroll_offset: usize,
pub visible_items: usize,
}
#[derive(Debug, Clone)]
pub struct CmdData {
pub name: String,
pub help: Option<String>,
pub aliases: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct FlatCommand {
pub id: String,
pub name: String,
pub help: Option<String>,
pub aliases: Vec<String>,
pub depth: usize,
pub full_path: String,
}
pub struct App {
pub spec: Spec,
pub mode: AppMode,
pub execution: Option<ExecutionState>,
pub theme_name: ThemeName,
pub command_path: Vec<String>,
pub flag_values: std::collections::HashMap<String, Vec<(String, FlagValue)>>,
pub arg_values: Vec<ArgValue>,
pub focus_manager: FocusManager<Focus>,
pub editing: bool,
pub filter_input: InputState,
pub filtering: bool,
pub command_tree_nodes: Vec<TreeNode<CmdData>>,
pub command_tree_state: TreeViewState,
pub flag_list_state: ListPickerState,
pub arg_list_state: ListPickerState,
pub edit_input: InputState,
pub click_regions: ClickRegionRegistry<Focus>,
pub choice_select: Option<ChoiceSelectState>,
pub theme_picker: Option<ThemePickerState>,
pub theme_indicator_rect: Option<Rect>,
pub flag_negate_cols: std::collections::HashMap<usize, u16>,
pub mouse_position: Option<(u16, u16)>,
}
impl App {
pub fn new(spec: Spec) -> Self {
Self::with_theme(spec, ThemeName::default())
}
pub fn is_executing(&self) -> bool {
self.mode == AppMode::Executing
}
pub fn execution_exited(&self) -> bool {
self.execution
.as_ref()
.map(|e| e.exited.load(Ordering::Relaxed))
.unwrap_or(false)
}
pub fn execution_exit_status(&self) -> Option<String> {
self.execution
.as_ref()
.and_then(|e| e.exit_status.lock().ok().and_then(|s| s.clone()))
}
pub fn close_execution(&mut self) {
self.mode = AppMode::Builder;
self.execution = None;
}
pub fn start_execution(&mut self, state: ExecutionState) {
self.mode = AppMode::Executing;
self.execution = Some(state);
}
pub fn write_to_pty(&self, data: &[u8]) {
if let Some(ref exec) = self.execution {
if let Ok(mut writer_guard) = exec.pty_writer.lock() {
if let Some(ref mut writer) = *writer_guard {
let _ = writer.write_all(data);
let _ = writer.flush();
}
}
}
}
pub fn resize_pty(&self, rows: u16, cols: u16) {
if let Some(ref exec) = self.execution {
if let Ok(mut master_guard) = exec.pty_master.lock() {
if let Some(ref mut master) = *master_guard {
let _ = master.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
});
}
}
if let Ok(mut parser_guard) = exec.parser.write() {
parser_guard.screen_mut().set_size(rows, cols);
}
}
}
pub fn with_theme(spec: usage::Spec, theme_name: ThemeName) -> Self {
let tree_nodes = build_command_tree(&spec);
let tree_state = TreeViewState::new();
let mut app = Self {
spec,
mode: AppMode::Builder,
execution: None,
theme_name,
command_path: Vec::new(),
flag_values: std::collections::HashMap::new(),
arg_values: Vec::new(),
focus_manager: FocusManager::new(),
editing: false,
filter_input: InputState::empty(),
filtering: false,
command_tree_nodes: tree_nodes,
command_tree_state: tree_state,
flag_list_state: ListPickerState::new(0),
arg_list_state: ListPickerState::new(0),
edit_input: InputState::empty(),
click_regions: ClickRegionRegistry::new(),
choice_select: None,
theme_picker: None,
theme_indicator_rect: None,
flag_negate_cols: std::collections::HashMap::new(),
mouse_position: None,
};
app.sync_state();
app.sync_command_path_from_tree();
app
}
fn rebuild_focus_manager(&mut self) {
let had_focus = !self.focus_manager.is_empty();
let current = self.focus();
self.focus_manager.clear();
if self.has_any_commands() {
self.focus_manager.register(Focus::Commands);
}
if !self.visible_flags().is_empty() {
self.focus_manager.register(Focus::Flags);
}
if !self.arg_values.is_empty() {
self.focus_manager.register(Focus::Args);
}
self.focus_manager.register(Focus::Preview);
if had_focus {
self.focus_manager.set(current);
}
}
pub fn palette(&self) -> ThemePalette {
self.theme_name.palette()
}
pub fn next_theme(&mut self) {
self.theme_name = self.theme_name.next();
}
pub fn prev_theme(&mut self) {
self.theme_name = self.theme_name.prev();
}
pub fn focus(&self) -> Focus {
self.focus_manager
.current()
.copied()
.unwrap_or(Focus::Preview)
}
pub fn set_focus(&mut self, panel: Focus) {
if self.focus() != panel {
self.filtering = false;
self.filter_input.clear();
}
if self.editing {
self.finish_editing();
}
self.choice_select = None;
self.focus_manager.set(panel);
}
pub fn is_theme_picking(&self) -> bool {
self.theme_picker.is_some()
}
pub fn open_theme_picker(&mut self) {
let all = ThemeName::all();
let current_idx = all.iter().position(|t| *t == self.theme_name).unwrap_or(0);
self.theme_picker = Some(ThemePickerState {
original_theme: self.theme_name,
selected_index: current_idx,
overlay_rect: None,
});
}
pub fn close_theme_picker(&mut self) {
if let Some(ref tp) = self.theme_picker {
self.theme_name = tp.original_theme;
}
self.theme_picker = None;
}
pub fn confirm_theme_picker(&mut self) {
self.theme_picker = None;
}
fn handle_theme_picker_key(&mut self, key: crossterm::event::KeyEvent) -> Action {
use crossterm::event::KeyCode;
let all = ThemeName::all();
let len = all.len();
match key.code {
KeyCode::Esc => {
self.close_theme_picker();
Action::None
}
KeyCode::Enter => {
self.confirm_theme_picker();
Action::None
}
KeyCode::Up | KeyCode::Char('k') => {
if let Some(ref mut tp) = self.theme_picker {
if tp.selected_index > 0 {
tp.selected_index -= 1;
} else {
tp.selected_index = len - 1;
}
self.theme_name = all[tp.selected_index];
}
Action::None
}
KeyCode::Down | KeyCode::Char('j') => {
if let Some(ref mut tp) = self.theme_picker {
if tp.selected_index + 1 < len {
tp.selected_index += 1;
} else {
tp.selected_index = 0;
}
self.theme_name = all[tp.selected_index];
}
Action::None
}
_ => Action::None,
}
}
pub fn is_choosing(&self) -> bool {
self.choice_select.is_some()
}
pub fn filtered_choices(&self) -> Vec<(usize, String)> {
let Some(ref cs) = self.choice_select else {
return Vec::new();
};
let filter = self.edit_input.text();
if filter.is_empty() || !cs.filter_active {
return cs
.choices
.iter()
.enumerate()
.map(|(i, c)| (i, c.clone()))
.collect();
}
let mut matcher = Matcher::new(Config::DEFAULT);
cs.choices
.iter()
.enumerate()
.filter(|(_, c)| fuzzy_match_score(c, filter, &mut matcher) > 0)
.map(|(i, c)| (i, c.clone()))
.collect()
}
pub fn choice_description(&self, original_index: usize) -> Option<&str> {
self.choice_select
.as_ref()
.and_then(|cs| cs.descriptions.get(original_index))
.and_then(|d| d.as_deref())
}
pub fn open_choice_select(&mut self, choices: Vec<String>, current_value: &str) {
let source_panel = self.focus();
let source_index = match source_panel {
Focus::Flags => self.flag_index(),
Focus::Args => self.arg_index(),
_ => 0,
};
let value_column: u16 = match source_panel {
Focus::Args => {
let arg = &self.arg_values[source_index];
let arg_display_len = arg.name.chars().count() + 2;
(1 + 2 + 2 + arg_display_len + 3) as u16
}
Focus::Flags => {
let flags = self.visible_flags();
let flag = &flags[source_index];
let flag_values = self.current_flag_values();
let value = flag_values.iter().find(|(n, _)| n == &flag.name);
let indicator_width = match value.map(|(_, v)| v) {
Some(FlagValue::Count(n)) => format!("[{}] ", n).chars().count(),
Some(FlagValue::String(_)) => 4, _ => 2, };
let flag_display = {
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(", ")
}
};
let flag_display_len = flag_display.chars().count();
let mut extra = 0usize;
if flag.global {
extra += 4; }
if flag.required {
extra += 2; }
(1 + 2 + indicator_width + flag_display_len + extra + 3) as u16
}
_ => 4,
};
let selected_index = choices
.iter()
.position(|c| c == current_value);
self.choice_select = Some(ChoiceSelectState {
choices,
descriptions: Vec::new(),
selected_index,
source_panel,
source_index,
value_column,
overlay_rect: None,
filter_active: false,
scroll_offset: 0,
visible_items: 0,
});
self.editing = true;
self.edit_input.set_text(current_value.to_string());
}
pub fn open_completion_select(
&mut self,
choices: Vec<String>,
descriptions: Vec<Option<String>>,
current_value: &str,
) {
self.open_choice_select(choices, current_value);
if let Some(ref mut cs) = self.choice_select {
cs.descriptions = descriptions;
}
}
pub fn close_choice_select(&mut self) {
self.choice_select = None;
}
pub fn confirm_choice_select(&mut self) {
let filtered = self.filtered_choices();
let Some(ref cs) = self.choice_select else {
return;
};
let Some(selected_index) = cs.selected_index else {
self.choice_select = None;
self.finish_editing();
return;
};
let source_panel = cs.source_panel;
let source_index = cs.source_index;
let Some((_, choice_text)) = filtered.get(selected_index) else {
self.choice_select = None;
self.finish_editing();
return;
};
let choice_text = choice_text.clone();
match source_panel {
Focus::Flags => {
let values = self.current_flag_values_mut();
if let Some((name, FlagValue::String(ref mut s))) = values.get_mut(source_index) {
let flag_name = name.clone();
*s = choice_text;
let new_val = FlagValue::String(s.clone());
self.sync_global_flag(&flag_name, &new_val);
}
}
Focus::Args => {
if let Some(arg) = self.arg_values.get_mut(source_index) {
arg.value = choice_text;
}
}
_ => {}
}
self.choice_select = None;
self.editing = false;
}
fn handle_choice_select_key(&mut self, key: crossterm::event::KeyEvent) -> Action {
use crossterm::event::KeyCode;
match key.code {
KeyCode::Esc => {
self.sync_edit_to_value();
self.editing = false;
self.close_choice_select();
Action::None
}
KeyCode::Enter => {
self.confirm_choice_select();
Action::None
}
KeyCode::Up => {
if let Some(ref mut cs) = self.choice_select {
match cs.selected_index {
Some(0) | None => cs.selected_index = None,
Some(idx) => cs.selected_index = Some(idx - 1),
}
}
Action::None
}
KeyCode::Down => {
let filtered_len = self.filtered_choices().len();
if let Some(ref mut cs) = self.choice_select {
if filtered_len > 0 {
match cs.selected_index {
None => cs.selected_index = Some(0),
Some(idx) if idx + 1 < filtered_len => {
cs.selected_index = Some(idx + 1);
}
_ => {}
}
}
}
Action::None
}
KeyCode::Backspace => {
self.edit_input.delete_char_backward();
self.sync_edit_to_value();
if let Some(ref mut cs) = self.choice_select {
cs.filter_active = true;
cs.selected_index = None;
}
Action::None
}
KeyCode::Left => {
self.edit_input.move_left();
Action::None
}
KeyCode::Right => {
self.edit_input.move_right();
Action::None
}
KeyCode::Char(c) => {
self.edit_input.insert_char(c);
self.sync_edit_to_value();
if let Some(ref mut cs) = self.choice_select {
cs.filter_active = true;
cs.selected_index = None;
}
Action::None
}
_ => Action::None,
}
}
pub fn filter_active(&self) -> bool {
!self.filter().is_empty()
}
pub fn command_index(&self) -> usize {
self.command_tree_state.selected_index
}
#[allow(dead_code)]
pub fn set_command_index(&mut self, idx: usize) {
self.command_tree_state.selected_index = idx;
self.sync_command_path_from_tree();
}
pub fn flag_index(&self) -> usize {
self.flag_list_state.selected_index
}
pub fn set_flag_index(&mut self, idx: usize) {
self.flag_list_state.select(idx);
}
pub fn arg_index(&self) -> usize {
self.arg_list_state.selected_index
}
pub fn set_arg_index(&mut self, idx: usize) {
self.arg_list_state.select(idx);
}
pub fn command_scroll(&self) -> usize {
self.command_tree_state.scroll as usize
}
pub fn flag_scroll(&self) -> usize {
self.flag_list_state.scroll as usize
}
pub fn arg_scroll(&self) -> usize {
self.arg_list_state.scroll as usize
}
pub fn filter(&self) -> &str {
self.filter_input.text()
}
pub fn current_command(&self) -> &SpecCommand {
let mut cmd = &self.spec.cmd;
for name in &self.command_path {
if let Some(sub) = cmd.find_subcommand(name) {
cmd = sub;
} else {
break;
}
}
cmd
}
pub fn find_completion(&self, arg_name: &str) -> Option<&usage::SpecComplete> {
let cmd = self.current_command();
cmd.complete.get(arg_name)
}
pub fn run_completion(
run_cmd: &str,
descriptions: bool,
) -> Option<(Vec<String>, Vec<Option<String>>)> {
let output = Command::new("sh")
.arg("-c")
.arg(run_cmd)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut choices = Vec::new();
let mut descs = Vec::new();
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if descriptions {
if let Some((value, desc)) = parse_completion_line(line) {
choices.push(value);
descs.push(Some(desc));
} else {
choices.push(line.to_string());
descs.push(None);
}
} else {
choices.push(line.to_string());
descs.push(None);
}
}
if choices.is_empty() {
return None;
}
Some((choices, descs))
}
pub fn run_and_open_completion(
&mut self,
arg_name: &str,
current_value: &str,
) -> Option<(Vec<String>, Vec<Option<String>>)> {
let complete = self.find_completion(arg_name)?.clone();
let run_cmd = complete.run.as_ref()?;
let result = Self::run_completion(run_cmd, complete.descriptions)?;
self.open_completion_select(result.0.clone(), result.1.clone(), current_value);
Some(result)
}
fn has_any_commands(&self) -> bool {
!self.spec.cmd.subcommands.is_empty()
}
#[cfg(test)]
pub fn visible_subcommands(&self) -> Vec<(&String, &SpecCommand)> {
let cmd = self.current_command();
let items: Vec<(&String, &SpecCommand)> =
cmd.subcommands.iter().filter(|(_, c)| !c.hide).collect();
if self.filtering && !self.filter().is_empty() && self.focus() == Focus::Commands {
let filter_lower = self.filter().to_lowercase();
items
.into_iter()
.filter(|(name, c)| {
fuzzy_match(&name.to_lowercase(), &filter_lower)
|| c.aliases
.iter()
.any(|a| fuzzy_match(&a.to_lowercase(), &filter_lower))
|| c.help
.as_ref()
.map(|h| fuzzy_match(&h.to_lowercase(), &filter_lower))
.unwrap_or(false)
})
.collect()
} else {
items
}
}
pub fn visible_flags(&self) -> Vec<&SpecFlag> {
let cmd = self.current_command();
let mut flags: Vec<&SpecFlag> = cmd.flags.iter().filter(|f| !f.hide).collect();
for flag in &self.spec.cmd.flags {
if flag.global && !flag.hide {
if !flags.iter().any(|f| f.name == flag.name) {
flags.push(flag);
}
}
}
flags
}
pub fn visible_args(&self) -> Vec<&usage::SpecArg> {
let cmd = self.current_command();
cmd.args.iter().filter(|a| !a.hide).collect()
}
pub fn sync_state(&mut self) {
let cmd = self.current_command();
self.arg_values = cmd
.args
.iter()
.filter(|a| !a.hide)
.map(|a| {
let choices = a
.choices
.as_ref()
.map(|c| c.choices.clone())
.unwrap_or_default();
let default = a.default.first().cloned().unwrap_or_default();
ArgValue {
name: a.name.clone(),
value: default,
required: a.required,
choices,
help: a.help.clone(),
}
})
.collect();
let path_key = self.command_path_key();
if !self.flag_values.contains_key(&path_key) {
let root_global_values: std::collections::HashMap<String, FlagValue> = self
.flag_values
.get("")
.map(|root_flags| {
root_flags
.iter()
.filter(|(name, _)| {
self.spec
.cmd
.flags
.iter()
.any(|f| f.global && f.name == *name)
})
.map(|(name, val)| (name.clone(), val.clone()))
.collect()
})
.unwrap_or_default();
let flags = self.visible_flags_snapshot();
let values: Vec<(String, FlagValue)> = flags
.iter()
.map(|f| {
if let Some(global_val) = root_global_values.get(&f.name) {
return (f.name.clone(), global_val.clone());
}
let val = if f.count {
FlagValue::Count(0)
} else if f.arg.is_some() {
let default = f.default.first().cloned().unwrap_or_default();
FlagValue::String(default)
} else if f.negate.is_some() {
FlagValue::NegBool(None)
} else {
FlagValue::Bool(false)
};
(f.name.clone(), val)
})
.collect();
self.flag_values.insert(path_key, values);
}
self.flag_list_state
.set_total(self.current_flag_values().len());
self.arg_list_state.set_total(self.arg_values.len());
self.rebuild_focus_manager();
}
fn visible_flags_snapshot(&self) -> Vec<SpecFlag> {
self.visible_flags().into_iter().cloned().collect()
}
fn command_path_key(&self) -> String {
self.command_path.join(" ")
}
pub fn current_flag_values(&self) -> &[(String, FlagValue)] {
let key = self.command_path_key();
self.flag_values
.get(&key)
.map(|v| v.as_slice())
.unwrap_or(&[])
}
pub fn current_flag_values_mut(&mut self) -> &mut Vec<(String, FlagValue)> {
let key = self.command_path_key();
self.flag_values.entry(key).or_default()
}
fn sync_global_flag(&mut self, flag_name: &str, new_value: &FlagValue) {
let is_global = self
.spec
.cmd
.flags
.iter()
.any(|f| f.global && f.name == flag_name);
if !is_global {
return;
}
let keys: Vec<String> = self.flag_values.keys().cloned().collect();
for key in keys {
if let Some(flags) = self.flag_values.get_mut(&key) {
if let Some((_, val)) = flags.iter_mut().find(|(n, _)| n == flag_name) {
*val = new_value.clone();
}
}
}
}
pub fn total_visible_commands(&self) -> usize {
flatten_command_tree(&self.command_tree_nodes).len()
}
pub fn compute_tree_match_scores(&self) -> std::collections::HashMap<String, MatchScores> {
let pattern = self.filter();
if pattern.is_empty() {
return std::collections::HashMap::new();
}
compute_tree_scores(&self.command_tree_nodes, pattern)
}
pub fn compute_flag_match_scores(&self) -> std::collections::HashMap<String, MatchScores> {
let pattern = self.filter();
if pattern.is_empty() {
return std::collections::HashMap::new();
}
let flags = self.visible_flags();
let mut scores = std::collections::HashMap::new();
for flag in flags {
let mut temp_matcher = Matcher::new(Config::DEFAULT);
let name_score = fuzzy_match_score(&flag.name, pattern, &mut temp_matcher);
let long_score = flag
.long
.iter()
.map(|l| fuzzy_match_score(l, pattern, &mut temp_matcher))
.max()
.unwrap_or(0);
let short_score = flag
.short
.iter()
.map(|s| {
let s_str = s.to_string();
fuzzy_match_score(&s_str, pattern, &mut temp_matcher)
})
.max()
.unwrap_or(0);
let help_score = flag
.help
.as_ref()
.map(|h| fuzzy_match_score(h, pattern, &mut temp_matcher))
.unwrap_or(0);
let combined_name_score = name_score.max(long_score).max(short_score);
scores.insert(
flag.name.clone(),
MatchScores {
name_score: combined_name_score,
help_score,
},
);
}
scores
}
pub fn compute_arg_match_scores(&self) -> std::collections::HashMap<String, MatchScores> {
let pattern = self.filter();
if pattern.is_empty() {
return std::collections::HashMap::new();
}
let args = self.visible_args();
let mut scores = std::collections::HashMap::new();
for arg in args {
let mut temp_matcher = Matcher::new(Config::DEFAULT);
let name_score = fuzzy_match_score(&arg.name, pattern, &mut temp_matcher);
let help_score = arg
.help
.as_ref()
.map(|h| fuzzy_match_score(h, pattern, &mut temp_matcher))
.unwrap_or(0);
scores.insert(
arg.name.clone(),
MatchScores {
name_score,
help_score,
},
);
}
scores
}
pub fn selected_command_id(&self) -> Option<String> {
let flat = flatten_command_tree(&self.command_tree_nodes);
flat.get(self.command_tree_state.selected_index)
.map(|cmd| cmd.id.clone())
}
pub fn sync_command_path_from_tree(&mut self) {
if let Some(id) = self.selected_command_id() {
if id.is_empty() {
self.command_path = vec![];
} else {
self.command_path = id.split(' ').map(|s| s.to_string()).collect();
}
}
self.sync_state();
}
fn node_has_children(&self, id: &str) -> bool {
fn find_in<T>(nodes: &[TreeNode<T>], id: &str) -> Option<bool> {
for node in nodes {
if node.id == id {
return Some(node.has_children());
}
if let Some(result) = find_in(&node.children, id) {
return Some(result);
}
}
None
}
find_in(&self.command_tree_nodes, id).unwrap_or(false)
}
fn find_parent_index(&self) -> Option<usize> {
let flat = flatten_command_tree(&self.command_tree_nodes);
let selected_id = flat
.get(self.command_tree_state.selected_index)
.map(|cmd| cmd.id.clone())?;
let parent = parent_id(&selected_id)?;
flat.iter().position(|cmd| cmd.id == parent)
}
pub fn tree_expand_or_enter(&mut self) {
if let Some(id) = self.selected_command_id() {
if self.node_has_children(&id) {
let total = self.total_visible_commands();
self.command_tree_state.select_next(total);
self.sync_command_path_from_tree();
}
}
}
pub fn tree_collapse_or_parent(&mut self) {
if let Some(parent_idx) = self.find_parent_index() {
self.command_tree_state.selected_index = parent_idx;
self.sync_command_path_from_tree();
}
}
#[allow(dead_code)]
pub fn navigate_to_command(&mut self, path: &[&str]) {
let target_id = path.join(" ");
let flat = flatten_command_tree(&self.command_tree_nodes);
if let Some(idx) = flat.iter().position(|cmd| cmd.id == target_id) {
self.command_tree_state.selected_index = idx;
self.sync_command_path_from_tree();
}
}
#[allow(dead_code)]
pub fn navigate_into_selected(&mut self) {
if let Some(id) = self.selected_command_id() {
if self.node_has_children(&id) {
let total = self.total_visible_commands();
self.command_tree_state.select_next(total);
self.sync_command_path_from_tree();
}
}
}
pub fn handle_mouse(&mut self, event: crossterm::event::MouseEvent) -> Action {
use crossterm::event::{MouseButton, MouseEventKind};
let col = event.column;
let row = event.row;
self.mouse_position = Some((col, row));
match event.kind {
MouseEventKind::Down(MouseButton::Left) => {
if self.is_theme_picking() {
let overlay_rect = self
.theme_picker
.as_ref()
.and_then(|tp| tp.overlay_rect);
if let Some(rect) = overlay_rect {
let inner_top = rect.y + 1;
let inner_bottom = rect.y + rect.height.saturating_sub(1);
if col >= rect.x
&& col < rect.x + rect.width
&& row >= inner_top
&& row < inner_bottom
{
let clicked_index = (row - inner_top) as usize;
let all = ThemeName::all();
if clicked_index < all.len() {
if let Some(ref mut tp) = self.theme_picker {
tp.selected_index = clicked_index;
}
self.theme_name = all[clicked_index];
self.confirm_theme_picker();
}
return Action::None;
}
}
self.close_theme_picker();
return Action::None;
}
if let Some(rect) = self.theme_indicator_rect {
if col >= rect.x
&& col < rect.x + rect.width
&& row >= rect.y
&& row < rect.y + rect.height
{
self.open_theme_picker();
return Action::None;
}
}
if self.is_choosing() {
let overlay_rect = self
.choice_select
.as_ref()
.and_then(|cs| cs.overlay_rect);
if let Some(rect) = overlay_rect {
let inner_top = rect.y; let inner_bottom = rect.y + rect.height.saturating_sub(1);
if col >= rect.x && col < rect.x + rect.width
&& row >= inner_top && row < inner_bottom
{
let scroll_offset = self.choice_select.as_ref().map_or(0, |cs| cs.scroll_offset);
let clicked_index = (row - inner_top) as usize + scroll_offset;
let filtered_len = self.filtered_choices().len();
if clicked_index < filtered_len {
if let Some(ref mut cs) = self.choice_select {
cs.selected_index = Some(clicked_index);
}
self.confirm_choice_select();
}
return Action::None;
}
}
self.sync_edit_to_value();
self.editing = false;
self.close_choice_select();
return Action::None;
}
if let Some(&clicked_panel) = self.click_regions.handle_click(col, row) {
if self.editing {
self.finish_editing();
}
let was_focused = self.focus() == clicked_panel;
self.set_focus(clicked_panel);
match clicked_panel {
Focus::Commands => {
if let Some(area) = self.command_area() {
let inner_top = area.y + 1; if row >= inner_top {
let clicked_offset = (row - inner_top) as usize;
let item_index = self.command_scroll() + clicked_offset;
let total = self.total_visible_commands();
if item_index < total {
self.command_tree_state.selected_index = item_index;
self.sync_command_path_from_tree();
}
}
}
}
Focus::Flags => {
if let Some(area) = self.flag_area() {
let inner_top = area.y + 1;
let inner_left = area.x + 1;
if row >= inner_top {
let clicked_offset = (row - inner_top) as usize;
let item_index = self.flag_scroll() + clicked_offset;
let len = self.current_flag_values().len();
if item_index < len {
self.set_flag_index(item_index);
let negate_col = self.flag_negate_cols.get(&item_index).copied();
if let Some(nc) = negate_col {
if col < inner_left + 4 {
return self.handle_enter();
} else if col >= nc {
return self.handle_negbool_click(Some(false));
} else {
return self.handle_negbool_click(Some(true));
}
} else if was_focused && self.flag_index() == item_index {
return self.handle_enter();
}
}
}
}
}
Focus::Args => {
if let Some(area) = self.arg_area() {
let inner_top = area.y + 1;
if row >= inner_top {
let clicked_offset = (row - inner_top) as usize;
let item_index = self.arg_scroll() + clicked_offset;
let len = self.arg_values.len();
if item_index < len {
if was_focused && self.arg_index() == item_index {
return self.handle_enter();
} else {
self.set_arg_index(item_index);
}
}
}
}
}
Focus::Preview => {
if was_focused {
return self.handle_enter();
}
}
}
return Action::None;
}
Action::None
}
MouseEventKind::Down(MouseButton::Right) => {
Action::None
}
MouseEventKind::ScrollUp => {
if self.is_choosing() {
self.scroll_choice_select_up();
} else {
self.scroll_up_in_focused();
}
Action::None
}
MouseEventKind::ScrollDown => {
if self.is_choosing() {
self.scroll_choice_select_down();
} else {
self.scroll_down_in_focused();
}
Action::None
}
MouseEventKind::Up(MouseButton::Left) => Action::None,
_ => Action::None,
}
}
fn command_area(&self) -> Option<Rect> {
self.click_regions
.regions()
.iter()
.find(|r| r.data == Focus::Commands)
.map(|r| r.area)
}
fn flag_area(&self) -> Option<Rect> {
self.click_regions
.regions()
.iter()
.find(|r| r.data == Focus::Flags)
.map(|r| r.area)
}
fn arg_area(&self) -> Option<Rect> {
self.click_regions
.regions()
.iter()
.find(|r| r.data == Focus::Args)
.map(|r| r.area)
}
pub fn hovered_index(&self, panel: Focus) -> Option<usize> {
let (col, row) = self.mouse_position?;
let area = match panel {
Focus::Commands => self.command_area()?,
Focus::Flags => self.flag_area()?,
Focus::Args => self.arg_area()?,
Focus::Preview => return None,
};
let inner_top = area.y + 1; let inner_bottom = area.y + area.height.saturating_sub(1);
if col < area.x || col >= area.x + area.width || row < inner_top || row >= inner_bottom {
return None;
}
let scroll = match panel {
Focus::Commands => self.command_scroll(),
Focus::Flags => self.flag_scroll(),
Focus::Args => self.arg_scroll(),
Focus::Preview => 0,
};
Some((row - inner_top) as usize + scroll)
}
fn scroll_up_in_focused(&mut self) {
self.move_up();
}
fn scroll_down_in_focused(&mut self) {
self.move_down();
}
fn scroll_choice_select_up(&mut self) {
if let Some(ref mut cs) = self.choice_select {
match cs.selected_index {
Some(0) | None => cs.selected_index = None,
Some(idx) => cs.selected_index = Some(idx - 1),
}
}
}
fn scroll_choice_select_down(&mut self) {
let filtered_len = self.filtered_choices().len();
if let Some(ref mut cs) = self.choice_select {
if filtered_len > 0 {
match cs.selected_index {
None => cs.selected_index = Some(0),
Some(idx) if idx + 1 < filtered_len => {
cs.selected_index = Some(idx + 1);
}
_ => {}
}
}
}
}
pub fn ensure_visible(&mut self, panel: Focus, viewport_height: usize) {
if viewport_height == 0 {
return;
}
match panel {
Focus::Commands => {
self.command_tree_state.ensure_visible(viewport_height);
}
Focus::Flags => {
self.flag_list_state.ensure_visible(viewport_height);
}
Focus::Args => {
self.arg_list_state.ensure_visible(viewport_height);
}
Focus::Preview => {}
}
}
pub fn handle_key(&mut self, key: crossterm::event::KeyEvent) -> Action {
use crossterm::event::KeyCode;
if self.is_executing() {
return self.handle_execution_key(key);
}
if key.code == KeyCode::Char('r')
&& key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL)
{
return Action::Execute;
}
if self.is_theme_picking() {
return self.handle_theme_picker_key(key);
}
if self.is_choosing() {
return self.handle_choice_select_key(key);
}
if self.editing {
return self.handle_editing_key(key);
}
if self.filtering {
return self.handle_filter_key(key);
}
match key.code {
KeyCode::Char('q') => Action::Quit,
KeyCode::Backspace => {
match self.focus() {
Focus::Flags => self.handle_flag_backspace(),
Focus::Args => self.handle_arg_backspace(),
_ => {}
}
Action::None
}
KeyCode::Char('T') => {
self.open_theme_picker();
Action::None
}
KeyCode::Char(']') => {
self.next_theme();
Action::None
}
KeyCode::Char('[') => {
self.prev_theme();
Action::None
}
KeyCode::Char('p') => Action::None,
KeyCode::Char('/') => {
if matches!(self.focus(), Focus::Commands | Focus::Flags | Focus::Args) {
self.filtering = true;
self.filter_input.clear();
}
Action::None
}
KeyCode::Tab => {
self.filtering = false;
self.filter_input.clear();
self.focus_manager.next();
Action::None
}
KeyCode::BackTab => {
self.filtering = false;
self.filter_input.clear();
self.focus_manager.prev();
Action::None
}
KeyCode::Esc => {
if self.filter_active() {
self.filtering = false;
self.filter_input.clear();
}
Action::None
}
KeyCode::Enter => self.handle_enter(),
KeyCode::Up | KeyCode::Char('k') => {
self.move_up();
Action::None
}
KeyCode::Down | KeyCode::Char('j') => {
self.move_down();
Action::None
}
KeyCode::Char(' ') => {
self.handle_space();
Action::None
}
KeyCode::Left | KeyCode::Char('h') => {
if self.focus() == Focus::Commands {
self.tree_collapse_or_parent();
}
Action::None
}
KeyCode::Right | KeyCode::Char('l') => {
if self.focus() == Focus::Commands {
self.tree_expand_or_enter();
}
Action::None
}
_ => Action::None,
}
}
fn handle_editing_key(&mut self, key: crossterm::event::KeyEvent) -> Action {
use crossterm::event::KeyCode;
match key.code {
KeyCode::Esc => {
self.finish_editing();
Action::None
}
KeyCode::Enter => {
self.finish_editing();
Action::None
}
KeyCode::Backspace => {
self.edit_input.delete_char_backward();
self.sync_edit_to_value();
Action::None
}
KeyCode::Delete => {
self.edit_input.delete_char_forward();
self.sync_edit_to_value();
Action::None
}
KeyCode::Left => {
self.edit_input.move_left();
Action::None
}
KeyCode::Right => {
self.edit_input.move_right();
Action::None
}
KeyCode::Home => {
self.edit_input.move_home();
Action::None
}
KeyCode::End => {
self.edit_input.move_end();
Action::None
}
KeyCode::Char(c) => {
self.edit_input.insert_char(c);
self.sync_edit_to_value();
Action::None
}
_ => Action::None,
}
}
fn sync_edit_to_value(&mut self) {
let text = self.edit_input.text.clone();
match self.focus() {
Focus::Flags => {
let flag_idx = self.flag_index();
let values = self.current_flag_values_mut();
if let Some((name, FlagValue::String(ref mut s))) = values.get_mut(flag_idx) {
let flag_name = name.clone();
*s = text;
let new_val = FlagValue::String(s.clone());
self.sync_global_flag(&flag_name, &new_val);
}
}
Focus::Args => {
let arg_idx = self.arg_index();
if let Some(arg) = self.arg_values.get_mut(arg_idx) {
arg.value = text;
}
}
_ => {}
}
}
pub fn start_editing(&mut self) {
self.editing = true;
let current_text = match self.focus() {
Focus::Flags => {
let flag_idx = self.flag_index();
self.current_flag_values()
.get(flag_idx)
.and_then(|(_, v)| match v {
FlagValue::String(s) => Some(s.clone()),
_ => None,
})
.unwrap_or_default()
}
Focus::Args => {
let arg_idx = self.arg_index();
self.arg_values
.get(arg_idx)
.map(|a| a.value.clone())
.unwrap_or_default()
}
_ => String::new(),
};
self.edit_input.set_text(current_text);
}
pub fn finish_editing(&mut self) {
self.sync_edit_to_value();
self.editing = false;
}
fn handle_filter_key(&mut self, key: crossterm::event::KeyEvent) -> Action {
use crossterm::event::KeyCode;
match key.code {
KeyCode::Esc => {
self.filtering = false;
self.filter_input.clear();
Action::None
}
KeyCode::Enter => {
self.filtering = false;
Action::None
}
KeyCode::Tab => {
self.filtering = false;
self.filter_input.clear();
self.focus_manager.next();
Action::None
}
KeyCode::BackTab => {
self.filtering = false;
self.filter_input.clear();
self.focus_manager.prev();
Action::None
}
KeyCode::Backspace => {
self.filter_input.delete_char_backward();
self.auto_select_next_match();
Action::None
}
KeyCode::Char(c) => {
self.filter_input.insert_char(c);
self.auto_select_next_match();
Action::None
}
KeyCode::Up => {
self.move_up();
Action::None
}
KeyCode::Down => {
self.move_down();
Action::None
}
_ => Action::None,
}
}
fn move_up(&mut self) {
if self.filter_active() {
self.move_to_prev_match();
return;
}
match self.focus() {
Focus::Commands => {
self.command_tree_state.select_prev();
self.sync_command_path_from_tree();
}
Focus::Flags => {
self.flag_list_state.select_prev();
}
Focus::Args => {
self.arg_list_state.select_prev();
}
Focus::Preview => {}
}
}
fn move_down(&mut self) {
if self.filter_active() {
self.move_to_next_match();
return;
}
match self.focus() {
Focus::Commands => {
let total = self.total_visible_commands();
self.command_tree_state.select_next(total);
self.sync_command_path_from_tree();
}
Focus::Flags => {
self.flag_list_state.select_next();
}
Focus::Args => {
self.arg_list_state.select_next();
}
Focus::Preview => {}
}
}
fn move_to_prev_match(&mut self) {
match self.focus() {
Focus::Commands => {
let scores = self.compute_tree_match_scores();
let flat = flatten_command_tree(&self.command_tree_nodes);
let keys: Vec<String> = flat.iter().map(|c| c.id.clone()).collect();
let current = self.command_tree_state.selected_index;
if let Some(idx) = find_adjacent_match(&keys, &scores, current, false) {
self.command_tree_state.selected_index = idx;
self.sync_command_path_from_tree();
}
}
Focus::Flags => {
let scores = self.compute_flag_match_scores();
let flags = self.visible_flags();
let keys: Vec<String> = flags.iter().map(|f| f.name.clone()).collect();
let current = self.flag_list_state.selected_index;
if let Some(idx) = find_adjacent_match(&keys, &scores, current, false) {
self.flag_list_state.select(idx);
}
}
Focus::Args => {
let scores = self.compute_arg_match_scores();
let keys: Vec<String> = self.arg_values.iter().map(|a| a.name.clone()).collect();
let current = self.arg_list_state.selected_index;
if let Some(idx) = find_adjacent_match(&keys, &scores, current, false) {
self.arg_list_state.select(idx);
}
}
_ => {}
}
}
fn move_to_next_match(&mut self) {
match self.focus() {
Focus::Commands => {
let scores = self.compute_tree_match_scores();
let flat = flatten_command_tree(&self.command_tree_nodes);
let keys: Vec<String> = flat.iter().map(|c| c.id.clone()).collect();
let current = self.command_tree_state.selected_index;
if let Some(idx) = find_adjacent_match(&keys, &scores, current, true) {
self.command_tree_state.selected_index = idx;
self.sync_command_path_from_tree();
}
}
Focus::Flags => {
let scores = self.compute_flag_match_scores();
let flags = self.visible_flags();
let keys: Vec<String> = flags.iter().map(|f| f.name.clone()).collect();
let current = self.flag_list_state.selected_index;
if let Some(idx) = find_adjacent_match(&keys, &scores, current, true) {
self.flag_list_state.select(idx);
}
}
Focus::Args => {
let scores = self.compute_arg_match_scores();
let keys: Vec<String> = self.arg_values.iter().map(|a| a.name.clone()).collect();
let current = self.arg_list_state.selected_index;
if let Some(idx) = find_adjacent_match(&keys, &scores, current, true) {
self.arg_list_state.select(idx);
}
}
_ => {}
}
}
fn handle_execution_key(&mut self, key: crossterm::event::KeyEvent) -> Action {
use crossterm::event::KeyCode;
if self.execution_exited() {
match key.code {
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {
self.close_execution();
return Action::None;
}
_ => return Action::None,
}
}
let bytes: Option<Vec<u8>> = match key.code {
KeyCode::Char(c) => {
let mut buf = [0u8; 4];
let s = c.encode_utf8(&mut buf);
Some(s.as_bytes().to_vec())
}
KeyCode::Enter => Some(b"\r".to_vec()),
KeyCode::Backspace => Some(b"\x7f".to_vec()),
KeyCode::Tab => Some(b"\t".to_vec()),
KeyCode::Esc => Some(b"\x1b".to_vec()),
KeyCode::Up => Some(b"\x1b[A".to_vec()),
KeyCode::Down => Some(b"\x1b[B".to_vec()),
KeyCode::Right => Some(b"\x1b[C".to_vec()),
KeyCode::Left => Some(b"\x1b[D".to_vec()),
KeyCode::Home => Some(b"\x1b[H".to_vec()),
KeyCode::End => Some(b"\x1b[F".to_vec()),
KeyCode::Delete => Some(b"\x1b[3~".to_vec()),
_ => None,
};
if let Some(data) = bytes {
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL)
{
if let KeyCode::Char('c') = key.code {
self.write_to_pty(b"\x03");
return Action::None;
}
if let KeyCode::Char('d') = key.code {
self.write_to_pty(b"\x04");
return Action::None;
}
}
self.write_to_pty(&data);
}
Action::None
}
fn handle_enter(&mut self) -> Action {
match self.focus() {
Focus::Commands => {
self.tree_expand_or_enter();
Action::None
}
Focus::Flags => {
let flag_idx = self.flag_index();
let maybe_choices: Option<Vec<String>> = {
let flags = self.visible_flags();
flags.get(flag_idx).and_then(|flag| {
flag.arg
.as_ref()
.and_then(|a| a.choices.as_ref())
.map(|c| c.choices.clone())
})
};
let flag_arg_name: Option<String> = {
let flags = self.visible_flags();
flags
.get(flag_idx)
.and_then(|f| f.arg.as_ref())
.map(|a| a.name.clone())
};
let is_string_flag = {
let values = self.current_flag_values();
values
.get(flag_idx)
.is_some_and(|(_, v)| matches!(v, FlagValue::String(_)))
};
if is_string_flag {
let values = self.current_flag_values_mut();
if let Some((_, FlagValue::String(s))) = values.get_mut(flag_idx) {
if let Some(choices) = maybe_choices {
let current = s.clone();
self.open_choice_select(choices, ¤t);
} else if let Some(ref arg_name) = flag_arg_name {
let current = s.clone();
if self.run_and_open_completion(arg_name, ¤t).is_none() {
self.start_editing();
}
} else {
self.start_editing();
}
}
} else {
self.toggle_simple_flag();
}
Action::None
}
Focus::Args => {
let arg_idx = self.arg_index();
let arg = &self.arg_values[arg_idx];
if !arg.choices.is_empty() {
let choices = arg.choices.clone();
let current = arg.value.clone();
self.open_choice_select(choices, ¤t);
} else {
let arg_name = arg.name.clone();
let current = arg.value.clone();
if self.run_and_open_completion(&arg_name, ¤t).is_none() {
self.start_editing();
}
}
Action::None
}
Focus::Preview => Action::Execute,
}
}
fn handle_space(&mut self) {
if self.focus() == Focus::Flags {
self.toggle_simple_flag();
}
}
fn toggle_simple_flag(&mut self) {
let flag_idx = self.flag_index();
let values = self.current_flag_values_mut();
if let Some((name, value)) = values.get_mut(flag_idx) {
let flag_name = name.clone();
match value {
FlagValue::Bool(b) => {
*b = !*b;
let new_val = FlagValue::Bool(*b);
self.sync_global_flag(&flag_name, &new_val);
}
FlagValue::NegBool(state) => {
*state = match *state {
None => Some(true),
Some(true) => Some(false),
Some(false) => None,
};
let new_val = FlagValue::NegBool(*state);
self.sync_global_flag(&flag_name, &new_val);
}
FlagValue::Count(c) => {
*c += 1;
let new_val = FlagValue::Count(*c);
self.sync_global_flag(&flag_name, &new_val);
}
_ => {}
}
}
}
fn auto_select_next_match(&mut self) {
match self.focus() {
Focus::Commands => {
let scores = self.compute_tree_match_scores();
let flat = flatten_command_tree(&self.command_tree_nodes);
let keys: Vec<String> = flat.iter().map(|c| c.id.clone()).collect();
let current = self.command_tree_state.selected_index;
if let Some(idx) = find_first_match(&keys, &scores, current) {
if idx != current {
self.command_tree_state.selected_index = idx;
self.sync_command_path_from_tree();
}
}
}
Focus::Flags => {
let scores = self.compute_flag_match_scores();
let flags = self.visible_flags();
let keys: Vec<String> = flags.iter().map(|f| f.name.clone()).collect();
let current = self.flag_list_state.selected_index;
if let Some(idx) = find_first_match(&keys, &scores, current) {
if idx != current {
self.flag_list_state.select(idx);
}
}
}
Focus::Args => {
let scores = self.compute_arg_match_scores();
let keys: Vec<String> = self.arg_values.iter().map(|a| a.name.clone()).collect();
let current = self.arg_list_state.selected_index;
if let Some(idx) = find_first_match(&keys, &scores, current) {
if idx != current {
self.arg_list_state.select(idx);
}
}
}
_ => {}
}
}
fn handle_flag_backspace(&mut self) {
let flag_idx = self.flag_index();
let values = self.current_flag_values_mut();
if let Some((name, value)) = values.get_mut(flag_idx) {
let flag_name = name.clone();
match value {
FlagValue::Count(c) => {
*c = c.saturating_sub(1);
let new_val = FlagValue::Count(*c);
self.sync_global_flag(&flag_name, &new_val);
}
FlagValue::Bool(b) => {
*b = false;
let new_val = FlagValue::Bool(false);
self.sync_global_flag(&flag_name, &new_val);
}
FlagValue::NegBool(state) => {
*state = None;
let new_val = FlagValue::NegBool(None);
self.sync_global_flag(&flag_name, &new_val);
}
FlagValue::String(s) => {
s.clear();
let new_val = FlagValue::String(String::new());
self.sync_global_flag(&flag_name, &new_val);
}
}
}
}
fn handle_negbool_click(&mut self, target: Option<bool>) -> Action {
let flag_idx = self.flag_index();
let values = self.current_flag_values_mut();
if let Some((name, FlagValue::NegBool(state))) = values.get_mut(flag_idx) {
let flag_name = name.clone();
*state = if *state == target { None } else { target };
let new_val = FlagValue::NegBool(*state);
self.sync_global_flag(&flag_name, &new_val);
}
Action::None
}
fn handle_arg_backspace(&mut self) {
let arg_idx = self.arg_index();
if let Some(arg) = self.arg_values.get_mut(arg_idx) {
arg.value.clear();
}
}
pub fn build_command(&self) -> String {
let mut parts: Vec<String> = Vec::new();
let bin = if self.spec.bin.is_empty() {
&self.spec.name
} else {
&self.spec.bin
};
parts.push(bin.clone());
let root_key = String::new();
if let Some(root_flags) = self.flag_values.get(&root_key) {
for (name, value) in root_flags {
if let Some(flag_str) = self.format_flag_value(name, value, &self.spec.cmd.flags) {
parts.push(flag_str);
}
}
}
let mut cmd = &self.spec.cmd;
for (i, name) in self.command_path.iter().enumerate() {
parts.push(name.clone());
if let Some(sub) = cmd.find_subcommand(name) {
cmd = sub;
let path_key = self.command_path[..=i].join(" ");
if let Some(level_flags) = self.flag_values.get(&path_key) {
for (fname, fvalue) in level_flags {
let is_global = self
.spec
.cmd
.flags
.iter()
.any(|f| f.global && f.name == *fname);
if is_global {
continue;
}
if let Some(flag_str) = self.format_flag_value(fname, fvalue, &cmd.flags) {
parts.push(flag_str);
}
}
}
}
}
for arg in &self.arg_values {
if !arg.value.is_empty() {
if arg.value.contains(' ') {
parts.push(format!("\"{}\"", arg.value));
} else {
parts.push(arg.value.clone());
}
}
}
parts.join(" ")
}
pub fn build_command_parts(&self) -> Vec<String> {
let mut parts: Vec<String> = Vec::new();
let bin = if self.spec.bin.is_empty() {
&self.spec.name
} else {
&self.spec.bin
};
for word in bin.split_whitespace() {
parts.push(word.to_string());
}
let root_key = String::new();
if let Some(root_flags) = self.flag_values.get(&root_key) {
for (name, value) in root_flags {
self.format_flag_parts(name, value, &self.spec.cmd.flags, &mut parts);
}
}
let mut cmd = &self.spec.cmd;
for (i, name) in self.command_path.iter().enumerate() {
parts.push(name.clone());
if let Some(sub) = cmd.find_subcommand(name) {
cmd = sub;
let path_key = self.command_path[..=i].join(" ");
if let Some(level_flags) = self.flag_values.get(&path_key) {
for (fname, fvalue) in level_flags {
let is_global = self
.spec
.cmd
.flags
.iter()
.any(|f| f.global && f.name == *fname);
if is_global {
continue;
}
self.format_flag_parts(fname, fvalue, &cmd.flags, &mut parts);
}
}
}
}
for arg in &self.arg_values {
if !arg.value.is_empty() {
parts.push(arg.value.clone());
}
}
parts
}
fn format_flag_parts(
&self,
name: &str,
value: &FlagValue,
flags: &[SpecFlag],
parts: &mut Vec<String>,
) {
let flag = flags.iter().find(|f| f.name == name);
let flag = flag.or_else(|| {
self.spec
.cmd
.flags
.iter()
.find(|f| f.name == name && f.global)
});
let Some(flag) = flag else { return };
match value {
FlagValue::Bool(true) => {
if let Some(long) = flag.long.first() {
parts.push(format!("--{long}"));
} else if let Some(short) = flag.short.first() {
parts.push(format!("-{short}"));
}
}
FlagValue::Bool(false) => {}
FlagValue::NegBool(None) => {}
FlagValue::NegBool(Some(true)) => {
if let Some(long) = flag.long.first() {
parts.push(format!("--{long}"));
} else if let Some(short) = flag.short.first() {
parts.push(format!("-{short}"));
}
}
FlagValue::NegBool(Some(false)) => {
if let Some(negate) = &flag.negate {
parts.push(negate.clone());
}
}
FlagValue::Count(0) => {}
FlagValue::Count(n) => {
if let Some(short) = flag.short.first() {
parts.push(format!("-{}", short.to_string().repeat(*n as usize)));
} else if let Some(long) = flag.long.first() {
for _ in 0..*n {
parts.push(format!("--{long}"));
}
}
}
FlagValue::String(s) if s.is_empty() => {}
FlagValue::String(s) => {
if let Some(long) = flag.long.first() {
parts.push(format!("--{long}"));
} else if let Some(short) = flag.short.first() {
parts.push(format!("-{short}"));
} else {
return;
}
parts.push(s.clone());
}
}
}
fn format_flag_value(
&self,
name: &str,
value: &FlagValue,
flags: &[SpecFlag],
) -> Option<String> {
let flag = flags.iter().find(|f| f.name == name);
let flag = flag.or_else(|| {
self.spec
.cmd
.flags
.iter()
.find(|f| f.name == name && f.global)
});
let flag = flag?;
match value {
FlagValue::Bool(true) => {
let prefix = if let Some(long) = flag.long.first() {
format!("--{long}")
} else if let Some(short) = flag.short.first() {
format!("-{short}")
} else {
return None;
};
Some(prefix)
}
FlagValue::Bool(false) => None,
FlagValue::NegBool(None) => None,
FlagValue::NegBool(Some(true)) => {
let prefix = if let Some(long) = flag.long.first() {
format!("--{long}")
} else if let Some(short) = flag.short.first() {
format!("-{short}")
} else {
return None;
};
Some(prefix)
}
FlagValue::NegBool(Some(false)) => flag.negate.clone(),
FlagValue::Count(0) => None,
FlagValue::Count(n) => {
if let Some(short) = flag.short.first() {
Some(format!("-{}", short.to_string().repeat(*n as usize)))
} else if let Some(long) = flag.long.first() {
Some(
std::iter::repeat_n(format!("--{long}"), *n as usize)
.collect::<Vec<_>>()
.join(" "),
)
} else {
None
}
}
FlagValue::String(s) if s.is_empty() => None,
FlagValue::String(s) => {
let prefix = if let Some(long) = flag.long.first() {
format!("--{long}")
} else if let Some(short) = flag.short.first() {
format!("-{short}")
} else {
return None;
};
if s.contains(' ') {
Some(format!("{prefix} \"{s}\""))
} else {
Some(format!("{prefix} {s}"))
}
}
}
}
}
pub fn build_command_tree(spec: &Spec) -> Vec<TreeNode<CmdData>> {
build_cmd_nodes(&spec.cmd, &[])
}
fn build_cmd_nodes(cmd: &SpecCommand, parent_path: &[String]) -> Vec<TreeNode<CmdData>> {
cmd.subcommands
.iter()
.filter(|(_, c)| !c.hide)
.map(|(name, c)| {
let mut path = parent_path.to_vec();
path.push(name.clone());
let id = path.join(" ");
TreeNode::new(
&id,
CmdData {
name: name.clone(),
help: c.help.clone(),
aliases: c.aliases.clone(),
},
)
.with_children(build_cmd_nodes(c, &path))
})
.collect()
}
fn compute_tree_scores(
nodes: &[TreeNode<CmdData>],
pattern: &str,
) -> std::collections::HashMap<String, MatchScores> {
let flat = flatten_command_tree(nodes);
let mut scores = std::collections::HashMap::new();
let mut matcher = Matcher::new(Config::DEFAULT);
for cmd in &flat {
let name_score = fuzzy_match_score(&cmd.name, pattern, &mut matcher);
let alias_score = cmd
.aliases
.iter()
.map(|a| fuzzy_match_score(a, pattern, &mut matcher))
.max()
.unwrap_or(0);
let help_score = cmd
.help
.as_ref()
.map(|h| fuzzy_match_score(h, pattern, &mut matcher))
.unwrap_or(0);
let path_score = fuzzy_match_score(&cmd.full_path, pattern, &mut matcher);
let combined_name_score = name_score.max(alias_score).max(path_score);
scores.insert(
cmd.id.clone(),
MatchScores {
name_score: combined_name_score,
help_score,
},
);
}
scores
}
fn parent_id(id: &str) -> Option<String> {
if id.is_empty() {
None } else if let Some(pos) = id.rfind(' ') {
Some(id[..pos].to_string())
} else {
Some(String::new()) }
}
fn find_adjacent_match(
keys: &[String],
scores: &std::collections::HashMap<String, MatchScores>,
current: usize,
forward: bool,
) -> Option<usize> {
let total = keys.len();
if total == 0 {
return None;
}
for offset in 1..total {
let idx = if forward {
(current + offset) % total
} else {
(current + total - offset) % total
};
if let Some(key) = keys.get(idx) {
if scores.get(key).map(|s| s.overall()).unwrap_or(0) > 0 {
return Some(idx);
}
}
}
None
}
fn find_first_match(
keys: &[String],
scores: &std::collections::HashMap<String, MatchScores>,
current: usize,
) -> Option<usize> {
if let Some(key) = keys.get(current) {
if scores.get(key).map(|s| s.overall()).unwrap_or(0) > 0 {
return Some(current);
}
}
for (idx, key) in keys.iter().enumerate() {
if scores.get(key).map(|s| s.overall()).unwrap_or(0) > 0 {
return Some(idx);
}
}
None
}
pub fn flatten_command_tree(nodes: &[TreeNode<CmdData>]) -> Vec<FlatCommand> {
fn flatten_recursive(
nodes: &[TreeNode<CmdData>],
depth: usize,
parent_names: &[String],
result: &mut Vec<FlatCommand>,
) {
for node in nodes {
let mut path_parts = parent_names.to_vec();
path_parts.push(node.data.name.clone());
let full_path = path_parts.join(" ");
result.push(FlatCommand {
id: node.id.clone(),
name: node.data.name.clone(),
help: node.data.help.clone(),
aliases: node.data.aliases.clone(),
depth,
full_path,
});
if !node.children.is_empty() {
flatten_recursive(&node.children, depth + 1, &path_parts, result);
}
}
}
let mut result = Vec::new();
flatten_recursive(nodes, 0, &[], &mut result);
result
}
pub fn fuzzy_match_score(text: &str, pattern: &str, matcher: &mut Matcher) -> u32 {
use nucleo_matcher::Utf32Str;
let pattern = Pattern::parse(pattern, CaseMatching::Smart, Normalization::Smart);
let mut haystack_buf = Vec::new();
let haystack = Utf32Str::new(text, &mut haystack_buf);
pattern.score(haystack, matcher).unwrap_or(0)
}
pub fn fuzzy_match_indices(
text: &str,
pattern_str: &str,
matcher: &mut Matcher,
) -> (u32, Vec<u32>) {
use nucleo_matcher::Utf32Str;
let pattern = Pattern::parse(pattern_str, CaseMatching::Smart, Normalization::Smart);
let mut haystack_buf = Vec::new();
let haystack = Utf32Str::new(text, &mut haystack_buf);
let mut indices = Vec::new();
if let Some(score) = pattern.indices(haystack, matcher, &mut indices) {
indices.sort_unstable();
indices.dedup();
(score, indices)
} else {
(0, Vec::new())
}
}
#[cfg(test)]
pub fn fuzzy_match(text: &str, pattern: &str) -> bool {
let mut text_chars = text.chars();
for pc in pattern.chars() {
loop {
match text_chars.next() {
Some(tc) if tc == pc => break,
Some(_) => continue,
None => return false,
}
}
}
true
}
fn parse_completion_line(line: &str) -> Option<(String, String)> {
let mut value = String::new();
let mut chars = line.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\\' && chars.peek() == Some(&':') {
value.push(':');
chars.next();
} else if ch == ':' {
let desc: String = chars.collect();
return Some((value, desc.to_string()));
} else {
value.push(ch);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_spec() -> Spec {
let input = include_str!("../fixtures/sample.usage.kdl");
input.parse::<Spec>().expect("Failed to parse sample spec")
}
#[test]
fn test_app_creation() {
let app = App::new(sample_spec());
assert_eq!(app.spec.bin, "mycli");
assert_eq!(app.spec.name, "My CLI");
assert_eq!(app.command_path, vec!["init"]);
assert_eq!(app.focus(), Focus::Commands);
}
#[test]
fn test_tree_built_from_spec() {
let app = App::new(sample_spec());
assert!(app.command_tree_nodes.len() > 1);
let names: Vec<&str> = app
.command_tree_nodes
.iter()
.map(|n| n.data.name.as_str())
.collect();
assert!(names.contains(&"init"));
assert!(names.contains(&"config"));
assert!(names.contains(&"run"));
}
#[test]
fn test_flat_list_all_visible() {
let app = App::new(sample_spec());
let flat = flatten_command_tree(&app.command_tree_nodes);
assert_eq!(flat.len(), 15);
assert!(flat.iter().any(|c| c.id == "config set"));
assert!(flat.iter().any(|c| c.id == "plugin install"));
}
#[test]
fn test_visible_subcommands_at_root() {
let mut app = App::new(sample_spec());
app.command_path.clear();
app.sync_state();
let subs = app.visible_subcommands();
let names: Vec<&str> = subs.iter().map(|(n, _)| n.as_str()).collect();
assert!(names.contains(&"init"));
assert!(names.contains(&"config"));
assert!(names.contains(&"run"));
assert!(names.contains(&"deploy"));
assert!(names.contains(&"plugin"));
assert!(names.contains(&"version"));
assert!(names.contains(&"help"));
}
#[test]
fn test_navigate_to_command() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["config"]);
assert_eq!(app.command_path, vec!["config"]);
let subs = app.visible_subcommands();
let names: Vec<&str> = subs.iter().map(|(n, _)| n.as_str()).collect();
assert!(names.contains(&"set"));
assert!(names.contains(&"get"));
assert!(names.contains(&"list"));
assert!(names.contains(&"remove"));
}
#[test]
fn test_navigate_to_deep_command() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["config", "set"]);
assert_eq!(app.command_path, vec!["config", "set"]);
}
#[test]
fn test_navigate_into_subcommand() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["config"]);
assert_eq!(app.command_path, vec!["config"]);
app.navigate_into_selected();
assert!(!app.command_path.is_empty());
assert!(
app.command_path.len() == 2 && app.command_path[0] == "config",
"Should be in config's subtree: {:?}",
app.command_path
);
}
#[test]
fn test_build_command_basic() {
let app = App::new(sample_spec());
let cmd = app.build_command();
assert_eq!(cmd, "mycli init");
}
#[test]
fn test_build_command_with_subcommand() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["init"]);
let cmd = app.build_command();
assert!(cmd.starts_with("mycli init"));
}
#[test]
fn test_build_command_with_flags_and_args() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["init"]);
if let Some(arg) = app.arg_values.get_mut(0) {
arg.value = "myproject".to_string();
}
let values = app.current_flag_values_mut();
for (name, value) in values.iter_mut() {
if name == "force" {
*value = FlagValue::Bool(true);
}
}
let cmd = app.build_command();
assert!(cmd.contains("mycli"));
assert!(cmd.contains("init"));
assert!(cmd.contains("--force"));
assert!(cmd.contains("myproject"));
}
#[test]
fn test_build_command_with_count_flag() {
let mut app = App::new(sample_spec());
let root_key = String::new();
if let Some(flags) = app.flag_values.get_mut(&root_key) {
for (name, value) in flags.iter_mut() {
if name == "verbose" {
*value = FlagValue::Count(3);
}
}
}
app.sync_global_flag("verbose", &FlagValue::Count(3));
let cmd = app.build_command();
assert!(cmd.contains("-vvv"));
}
#[test]
fn test_negated_flag_init_as_negbool_none() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["run"]);
let values = app.current_flag_values();
let color = values.iter().find(|(n, _)| n == "color");
assert_eq!(
color,
Some(&("color".to_string(), FlagValue::NegBool(None))),
"--color flag with negate should initialize to NegBool(None)"
);
}
#[test]
fn test_negated_flag_omitted_emits_nothing() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["run"]);
let cmd = app.build_command();
assert!(
!cmd.contains("--color"),
"omitted negatable flag should not emit --color: {cmd}"
);
assert!(
!cmd.contains("--no-color"),
"omitted negatable flag should not emit --no-color: {cmd}"
);
}
#[test]
fn test_negated_flag_explicit_on_emits_flag() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["run"]);
let path_key = "run".to_string();
if let Some(flags) = app.flag_values.get_mut(&path_key) {
for (name, value) in flags.iter_mut() {
if name == "color" {
*value = FlagValue::NegBool(Some(true));
}
}
}
let cmd = app.build_command();
assert!(
cmd.contains("--color"),
"explicit on should emit --color: {cmd}"
);
}
#[test]
fn test_negated_flag_explicit_off_emits_negate() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["run"]);
let path_key = "run".to_string();
if let Some(flags) = app.flag_values.get_mut(&path_key) {
for (name, value) in flags.iter_mut() {
if name == "color" {
*value = FlagValue::NegBool(Some(false));
}
}
}
let cmd = app.build_command();
assert!(
cmd.contains("--no-color"),
"explicit off should emit --no-color: {cmd}"
);
let without_negate = cmd.replace("--no-color", "");
assert!(
!without_negate.contains("--color"),
"should not include positive flag when negated: {cmd}"
);
}
#[test]
fn test_negated_flag_toggle_cycles() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["run"]);
app.set_focus(Focus::Flags);
let color_idx = app
.current_flag_values()
.iter()
.position(|(n, _)| n == "color")
.expect("color flag should exist");
app.flag_list_state.select(color_idx);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
assert_eq!(
app.current_flag_values()[color_idx].1,
FlagValue::NegBool(None)
);
app.handle_key(enter);
assert_eq!(
app.current_flag_values()[color_idx].1,
FlagValue::NegBool(Some(true))
);
app.handle_key(enter);
assert_eq!(
app.current_flag_values()[color_idx].1,
FlagValue::NegBool(Some(false))
);
app.handle_key(enter);
assert_eq!(
app.current_flag_values()[color_idx].1,
FlagValue::NegBool(None)
);
}
#[test]
fn test_negated_flag_backspace_resets() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["run"]);
app.set_focus(Focus::Flags);
let color_idx = app
.current_flag_values()
.iter()
.position(|(n, _)| n == "color")
.expect("color flag should exist");
app.flag_list_state.select(color_idx);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
assert_eq!(
app.current_flag_values()[color_idx].1,
FlagValue::NegBool(Some(true))
);
let backspace = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Backspace,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(backspace);
assert_eq!(
app.current_flag_values()[color_idx].1,
FlagValue::NegBool(None)
);
}
#[test]
fn test_fuzzy_match() {
assert!(fuzzy_match("config", "cfg"));
assert!(fuzzy_match("config", "con"));
assert!(fuzzy_match("config", "config"));
assert!(!fuzzy_match("config", "xyz"));
assert!(fuzzy_match("deploy", "dpl"));
assert!(!fuzzy_match("deploy", "dpx"));
assert!(fuzzy_match("hello world", "hwd"));
}
#[test]
fn test_full_path_matching() {
let app = App::new(sample_spec());
let scores = compute_tree_scores(&app.command_tree_nodes, "cfgset");
let set_score = scores.get("config set").map(|s| s.overall()).unwrap_or(0);
assert!(
set_score > 0,
"cfgset should match config set, got score {set_score}"
);
let init_score = scores.get("init").map(|s| s.overall()).unwrap_or(0);
assert_eq!(init_score, 0, "cfgset should not match init");
let run_score = scores.get("run").map(|s| s.overall()).unwrap_or(0);
assert_eq!(run_score, 0, "cfgset should not match run");
let scores2 = compute_tree_scores(&app.command_tree_nodes, "plinstall");
let install_score = scores2
.get("plugin install")
.map(|s| s.overall())
.unwrap_or(0);
assert!(
install_score > 0,
"plinstall should match plugin install, got score {install_score}"
);
}
#[test]
fn test_fuzzy_match_with_pattern_indices() {
use nucleo_matcher::{Config, Matcher};
let mut matcher = Matcher::new(Config::DEFAULT);
let (score, indices) = fuzzy_match_indices("config", "cfg", &mut matcher);
assert!(score > 0, "Should match 'cfg' in 'config'");
assert_eq!(indices, vec![0, 3, 5], "Should match c, f, g");
let (score, indices) = fuzzy_match_indices("foo bar baz", "foo baz", &mut matcher);
assert!(score > 0, "Should match multi-word pattern");
assert!(indices.contains(&0)); assert!(indices.len() >= 6);
let (score, indices) = fuzzy_match_indices("config", "xyz", &mut matcher);
assert_eq!(score, 0, "Should not match 'xyz'");
assert!(indices.is_empty(), "No indices for non-match");
let (score, indices) = fuzzy_match_indices("MyConfig", "myconf", &mut matcher);
assert!(score > 0, "Should match case-insensitively");
assert_eq!(indices.len(), 6, "Should match all 6 characters");
}
#[test]
fn test_command_path_navigation() {
let mut app = App::new(sample_spec());
assert_eq!(app.command_path, vec!["init"]);
app.navigate_to_command(&["config"]);
assert_eq!(app.command_path, vec!["config"]);
app.navigate_to_command(&["config", "set"]);
assert_eq!(app.command_path, vec!["config", "set"]);
}
#[test]
fn test_visible_flags_includes_global() {
let app = App::new(sample_spec());
let flags = app.visible_flags();
let names: Vec<&str> = flags.iter().map(|f| f.name.as_str()).collect();
assert!(names.contains(&"verbose"));
assert!(names.contains(&"quiet"));
}
#[test]
fn test_arg_values_initialized() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["init"]);
assert!(!app.arg_values.is_empty());
assert_eq!(app.arg_values[0].name, "name");
assert!(app.arg_values[0].required);
}
#[test]
fn test_deploy_has_choices() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
assert!(!app.arg_values.is_empty());
assert_eq!(app.arg_values[0].name, "environment");
assert!(app.arg_values[0].choices.contains(&"dev".to_string()));
assert!(app.arg_values[0].choices.contains(&"staging".to_string()));
assert!(app.arg_values[0].choices.contains(&"prod".to_string()));
}
#[test]
fn test_flag_with_default_value() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["run"]);
let flag_values = app.current_flag_values();
let jobs = flag_values.iter().find(|(n, _)| n == "jobs");
assert!(jobs.is_some());
if let Some((_, FlagValue::String(s))) = jobs {
assert_eq!(s, "4");
} else {
panic!("Expected string flag value for jobs");
}
}
#[test]
fn test_key_handling_quit() {
let mut app = App::new(sample_spec());
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('q'),
crossterm::event::KeyModifiers::NONE,
);
assert_eq!(app.handle_key(key), Action::Quit);
}
#[test]
fn test_key_handling_navigation() {
let mut app = App::new(sample_spec());
let down = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(down);
assert!(app.command_index() > 0);
let up = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Up,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(up);
assert_eq!(app.command_index(), 0);
}
#[test]
fn test_tab_cycles_focus() {
let mut app = App::new(sample_spec());
assert_eq!(app.focus(), Focus::Commands);
let tab = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Tab,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(tab);
assert_eq!(app.focus(), Focus::Flags);
let tab = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Tab,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(tab);
assert_ne!(app.focus(), Focus::Commands);
}
#[test]
fn test_filter_mode() {
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
let slash = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('/'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(slash);
assert!(app.filtering);
for c in "cfg".chars() {
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(c),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(key);
}
assert_eq!(app.filter(), "cfg");
assert!(app.filtering);
assert_eq!(app.filter(), "cfg");
}
#[test]
fn test_filter_flags() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Flags);
let slash = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('/'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(slash);
assert!(app.filtering);
for c in "roll".chars() {
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(c),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(key);
}
assert_eq!(app.filter(), "roll");
let flags = app.visible_flags();
let scores = app.compute_flag_match_scores();
let rollback_score = scores.get("rollback").map(|s| s.overall()).unwrap_or(0);
assert!(rollback_score > 0, "rollback should match 'roll'");
let tag_score = scores.get("tag").map(|s| s.overall()).unwrap_or(0);
let yes_score = scores.get("yes").map(|s| s.overall()).unwrap_or(0);
assert_eq!(tag_score, 0, "tag should not match 'roll'");
assert_eq!(yes_score, 0, "yes should not match 'roll'");
let names: Vec<&str> = flags.iter().map(|f| f.name.as_str()).collect();
assert!(names.contains(&"rollback"));
assert!(names.contains(&"tag"));
assert!(names.contains(&"yes"));
}
#[test]
fn test_filter_tab_switches_focus_and_clears() {
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
let slash = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('/'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(slash);
assert!(app.filtering);
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('x'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(key);
assert_eq!(app.filter(), "x");
let tab = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Tab,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(tab);
assert!(!app.filtering);
assert!(app.filter().is_empty());
assert_eq!(app.focus(), Focus::Flags);
}
#[test]
fn test_slash_does_not_activate_filter_in_preview_pane() {
let mut app = App::new(sample_spec());
app.set_focus(Focus::Preview);
assert_eq!(app.focus(), Focus::Preview);
assert!(!app.filtering);
let slash = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('/'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(slash);
assert!(
!app.filtering,
"Filter mode should not activate in Preview pane"
);
assert!(app.filter().is_empty());
}
#[test]
fn test_filtered_navigation_works_after_enter_applies_filter() {
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
let slash = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('/'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(slash);
assert!(app.filtering);
let p_key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('p'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(p_key);
assert_eq!(app.filter(), "p");
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
assert!(!app.filtering, "Should exit typing mode after Enter");
assert_eq!(app.filter(), "p", "Query should remain after Enter");
assert!(app.filter_active(), "Filter should still be active");
let j_key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('j'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(j_key);
assert_eq!(app.filter(), "p", "j should navigate, not append to filter");
assert!(app.filter_active(), "Filter should remain active after j");
}
#[test]
fn test_esc_clears_applied_filter() {
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
let slash = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('/'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(slash);
let p_key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('p'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(p_key);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
assert!(!app.filtering);
assert!(app.filter_active(), "Filter should be active after Enter");
let esc = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(esc);
assert!(!app.filtering);
assert!(!app.filter_active(), "Esc should clear the applied filter");
assert!(app.filter().is_empty());
}
#[test]
fn test_args_filter_matches_name() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Args);
let slash = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('/'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(slash);
assert!(app.filtering);
let e_key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('e'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(e_key);
let n_key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('n'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(n_key);
let v_key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('v'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(v_key);
assert_eq!(app.filter(), "env");
let scores = app.compute_arg_match_scores();
let env_score = scores.get("environment").map(|s| s.overall()).unwrap_or(0);
assert!(env_score > 0, "environment should match 'env'");
}
#[test]
fn test_args_filtered_navigation() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["init"]);
app.set_focus(Focus::Args);
let slash = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('/'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(slash);
let n_key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('n'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(n_key);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
assert!(!app.filtering, "Should exit typing mode");
assert!(app.filter_active(), "Filter should still be active");
let _initial_index = app.arg_list_state.selected_index;
let down = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(down);
let _new_index = app.arg_list_state.selected_index;
assert!(app.filter_active());
}
#[test]
fn test_tab_clears_applied_filter() {
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
let slash = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('/'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(slash);
let p_key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('p'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(p_key);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
assert!(app.filter_active(), "Filter should be active after Enter");
let tab = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Tab,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(tab);
assert!(!app.filter_active(), "Tab should clear the applied filter");
assert!(app.filter().is_empty());
assert_eq!(app.focus(), Focus::Flags);
}
#[test]
fn test_scroll_offset_ensure_visible() {
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
app.command_tree_state.selected_index = 5;
app.command_tree_state.scroll = 0;
app.ensure_visible(Focus::Commands, 3);
assert_eq!(app.command_scroll(), 3);
app.command_tree_state.selected_index = 2;
app.ensure_visible(Focus::Commands, 3);
assert_eq!(app.command_scroll(), 2);
app.command_tree_state.selected_index = 3;
app.ensure_visible(Focus::Commands, 3);
assert_eq!(app.command_scroll(), 2);
}
#[test]
fn test_scroll_offset_on_move_up() {
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
app.command_tree_state.scroll = 2;
app.command_tree_state.selected_index = 2;
app.move_up();
assert_eq!(app.command_index(), 1);
}
#[test]
fn test_mouse_click_focuses_panel() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
app.click_regions.clear();
app.click_regions
.register(ratatui::layout::Rect::new(0, 1, 40, 18), Focus::Commands);
app.click_regions
.register(ratatui::layout::Rect::new(40, 1, 60, 18), Focus::Flags);
app.click_regions
.register(ratatui::layout::Rect::new(0, 21, 100, 3), Focus::Preview);
let mouse = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 50,
row: 3,
modifiers: crossterm::event::KeyModifiers::NONE,
};
app.handle_mouse(mouse);
assert_eq!(app.focus(), Focus::Flags);
let mouse = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 10,
row: 22,
modifiers: crossterm::event::KeyModifiers::NONE,
};
app.handle_mouse(mouse);
assert_eq!(app.focus(), Focus::Preview);
let mouse = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 10,
row: 3,
modifiers: crossterm::event::KeyModifiers::NONE,
};
app.handle_mouse(mouse);
assert_eq!(app.focus(), Focus::Commands);
}
#[test]
fn test_mouse_click_selects_item() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
app.click_regions.clear();
app.click_regions
.register(ratatui::layout::Rect::new(0, 1, 40, 18), Focus::Commands);
let mouse = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 10,
row: 4,
modifiers: crossterm::event::KeyModifiers::NONE,
};
app.handle_mouse(mouse);
assert_eq!(app.command_index(), 2);
}
#[test]
fn test_mouse_scroll_moves_selection() {
use crossterm::event::{MouseEvent, MouseEventKind};
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
let mouse = MouseEvent {
kind: MouseEventKind::ScrollDown,
column: 10,
row: 5,
modifiers: crossterm::event::KeyModifiers::NONE,
};
app.handle_mouse(mouse);
assert_eq!(app.command_index(), 1);
app.handle_mouse(mouse);
assert_eq!(app.command_index(), 2);
let mouse_up = MouseEvent {
kind: MouseEventKind::ScrollUp,
column: 10,
row: 5,
modifiers: crossterm::event::KeyModifiers::NONE,
};
app.handle_mouse(mouse_up);
assert_eq!(app.command_index(), 1);
}
#[test]
fn test_focus_manager_integration() {
let mut app = App::new(sample_spec());
assert_eq!(app.focus(), Focus::Commands);
app.focus_manager.next();
assert_eq!(app.focus(), Focus::Flags);
app.focus_manager.next();
assert_eq!(app.focus(), Focus::Args);
app.focus_manager.next();
assert_eq!(app.focus(), Focus::Preview);
app.focus_manager.next();
assert_eq!(app.focus(), Focus::Commands);
app.focus_manager.prev();
assert_eq!(app.focus(), Focus::Preview);
}
#[test]
fn test_tree_view_state_integration() {
let mut app = App::new(sample_spec());
let total = app.total_visible_commands();
assert_eq!(total, 15);
assert_eq!(app.command_index(), 0);
let total = app.total_visible_commands();
app.command_tree_state.select_next(total);
assert_eq!(app.command_index(), 1);
app.command_tree_state.select_prev();
assert_eq!(app.command_index(), 0);
app.command_tree_state.select_prev();
assert_eq!(app.command_index(), 0);
}
#[test]
fn test_input_state_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();
assert!(app.editing);
assert!(app.edit_input.text().is_empty());
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');
app.sync_edit_to_value();
assert_eq!(app.edit_input.text(), "hello");
assert_eq!(app.arg_values[0].value, "hello");
app.edit_input.move_home();
assert_eq!(app.edit_input.cursor_pos, 0);
app.edit_input.move_end();
assert_eq!(app.edit_input.cursor_pos, 5);
app.edit_input.delete_char_backward();
app.sync_edit_to_value();
assert_eq!(app.arg_values[0].value, "hell");
app.finish_editing();
assert!(!app.editing);
}
#[test]
fn test_click_region_registry() {
let mut app = App::new(sample_spec());
app.click_regions.clear();
app.click_regions
.register(Rect::new(0, 0, 40, 20), Focus::Commands);
app.click_regions
.register(Rect::new(40, 0, 60, 20), Focus::Flags);
app.click_regions
.register(Rect::new(0, 20, 100, 3), Focus::Preview);
assert_eq!(
app.click_regions.handle_click(10, 5),
Some(&Focus::Commands)
);
assert_eq!(app.click_regions.handle_click(50, 5), Some(&Focus::Flags));
assert_eq!(
app.click_regions.handle_click(50, 21),
Some(&Focus::Preview)
);
}
#[test]
fn test_filter_input_state() {
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);
assert!(app.filtering);
assert!(app.filter().is_empty());
let key_d = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('d'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(key_d);
assert_eq!(app.filter(), "d");
let bs = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Backspace,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(bs);
assert!(app.filter().is_empty());
let key_x = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('x'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(key_x);
let esc = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(esc);
assert!(!app.filtering);
assert!(app.filter().is_empty());
}
#[test]
fn test_flat_list_total_commands() {
let app = App::new(sample_spec());
assert_eq!(app.total_visible_commands(), 15);
}
#[test]
fn test_right_moves_to_first_child() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["config"]);
app.set_focus(Focus::Commands);
let right = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Right,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(right);
assert_eq!(
app.command_path,
vec!["config", "set"],
"Should have moved to first child of config"
);
}
#[test]
fn test_left_moves_to_parent() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["config", "set"]);
assert_eq!(app.command_path, vec!["config", "set"]);
app.set_focus(Focus::Commands);
let left = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Left,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(left);
assert_eq!(
app.command_path,
vec!["config"],
"Should have moved to parent of set"
);
}
#[test]
fn test_left_on_top_level_does_nothing() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["config"]);
app.set_focus(Focus::Commands);
let left = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Left,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(left);
assert_eq!(
app.command_path,
vec!["config"],
"Should stay on config since it has no parent"
);
}
#[test]
fn test_tree_selected_command_determines_flags() {
let mut app = App::new(sample_spec());
let root_flags = app.visible_flags();
let root_flag_names: Vec<&str> = root_flags.iter().map(|f| f.name.as_str()).collect();
assert!(root_flag_names.contains(&"verbose"));
app.navigate_to_command(&["deploy"]);
let deploy_flags = app.visible_flags();
let deploy_flag_names: Vec<&str> = deploy_flags.iter().map(|f| f.name.as_str()).collect();
assert!(deploy_flag_names.contains(&"tag"));
assert!(deploy_flag_names.contains(&"rollback"));
}
#[test]
fn test_parent_id() {
assert_eq!(parent_id(""), None);
assert_eq!(parent_id("init"), Some("".to_string()));
assert_eq!(parent_id("config"), Some("".to_string()));
assert_eq!(parent_id("config set"), Some("config".to_string()));
assert_eq!(parent_id("plugin install"), Some("plugin".to_string()));
}
#[test]
fn test_count_flag_decrement_via_backspace() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
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));
}
assert_eq!(
app.current_flag_values()[fidx].1,
FlagValue::Count(3),
"Space should increment count to 3"
);
app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(
app.current_flag_values()[fidx].1,
FlagValue::Count(2),
"Backspace should decrement count to 2"
);
app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(
app.current_flag_values()[fidx].1,
FlagValue::Count(0),
"Count should be 0 after decrementing fully"
);
app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(
app.current_flag_values()[fidx].1,
FlagValue::Count(0),
"Count should not go below 0"
);
}
#[test]
fn test_backspace_clears_bool_flag() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut app = App::new(sample_spec());
app.set_focus(Focus::Flags);
let fidx = app
.current_flag_values()
.iter()
.position(|(n, _)| n == "quiet")
.unwrap();
app.set_flag_index(fidx);
app.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
assert_eq!(app.current_flag_values()[fidx].1, FlagValue::Bool(true));
app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(
app.current_flag_values()[fidx].1,
FlagValue::Bool(false),
"Backspace should turn off boolean flags"
);
}
#[test]
fn test_backspace_clears_string_flag() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
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 == "tag")
.unwrap();
app.set_flag_index(fidx);
app.start_editing();
app.edit_input.insert_char('v');
app.edit_input.insert_char('1');
app.sync_edit_to_value();
app.finish_editing();
assert_eq!(
app.current_flag_values()[fidx].1,
FlagValue::String("v1".to_string())
);
app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(
app.current_flag_values()[fidx].1,
FlagValue::String(String::new()),
"Backspace should clear string flag values"
);
}
#[test]
fn test_backspace_clears_arg_value() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
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();
app.edit_input.insert_char('t');
app.edit_input.insert_char('e');
app.edit_input.insert_char('s');
app.edit_input.insert_char('t');
app.sync_edit_to_value();
app.finish_editing();
assert_eq!(app.arg_values[0].value, "test");
app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(
app.arg_values[0].value, "",
"Backspace should clear argument values"
);
}
#[test]
fn test_backspace_decrements_count_flag() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
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));
app.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
assert_eq!(app.current_flag_values()[fidx].1, FlagValue::Count(3));
app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(app.current_flag_values()[fidx].1, FlagValue::Count(2));
}
#[test]
fn test_editing_finished_on_mouse_click_different_arg() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
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();
assert!(app.editing);
app.edit_input.insert_char('h');
app.edit_input.insert_char('i');
app.sync_edit_to_value();
assert_eq!(app.arg_values[0].value, "hi");
let args_area = ratatui::layout::Rect::new(40, 5, 60, 10);
app.click_regions.clear();
app.click_regions.register(args_area, Focus::Args);
let mouse_event = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 50,
row: 7, modifiers: crossterm::event::KeyModifiers::NONE,
};
app.handle_mouse(mouse_event);
assert!(
!app.editing,
"Editing should be finished after clicking a different item"
);
assert_eq!(
app.arg_values[0].value, "hi",
"First arg value should be preserved"
);
assert_eq!(
app.arg_values[1].value, "",
"Second arg value should be empty, not copied from first"
);
}
#[test]
fn test_editing_finished_on_mouse_click_different_panel() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
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();
app.edit_input.insert_char('t');
app.edit_input.insert_char('e');
app.edit_input.insert_char('s');
app.edit_input.insert_char('t');
app.sync_edit_to_value();
assert_eq!(app.arg_values[0].value, "test");
assert!(app.editing);
app.click_regions.clear();
app.click_regions
.register(ratatui::layout::Rect::new(0, 0, 40, 15), Focus::Flags);
app.click_regions
.register(ratatui::layout::Rect::new(40, 0, 60, 15), Focus::Args);
let mouse_event = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 10,
row: 3,
modifiers: crossterm::event::KeyModifiers::NONE,
};
app.handle_mouse(mouse_event);
assert!(
!app.editing,
"Editing should be finished when clicking another panel"
);
assert_eq!(app.arg_values[0].value, "test");
assert_eq!(app.focus(), Focus::Flags);
}
#[test]
fn test_edit_input_not_shared_between_args() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
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();
app.edit_input.insert_char('b');
app.edit_input.insert_char('u');
app.edit_input.insert_char('i');
app.edit_input.insert_char('l');
app.edit_input.insert_char('d');
app.sync_edit_to_value();
assert_eq!(app.arg_values[0].value, "build");
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(!app.editing);
app.set_arg_index(1);
app.start_editing();
assert_eq!(
app.edit_input.text(),
"",
"edit_input should be initialized from the new arg's value, not the old one"
);
assert_eq!(app.arg_values[0].value, "build");
assert_eq!(app.arg_values[1].value, "");
}
#[test]
fn test_backspace_noop_on_non_flag_arg_focus() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(app.focus(), Focus::Commands);
}
#[test]
fn test_flatten_command_tree_ids() {
let app = App::new(sample_spec());
let flat = flatten_command_tree(&app.command_tree_nodes);
assert_eq!(flat.len(), 15);
assert_eq!(flat[0].id, "init");
assert_eq!(flat[1].id, "config");
assert_eq!(flat[2].id, "config set");
assert_eq!(flat[3].id, "config get");
assert_eq!(flat[4].id, "config list");
assert_eq!(flat[5].id, "config remove");
assert_eq!(flat[6].id, "run");
assert_eq!(flat[7].id, "deploy");
assert_eq!(flat[8].id, "plugin");
assert_eq!(flat[9].id, "plugin install");
assert_eq!(flat[10].id, "plugin uninstall");
assert_eq!(flat[11].id, "plugin list");
assert_eq!(flat[12].id, "plugin update");
assert_eq!(flat[13].id, "version");
assert_eq!(flat[14].id, "help");
}
#[test]
fn test_flatten_command_tree_full_path() {
let app = App::new(sample_spec());
let flat = flatten_command_tree(&app.command_tree_nodes);
assert_eq!(flat[0].full_path, "init");
assert_eq!(flat[1].full_path, "config");
assert_eq!(flat[2].full_path, "config set");
assert_eq!(flat[9].full_path, "plugin install");
}
#[test]
fn test_flatten_command_tree_depth() {
let app = App::new(sample_spec());
let flat = flatten_command_tree(&app.command_tree_nodes);
assert_eq!(flat[0].depth, 0); assert_eq!(flat[1].depth, 0); assert_eq!(flat[2].depth, 1); assert_eq!(flat[6].depth, 0); assert_eq!(flat[9].depth, 1); }
#[test]
fn test_ctrl_r_executes_from_any_panel() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Commands);
let ctrl_r = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('r'),
crossterm::event::KeyModifiers::CONTROL,
);
assert_eq!(app.handle_key(ctrl_r), Action::Execute);
app.set_focus(Focus::Flags);
assert_eq!(app.handle_key(ctrl_r), Action::Execute);
app.set_focus(Focus::Args);
assert_eq!(app.handle_key(ctrl_r), Action::Execute);
app.set_focus(Focus::Preview);
assert_eq!(app.handle_key(ctrl_r), Action::Execute);
}
#[test]
fn test_build_command_parts_basic() {
let app = App::new(sample_spec());
let parts = app.build_command_parts();
assert_eq!(parts, vec!["mycli", "init"]);
}
#[test]
fn test_build_command_parts_with_subcommand() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
let parts = app.build_command_parts();
assert_eq!(parts, vec!["mycli", "deploy"]);
}
#[test]
fn test_build_command_parts_with_flags_and_args() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
let values = app.current_flag_values_mut();
if let Some((_, val)) = values.iter_mut().find(|(n, _)| n == "rollback") {
*val = FlagValue::Bool(true);
}
if let Some((_, val)) = values.iter_mut().find(|(n, _)| n == "tag") {
*val = FlagValue::String("v1.0".to_string());
}
app.arg_values[0].value = "prod".to_string();
let parts = app.build_command_parts();
assert!(parts.contains(&"mycli".to_string()));
assert!(parts.contains(&"deploy".to_string()));
assert!(parts.contains(&"--rollback".to_string()));
assert!(parts.contains(&"--tag".to_string()));
assert!(parts.contains(&"v1.0".to_string()));
assert!(parts.contains(&"prod".to_string()));
let tag_idx = parts.iter().position(|p| p == "--tag").unwrap();
assert_eq!(parts[tag_idx + 1], "v1.0");
}
#[test]
fn test_build_command_parts_splits_bin_with_spaces() {
let mut spec = sample_spec();
spec.bin = "mise run".to_string();
let app = App::new(spec);
let parts = app.build_command_parts();
assert_eq!(parts[0], "mise");
assert_eq!(parts[1], "run");
}
#[test]
fn test_build_command_parts_arg_not_quoted() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.arg_values[0].value = "hello world".to_string();
let parts = app.build_command_parts();
assert!(parts.contains(&"hello world".to_string()));
assert!(!parts.iter().any(|p| p.contains('"')));
}
#[test]
fn test_build_command_parts_count_flag() {
let mut app = App::new(sample_spec());
let root_key = String::new();
if let Some(flags) = app.flag_values.get_mut(&root_key) {
if let Some((_, val)) = flags.iter_mut().find(|(n, _)| n == "verbose") {
*val = FlagValue::Count(3);
}
}
let parts = app.build_command_parts();
assert!(parts.contains(&"-vvv".to_string()));
}
#[test]
fn test_app_mode_default_is_builder() {
let app = App::new(sample_spec());
assert_eq!(app.mode, AppMode::Builder);
assert!(!app.is_executing());
assert!(app.execution.is_none());
}
#[test]
fn test_start_and_close_execution() {
let mut app = App::new(sample_spec());
let parser = Arc::new(RwLock::new(vt100::Parser::new(24, 80, 0)));
let exited = Arc::new(AtomicBool::new(false));
let exit_status = Arc::new(Mutex::new(None));
let pty_writer: Arc<Mutex<Option<Box<dyn std::io::Write + Send>>>> =
Arc::new(Mutex::new(None));
let state = ExecutionState {
command_display: "mycli deploy".to_string(),
parser,
pty_writer,
pty_master: Arc::new(Mutex::new(None)),
exited: exited.clone(),
exit_status,
};
app.start_execution(state);
assert_eq!(app.mode, AppMode::Executing);
assert!(app.is_executing());
assert!(app.execution.is_some());
assert!(!app.execution_exited());
exited.store(true, Ordering::Relaxed);
assert!(app.execution_exited());
app.close_execution();
assert_eq!(app.mode, AppMode::Builder);
assert!(!app.is_executing());
assert!(app.execution.is_none());
}
#[test]
fn test_execution_exit_status() {
let mut app = App::new(sample_spec());
let parser = Arc::new(RwLock::new(vt100::Parser::new(24, 80, 0)));
let exited = Arc::new(AtomicBool::new(true));
let exit_status = Arc::new(Mutex::new(Some("0".to_string())));
let pty_writer: Arc<Mutex<Option<Box<dyn std::io::Write + Send>>>> =
Arc::new(Mutex::new(None));
let state = ExecutionState {
command_display: "mycli deploy".to_string(),
parser,
pty_writer,
pty_master: Arc::new(Mutex::new(None)),
exited,
exit_status,
};
app.start_execution(state);
assert_eq!(app.execution_exit_status(), Some("0".to_string()));
}
#[test]
fn test_execution_key_closes_on_exit() {
let mut app = App::new(sample_spec());
let parser = Arc::new(RwLock::new(vt100::Parser::new(24, 80, 0)));
let exited = Arc::new(AtomicBool::new(true));
let exit_status = Arc::new(Mutex::new(Some("0".to_string())));
let pty_writer: Arc<Mutex<Option<Box<dyn std::io::Write + Send>>>> =
Arc::new(Mutex::new(None));
let state = ExecutionState {
command_display: "mycli".to_string(),
parser,
pty_writer,
pty_master: Arc::new(Mutex::new(None)),
exited,
exit_status,
};
app.start_execution(state);
assert!(app.is_executing());
let esc = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
);
let result = app.handle_key(esc);
assert_eq!(result, Action::None);
assert!(!app.is_executing());
assert_eq!(app.mode, AppMode::Builder);
}
#[test]
fn test_execution_key_enter_closes_on_exit() {
let mut app = App::new(sample_spec());
let parser = Arc::new(RwLock::new(vt100::Parser::new(24, 80, 0)));
let exited = Arc::new(AtomicBool::new(true));
let exit_status = Arc::new(Mutex::new(None));
let pty_writer: Arc<Mutex<Option<Box<dyn std::io::Write + Send>>>> =
Arc::new(Mutex::new(None));
let state = ExecutionState {
command_display: "mycli".to_string(),
parser,
pty_writer,
pty_master: Arc::new(Mutex::new(None)),
exited,
exit_status,
};
app.start_execution(state);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
let result = app.handle_key(enter);
assert_eq!(result, Action::None);
assert!(!app.is_executing());
}
#[test]
fn test_preview_enter_returns_execute_action() {
let mut app = App::new(sample_spec());
app.set_focus(Focus::Preview);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
let result = app.handle_key(enter);
assert_eq!(result, Action::Execute);
}
#[test]
fn test_execution_key_does_not_close_while_running() {
let mut app = App::new(sample_spec());
let parser = Arc::new(RwLock::new(vt100::Parser::new(24, 80, 0)));
let exited = Arc::new(AtomicBool::new(false)); let exit_status = Arc::new(Mutex::new(None));
let pty_writer: Arc<Mutex<Option<Box<dyn std::io::Write + Send>>>> =
Arc::new(Mutex::new(None));
let state = ExecutionState {
command_display: "mycli".to_string(),
parser,
pty_writer,
pty_master: Arc::new(Mutex::new(None)),
exited,
exit_status,
};
app.start_execution(state);
let q = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('q'),
crossterm::event::KeyModifiers::NONE,
);
let result = app.handle_key(q);
assert_eq!(result, Action::None);
assert!(app.is_executing(), "Should still be executing");
}
#[test]
fn test_resize_pty() {
use portable_pty::{NativePtySystem, PtySize, PtySystem};
let mut app = App::new(sample_spec());
let pty_system = NativePtySystem::default();
let pair = pty_system
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.expect("Failed to open PTY");
let parser = Arc::new(RwLock::new(vt100::Parser::new(24, 80, 0)));
let pty_master: Arc<Mutex<Option<Box<dyn portable_pty::MasterPty + Send>>>> =
Arc::new(Mutex::new(Some(pair.master)));
let state = ExecutionState {
command_display: "test command".to_string(),
parser,
pty_writer: Arc::new(Mutex::new(None)),
pty_master,
exited: Arc::new(AtomicBool::new(false)),
exit_status: Arc::new(Mutex::new(None)),
};
app.start_execution(state);
app.resize_pty(40, 120);
if let Some(ref exec) = app.execution {
let master_guard = exec.pty_master.lock().unwrap();
assert!(
master_guard.is_some(),
"PTY master should still exist after resize"
);
}
}
#[test]
fn test_bracket_right_cycles_theme_forward() {
let mut app = App::new(sample_spec());
let initial_theme = app.theme_name;
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(']'),
crossterm::event::KeyModifiers::NONE,
);
let result = app.handle_key(key);
assert_eq!(result, Action::None);
assert_ne!(app.theme_name, initial_theme, "Theme should have changed");
assert_eq!(app.theme_name, ThemeName::OneDarkPro);
let mut app2 = App::new(sample_spec());
let t_key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('T'),
crossterm::event::KeyModifiers::NONE,
);
app2.handle_key(t_key);
assert!(app2.is_theme_picking(), "T should open theme picker");
assert_eq!(app2.theme_name, ThemeName::Dracula, "Theme shouldn't change until navigated");
}
#[test]
fn test_bracket_left_cycles_theme_backward() {
let mut app = App::new(sample_spec());
let initial_theme = app.theme_name;
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('['),
crossterm::event::KeyModifiers::NONE,
);
let result = app.handle_key(key);
assert_eq!(result, Action::None);
assert_ne!(
app.theme_name, initial_theme,
"Theme should have changed backward"
);
}
#[test]
fn test_bracket_left_right_are_inverses() {
let mut app = App::new(sample_spec());
let initial_theme = app.theme_name;
let right = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(']'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(right);
let after_forward = app.theme_name;
assert_ne!(after_forward, initial_theme);
let left = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('['),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(left);
assert_eq!(app.theme_name, initial_theme, "[ should undo ]");
}
#[test]
fn test_enter_on_commands_navigates_into_child() {
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
app.navigate_to_command(&["config"]);
let config_idx = app.command_index();
assert_eq!(app.command_path, vec!["config"]);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
let result = app.handle_key(enter);
assert_eq!(result, Action::None);
assert!(
app.command_index() > config_idx,
"Enter should navigate into first child"
);
assert!(
app.command_path.len() >= 2,
"Should have navigated deeper: {:?}",
app.command_path
);
}
#[test]
fn test_enter_on_commands_same_as_right() {
let mut app_enter = App::new(sample_spec());
app_enter.set_focus(Focus::Commands);
app_enter.navigate_to_command(&["config"]);
let mut app_right = App::new(sample_spec());
app_right.set_focus(Focus::Commands);
app_right.navigate_to_command(&["config"]);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
let right = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Right,
crossterm::event::KeyModifiers::NONE,
);
app_enter.handle_key(enter);
app_right.handle_key(right);
assert_eq!(app_enter.command_index(), app_right.command_index());
assert_eq!(app_enter.command_path, app_right.command_path);
}
#[test]
fn test_enter_on_leaf_command_does_nothing() {
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
app.navigate_to_command(&["init"]); let init_idx = app.command_index();
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
let result = app.handle_key(enter);
assert_eq!(result, Action::None);
assert_eq!(
app.command_index(),
init_idx,
"Enter on leaf should not move"
);
}
#[test]
fn test_p_on_non_preview_does_nothing() {
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
let p = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('p'),
crossterm::event::KeyModifiers::NONE,
);
let result = app.handle_key(p);
assert_eq!(
result,
Action::None,
"p should do nothing when not on Preview"
);
app.set_focus(Focus::Flags);
let result = app.handle_key(p);
assert_eq!(result, Action::None, "p should do nothing when on Flags");
}
#[test]
fn test_startup_sync_selects_first_command() {
let app = App::new(sample_spec());
assert_eq!(app.command_path, vec!["init"]);
let flags = app.visible_flags();
let names: Vec<&str> = flags.iter().map(|f| f.name.as_str()).collect();
assert!(
names.contains(&"template"),
"init should have template flag"
);
assert!(names.contains(&"force"), "init should have force flag");
assert!(
names.contains(&"verbose"),
"init should have global verbose"
);
assert!(!app.arg_values.is_empty(), "init has a <name> arg");
assert_eq!(app.arg_values[0].name, "name");
}
#[test]
fn test_global_flag_sync_from_subcommand() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Flags);
let flags = app.visible_flags();
let verbose_idx = flags.iter().position(|f| f.name == "verbose").unwrap();
app.set_flag_index(verbose_idx);
let space = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(' '),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(space);
let deploy_flags = app.current_flag_values();
let verbose_val = deploy_flags.iter().find(|(n, _)| n == "verbose");
assert!(
matches!(verbose_val, Some((_, FlagValue::Count(1)))),
"verbose should be Count(1) at deploy level"
);
let root_flags = app.flag_values.get("").unwrap();
let root_verbose = root_flags.iter().find(|(n, _)| n == "verbose");
assert!(
matches!(root_verbose, Some((_, FlagValue::Count(1)))),
"verbose should be synced to root level"
);
let cmd = app.build_command();
assert!(
cmd.contains("-v"),
"global flag toggled from subcommand should appear in command: {cmd}"
);
}
#[test]
fn test_global_flag_sync_across_multiple_levels() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["init"]);
app.set_focus(Focus::Flags);
let flags = app.visible_flags();
let verbose_idx = flags.iter().position(|f| f.name == "verbose").unwrap();
app.set_flag_index(verbose_idx);
let space = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(' '),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(space);
app.handle_key(space);
app.handle_key(space);
app.navigate_to_command(&["deploy"]);
let deploy_flags = app.current_flag_values();
let verbose_val = deploy_flags.iter().find(|(n, _)| n == "verbose");
assert!(
matches!(verbose_val, Some((_, FlagValue::Count(3)))),
"verbose should be Count(3) at deploy level after sync"
);
let cmd = app.build_command();
assert!(cmd.contains("-vvv"), "command should contain -vvv: {cmd}");
}
#[test]
fn test_filtered_navigation_skips_non_matches() {
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
let slash = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('/'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(slash);
for c in "deploy".chars() {
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(c),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(key);
}
let flat = flatten_command_tree(&app.command_tree_nodes);
let selected = &flat[app.command_index()];
assert_eq!(selected.name, "deploy", "should auto-select deploy");
let down = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(down);
let selected = &flat[app.command_index()];
assert_eq!(
selected.name, "deploy",
"down should stay on deploy (only match)"
);
let up = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Up,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(up);
let selected = &flat[app.command_index()];
assert_eq!(
selected.name, "deploy",
"up should stay on deploy (only match)"
);
}
#[test]
fn test_filtered_navigation_cycles_through_matches() {
let mut app = App::new(sample_spec());
app.set_focus(Focus::Commands);
let slash = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('/'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(slash);
for c in "'list".chars() {
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(c),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(key);
}
let flat = flatten_command_tree(&app.command_tree_nodes);
let first_selected = app.command_index();
let first_name = flat[first_selected].name.clone();
assert_eq!(first_name, "list", "should auto-select first 'list'");
let down = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(down);
let second_selected = app.command_index();
assert_ne!(
second_selected, first_selected,
"down should move to a different list match"
);
assert_eq!(flat[second_selected].name, "list");
let up = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Up,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(up);
assert_eq!(app.command_index(), first_selected);
}
#[test]
fn test_filtered_flag_navigation() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Flags);
let slash = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('/'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(slash);
for c in "skip".chars() {
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(c),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(key);
}
let flags = app.visible_flags();
let selected_flag = &flags[app.flag_index()];
assert_eq!(
selected_flag.name, "skip-tests",
"should auto-select skip-tests"
);
}
#[test]
fn test_match_scores_separate_name_and_help() {
let app = App::new(sample_spec());
let scores = compute_tree_scores(&app.command_tree_nodes, "verbose");
if let Some(init_scores) = scores.get("init") {
assert_eq!(
init_scores.name_score, 0,
"init name should not match 'verbose'"
);
}
}
#[test]
fn test_match_scores_help_only_match() {
let app = App::new(sample_spec());
let scores = compute_tree_scores(&app.command_tree_nodes, "project");
let init_scores = scores.get("init").expect("init should have scores");
assert_eq!(
init_scores.name_score, 0,
"init name should not match 'project'"
);
assert!(
init_scores.help_score > 0,
"init help should match 'project'"
);
assert!(
init_scores.overall() > 0,
"init should match overall via help"
);
}
#[test]
fn test_match_scores_name_only_match() {
let app = App::new(sample_spec());
let scores = compute_tree_scores(&app.command_tree_nodes, "cfg");
let config_scores = scores.get("config").expect("config should have scores");
assert!(
config_scores.name_score > 0,
"config name should match 'cfg'"
);
}
#[test]
fn test_flag_match_scores_separate_name_and_help() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Flags);
app.filter_input.clear();
for c in "Docker".chars() {
app.filter_input.insert_char(c);
}
let scores = app.compute_flag_match_scores();
if let Some(tag_scores) = scores.get("tag") {
assert_eq!(
tag_scores.name_score, 0,
"tag name should not match 'Docker'"
);
assert!(
tag_scores.help_score > 0,
"tag help 'Docker image tag' should match 'Docker'"
);
}
}
#[test]
fn test_args_filter_auto_selects_matching_arg() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["config", "set"]);
app.set_focus(Focus::Args);
assert!(
app.arg_values.len() >= 2,
"config set should have at least 2 args"
);
app.set_arg_index(0);
assert_eq!(app.arg_index(), 0);
let first_arg_name = app.arg_values[0].name.clone();
let slash = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('/'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(slash);
assert!(app.filtering);
for c in "val".chars() {
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(c),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(key);
}
let scores = app.compute_arg_match_scores();
let first_score = scores
.get(&first_arg_name)
.map(|s| s.overall())
.unwrap_or(0);
assert_eq!(
first_score, 0,
"'{}' should not match 'val'",
first_arg_name
);
let selected = app.arg_index();
let selected_name = &app.arg_values[selected].name;
let selected_score = scores
.get(selected_name)
.map(|s| s.overall())
.unwrap_or(0);
assert!(
selected_score > 0,
"selected arg '{}' should match 'val'",
selected_name
);
}
#[test]
fn test_enter_on_choice_flag_opens_select_box() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["init"]);
app.set_focus(Focus::Flags);
let fidx = app
.current_flag_values()
.iter()
.position(|(n, _)| n == "template")
.unwrap();
app.set_flag_index(fidx);
assert!(!app.is_choosing(), "Should not be choosing before Enter");
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
assert!(app.is_choosing(), "Enter on choice flag should open select box");
let cs = app.choice_select.as_ref().unwrap();
assert_eq!(cs.choices, vec!["basic", "full", "minimal"]);
assert_eq!(cs.source_panel, Focus::Flags);
assert_eq!(cs.source_index, fidx);
}
#[test]
fn test_enter_on_choice_arg_opens_select_box() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Args);
app.set_arg_index(0);
assert!(!app.is_choosing());
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
assert!(app.is_choosing(), "Enter on choice arg should open select box");
let cs = app.choice_select.as_ref().unwrap();
assert_eq!(cs.choices, vec!["dev", "staging", "prod"]);
assert_eq!(cs.source_panel, Focus::Args);
}
#[test]
fn test_choice_select_enter_confirms() {
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 down = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(down); app.handle_key(down);
app.handle_key(enter);
assert!(!app.is_choosing(), "Enter should close select box");
assert_eq!(app.arg_values[0].value, "staging", "Should have selected staging");
}
#[test]
fn test_choice_select_esc_cancels() {
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 down = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(down);
let esc = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(esc);
assert!(!app.is_choosing(), "Esc should close select box");
assert_eq!(app.arg_values[0].value, "", "Esc should not change value");
}
#[test]
fn test_choice_select_filter_narrows_choices() {
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());
assert_eq!(app.filtered_choices().len(), 3);
let d_key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('d'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(d_key);
let filtered = app.filtered_choices();
assert!(filtered.len() < 3, "Filter should narrow choices");
assert!(
filtered.iter().any(|(_, c)| c == "dev"),
"dev should match 'd'"
);
let e_key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('e'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(e_key);
let v_key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('v'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(v_key);
let filtered = app.filtered_choices();
assert_eq!(filtered.len(), 1, "Only 'dev' should match 'dev'");
assert_eq!(filtered[0].1, "dev");
app.handle_key(enter);
assert!(!app.is_choosing());
assert_eq!(app.arg_values[0].value, "dev");
}
#[test]
fn test_choice_select_preselects_current_value() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["deploy"]);
app.set_focus(Focus::Args);
app.set_arg_index(0);
app.arg_values[0].value = "staging".to_string();
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
assert!(app.is_choosing());
let cs = app.choice_select.as_ref().unwrap();
assert_eq!(cs.selected_index, Some(1), "Should pre-select 'staging'");
}
#[test]
fn test_choice_select_flag_confirms_value() {
let mut app = App::new(sample_spec());
app.navigate_to_command(&["init"]);
app.set_focus(Focus::Flags);
let fidx = app
.current_flag_values()
.iter()
.position(|(n, _)| n == "template")
.unwrap();
app.set_flag_index(fidx);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
assert!(app.is_choosing());
let down = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(down); app.handle_key(down);
app.handle_key(enter);
assert!(!app.is_choosing());
let val = &app.current_flag_values()[fidx].1;
assert_eq!(
val,
&FlagValue::String("full".to_string()),
"Flag value should be 'full'"
);
}
#[test]
fn test_choice_select_mouse_click_closes() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
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 mouse = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 5,
row: 5,
modifiers: crossterm::event::KeyModifiers::NONE,
};
app.handle_mouse(mouse);
assert!(!app.is_choosing(), "Mouse click should close select box");
}
#[test]
fn test_choice_select_navigation_bounds() {
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);
let up = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Up,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(up);
assert_eq!(app.choice_select.as_ref().unwrap().selected_index, None);
let down = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(down);
assert_eq!(app.choice_select.as_ref().unwrap().selected_index, Some(0));
app.handle_key(down);
app.handle_key(down);
assert_eq!(app.choice_select.as_ref().unwrap().selected_index, Some(2));
app.handle_key(down);
assert_eq!(app.choice_select.as_ref().unwrap().selected_index, Some(2));
}
#[test]
fn test_choice_select_set_focus_closes() {
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());
app.set_focus(Focus::Flags);
assert!(!app.is_choosing(), "Switching focus should close select box");
}
#[test]
fn test_t_opens_theme_picker() {
let mut app = App::new(sample_spec());
assert!(!app.is_theme_picking());
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('T'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(key);
assert!(app.is_theme_picking());
assert_eq!(app.theme_picker.as_ref().unwrap().selected_index, 0);
assert_eq!(app.theme_picker.as_ref().unwrap().original_theme, ThemeName::Dracula);
}
#[test]
fn test_theme_picker_esc_restores_original() {
let mut app = App::new(sample_spec());
app.open_theme_picker();
let down = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(down);
assert_eq!(app.theme_name, ThemeName::OneDarkPro);
let esc = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(esc);
assert!(!app.is_theme_picking());
assert_eq!(app.theme_name, ThemeName::Dracula);
}
#[test]
fn test_theme_picker_enter_confirms() {
let mut app = App::new(sample_spec());
app.open_theme_picker();
let down = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(down);
app.handle_key(down);
assert_eq!(app.theme_name, ThemeName::Nord);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
assert!(!app.is_theme_picking());
assert_eq!(app.theme_name, ThemeName::Nord);
}
#[test]
fn test_theme_picker_up_down_navigates() {
let mut app = App::new(sample_spec());
app.open_theme_picker();
let down = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(down);
assert_eq!(app.theme_picker.as_ref().unwrap().selected_index, 1);
assert_eq!(app.theme_name, ThemeName::OneDarkPro);
let up = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Up,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(up);
assert_eq!(app.theme_picker.as_ref().unwrap().selected_index, 0);
assert_eq!(app.theme_name, ThemeName::Dracula);
app.handle_key(up);
assert_eq!(
app.theme_picker.as_ref().unwrap().selected_index,
ThemeName::all().len() - 1
);
assert_eq!(app.theme_name, ThemeName::Cyberpunk);
}
#[test]
fn test_theme_picker_jk_navigates() {
let mut app = App::new(sample_spec());
app.open_theme_picker();
let j = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('j'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(j);
assert_eq!(app.theme_name, ThemeName::OneDarkPro);
let k = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('k'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(k);
assert_eq!(app.theme_name, ThemeName::Dracula);
}
#[test]
fn test_theme_picker_t_does_not_nest() {
let mut app = App::new(sample_spec());
app.open_theme_picker();
assert!(app.is_theme_picking());
let t = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('T'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(t);
assert!(app.is_theme_picking());
assert_eq!(app.theme_picker.as_ref().unwrap().original_theme, ThemeName::Dracula);
}
#[test]
fn test_bracket_keys_still_cycle_without_picker() {
let mut app = App::new(sample_spec());
let rb = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(']'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(rb);
assert!(!app.is_theme_picking());
assert_eq!(app.theme_name, ThemeName::OneDarkPro);
let lb = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('['),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(lb);
assert!(!app.is_theme_picking());
assert_eq!(app.theme_name, ThemeName::Dracula);
}
#[test]
fn test_theme_picker_mouse_click_outside_closes() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let mut app = App::new(sample_spec());
app.open_theme_picker();
if let Some(ref mut tp) = app.theme_picker {
tp.overlay_rect = Some(Rect::new(60, 5, 20, 17));
}
let mouse = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 5,
row: 5,
modifiers: crossterm::event::KeyModifiers::NONE,
};
app.handle_mouse(mouse);
assert!(!app.is_theme_picking(), "Click outside should close picker");
assert_eq!(app.theme_name, ThemeName::Dracula, "Should restore original theme");
}
#[test]
fn test_theme_picker_mouse_click_on_indicator_opens() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let mut app = App::new(sample_spec());
app.theme_indicator_rect = Some(Rect::new(85, 23, 10, 1));
assert!(!app.is_theme_picking());
let mouse = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 88,
row: 23,
modifiers: crossterm::event::KeyModifiers::NONE,
};
app.handle_mouse(mouse);
assert!(app.is_theme_picking(), "Click on theme indicator should open picker");
}
#[test]
fn test_theme_picker_down_wraps_at_end() {
let mut app = App::new(sample_spec());
app.open_theme_picker();
let all = ThemeName::all();
if let Some(ref mut tp) = app.theme_picker {
tp.selected_index = all.len() - 1;
}
app.theme_name = *all.last().unwrap();
let down = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(down);
assert_eq!(app.theme_picker.as_ref().unwrap().selected_index, 0);
assert_eq!(app.theme_name, ThemeName::Dracula);
}
#[test]
fn test_parse_completion_line_simple() {
let result = parse_completion_line("auth:Authentication plugin");
assert_eq!(
result,
Some(("auth".to_string(), "Authentication plugin".to_string()))
);
}
#[test]
fn test_parse_completion_line_escaped_colon() {
let result = parse_completion_line("host\\:port:A host:port pair");
assert_eq!(
result,
Some(("host:port".to_string(), "A host:port pair".to_string()))
);
}
#[test]
fn test_parse_completion_line_no_description() {
let result = parse_completion_line("simple-value");
assert_eq!(result, None);
}
#[test]
fn test_run_completion_simple() {
let result = App::run_completion("printf 'alpha\nbeta\ngamma\n'", false);
assert!(result.is_some());
let (choices, descs) = result.unwrap();
assert_eq!(choices, vec!["alpha", "beta", "gamma"]);
assert!(descs.iter().all(|d| d.is_none()));
}
#[test]
fn test_run_completion_with_descriptions() {
let result =
App::run_completion("printf 'auth:Auth plugin\ncache:Cache layer\n'", true);
assert!(result.is_some());
let (choices, descs) = result.unwrap();
assert_eq!(choices, vec!["auth", "cache"]);
assert_eq!(
descs,
vec![
Some("Auth plugin".to_string()),
Some("Cache layer".to_string())
]
);
}
#[test]
fn test_run_completion_failure() {
let result = App::run_completion("false", false);
assert!(result.is_none());
}
#[test]
fn test_run_completion_empty_output() {
let result = App::run_completion("echo ''", false);
assert!(result.is_none());
}
#[test]
fn test_find_completion_exists() {
let spec = sample_spec();
let mut app = App::new(spec);
app.navigate_to_command(&["plugin", "install"]);
let complete = app.find_completion("name");
assert!(complete.is_some(), "Should find completion for 'name'");
assert!(
complete.unwrap().run.is_some(),
"Completion should have a run command"
);
}
#[test]
fn test_find_completion_not_exists() {
let spec = sample_spec();
let mut app = App::new(spec);
app.navigate_to_command(&["plugin", "install"]);
let complete = app.find_completion("nonexistent");
assert!(complete.is_none());
}
#[test]
fn test_find_completion_with_descriptions_flag() {
let spec = sample_spec();
let mut app = App::new(spec);
app.navigate_to_command(&["plugin", "update"]);
let complete = app.find_completion("name");
assert!(complete.is_some());
assert!(complete.unwrap().descriptions);
}
#[test]
fn test_completion_runs_each_time() {
let spec = sample_spec();
let mut app = App::new(spec);
app.navigate_to_command(&["plugin", "install"]);
let result = app.run_and_open_completion("name", "");
assert!(result.is_some());
let (choices, _) = result.unwrap();
assert!(!choices.is_empty());
app.close_choice_select();
let result2 = app.run_and_open_completion("name", "auth");
assert!(result2.is_some());
let (choices2, _) = result2.unwrap();
assert_eq!(choices2, choices, "Should get same results on re-run");
}
#[test]
fn test_enter_on_completion_arg_opens_select() {
let spec = sample_spec();
let mut app = App::new(spec);
app.navigate_to_command(&["plugin", "install"]);
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(),
"Enter on arg with completion should open select"
);
assert!(
app.editing,
"Editing should be active alongside choice select"
);
}
#[test]
fn test_esc_from_completion_select_keeps_text() {
let spec = sample_spec();
let mut app = App::new(spec);
app.navigate_to_command(&["plugin", "install"]);
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 a_key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('a'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(a_key);
let esc = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(esc);
assert!(!app.is_choosing(), "Select should be closed");
assert!(!app.editing, "Editing should be closed");
assert_eq!(app.arg_values[0].value, "a", "Typed text should be kept as value");
}
#[test]
fn test_completion_descriptions_in_select() {
let spec = sample_spec();
let mut app = App::new(spec);
app.navigate_to_command(&["plugin", "update"]);
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 cs = app.choice_select.as_ref().unwrap();
assert!(
!cs.descriptions.is_empty(),
"Should have descriptions"
);
assert!(
cs.descriptions.iter().any(|d| d.is_some()),
"At least one description should be present"
);
}
#[test]
fn test_choice_select_typing_clears_selection() {
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 down = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(down);
assert_eq!(app.choice_select.as_ref().unwrap().selected_index, Some(0));
let d_key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('d'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(d_key);
assert_eq!(
app.choice_select.as_ref().unwrap().selected_index, None,
"Typing should clear selection"
);
assert_eq!(app.edit_input.text(), "d", "Text should be in edit input");
}
#[test]
fn test_choice_select_enter_with_no_selection_accepts_text() {
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());
for c in "custom".chars() {
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(c),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(key);
}
assert_eq!(app.choice_select.as_ref().unwrap().selected_index, None);
app.handle_key(enter);
assert!(!app.is_choosing(), "Select should be closed");
assert!(!app.editing, "Editing should be closed");
assert_eq!(app.arg_values[0].value, "custom", "Typed text should be the value");
}
#[test]
fn test_choice_select_up_from_first_deselects() {
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);
let down = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(down);
assert_eq!(app.choice_select.as_ref().unwrap().selected_index, Some(0));
let up = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Up,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(up);
assert_eq!(
app.choice_select.as_ref().unwrap().selected_index, None,
"Up from first item should deselect"
);
}
#[test]
fn test_choice_select_reopen_after_selection() {
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 down = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(down); app.handle_key(enter); assert!(!app.is_choosing());
assert_eq!(app.arg_values[0].value, "dev");
app.handle_key(enter);
assert!(app.is_choosing(), "Should be able to reopen select after choosing");
assert_eq!(
app.choice_select.as_ref().unwrap().selected_index,
Some(0),
"Should pre-select current value"
);
}
#[test]
fn test_choice_select_shows_all_choices_on_reopen() {
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,
);
let down = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(enter);
app.handle_key(down);
app.handle_key(enter);
assert_eq!(app.arg_values[0].value, "dev");
app.handle_key(enter);
assert!(app.is_choosing());
assert!(!app.choice_select.as_ref().unwrap().filter_active);
assert_eq!(app.filtered_choices().len(), 3);
let s_key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('s'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key(s_key);
assert!(app.choice_select.as_ref().unwrap().filter_active);
assert!(app.filtered_choices().len() < 3);
}
}