use std::collections::HashMap;
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, ListItem, Paragraph},
};
use crate::prelude::*;
use commands::{ArgKind, ArgValue, CommandId, CommandMeta, HistoryEntry, meta};
use input::{Key, KeyEvent, Modifiers, MouseButton, MouseEvent, MouseEventKind};
use operation::{CommandSelectorOp, Event, Operation};
use settings::Settings;
use utils::fuzzy::filter_and_rank_by;
use views::View;
use widgets::{
focus::FocusRing,
list::SelectableList,
pane::TitledPane,
tree::{FlatNode, TreeNode, TreeWidget},
};
use widgets::focusable::FocusOp;
use widgets::input_field::InputField;
#[derive(Debug)]
enum Mode {
Selector(SelectorState),
ArgForm(ArgFormState),
}
const FOCUS_FILTER: &str = "filter";
const FOCUS_HISTORY: &str = "history";
const FOCUS_TREE: &str = "tree";
#[derive(Debug)]
struct SelectorState {
filter: InputField,
focus: FocusRing,
history_all: Vec<CommandId>,
history_list: SelectableList<CommandId>,
tree: CommandTreeView,
tree_all: Vec<CommandId>,
tree_filtered: SelectableList<CommandId>,
}
impl SelectorState {
fn new(all_commands: Vec<CommandId>, history: Vec<CommandId>) -> Self {
let tree = CommandTreeView::from_commands(&all_commands);
let history_list = SelectableList::new(history.clone());
let tree_filtered = SelectableList::new(all_commands.clone());
Self {
filter: InputField::new("Filter"),
focus: FocusRing::new(vec![FOCUS_FILTER, FOCUS_HISTORY, FOCUS_TREE]),
history_all: history,
history_list,
tree,
tree_all: all_commands,
tree_filtered,
}
}
fn refilter(&mut self) {
let q = self.filter.text().to_string();
let id_text = |id: &CommandId| format!("{}.{}", id.group, id.name);
let hist = filter_and_rank_by(&self.history_all, &q, id_text)
.into_iter()
.cloned()
.collect();
self.history_list.set_items(hist);
let tree = filter_and_rank_by(&self.tree_all, &q, id_text)
.into_iter()
.cloned()
.collect();
self.tree_filtered.set_items(tree);
}
fn active_pane(&self) -> &str {
let cur = self.focus.current();
if cur == FOCUS_TREE {
FOCUS_TREE
} else {
FOCUS_HISTORY
}
}
fn selected_command(&self) -> Option<CommandId> {
match self.active_pane() {
FOCUS_HISTORY => self.history_list.selected().cloned(),
_ => {
if self.filter.is_empty() {
self.tree.selected_leaf()
} else {
self.tree_filtered.selected().cloned()
}
}
}
}
}
#[derive(Debug)]
struct CommandTreeView {
inner: TreeWidget<CommandTreeNode>,
}
#[derive(Debug, Clone)]
enum CommandTreeNode {
Group(String), Command(CommandId), }
impl CommandTreeView {
fn from_commands(ids: &[CommandId]) -> Self {
let mut groups: Vec<(String, Vec<CommandId>)> = Vec::new();
for id in ids {
if let Some(g) = groups.iter_mut().find(|(g, _)| g == id.group.as_ref()) {
g.1.push(id.clone());
} else {
groups.push((id.group.to_string(), vec![id.clone()]));
}
}
let roots: Vec<TreeNode<CommandTreeNode>> = groups
.into_iter()
.map(|(group, cmds)| {
let children = cmds
.into_iter()
.map(|id| TreeNode::leaf(CommandTreeNode::Command(id)))
.collect();
let mut node = TreeNode::inner(CommandTreeNode::Group(group), children);
node.expanded = true; node
})
.collect();
Self {
inner: TreeWidget::new(roots),
}
}
fn selected_leaf(&self) -> Option<CommandId> {
let flat = self.inner.selected()?;
match &flat.node.data {
CommandTreeNode::Command(id) => Some(id.clone()),
CommandTreeNode::Group(_) => None,
}
}
fn key_up(&mut self) {
self.inner.move_up();
}
fn key_down(&mut self) {
self.inner.move_down();
}
fn key_left(&mut self) {
self.inner.collapse_or_leave();
}
fn key_right(&mut self) {
self.inner.expand_or_enter();
}
#[allow(clippy::too_many_arguments)]
fn render(
&self,
frame: &mut Frame,
area: Rect,
_active: bool,
pane_bg: Color,
sel_bg: Color,
sel_fg: Color,
dir_fg: Color,
) {
self.inner.render(area, frame.buffer_mut(), |flat, selected| {
render_tree_node(flat, selected, pane_bg, sel_bg, sel_fg, dir_fg)
});
}
}
fn render_tree_node(
flat: &FlatNode<'_, CommandTreeNode>,
selected: bool,
pane_bg: Color,
sel_bg: Color,
sel_fg: Color,
dir_fg: Color,
) -> Line<'static> {
let bg = if selected { sel_bg } else { pane_bg };
match &flat.node.data {
CommandTreeNode::Group(g) => {
let icon = if flat.node.expanded { "▾ " } else { "▸ " };
Line::from(vec![Span::styled(
format!("{}{}/", icon, g),
Style::default()
.fg(if selected { sel_fg } else { dir_fg })
.bg(bg)
.add_modifier(Modifier::BOLD),
)])
}
CommandTreeNode::Command(id) => {
Line::from(vec![
Span::styled(" ", Style::default().bg(bg)),
Span::styled(
id.name.to_string(),
Style::default()
.fg(if selected { sel_fg } else { Color::Gray })
.bg(bg)
.add_modifier(if selected { Modifier::BOLD } else { Modifier::empty() }),
),
])
}
}
}
#[derive(Debug)]
struct ArgFormState {
command_id: CommandId,
command_title: String,
fields: Vec<ArgField>,
focused: usize,
}
#[derive(Debug)]
struct ArgField {
name: String,
description: String,
kind: ArgKind,
required: bool,
value: String,
}
impl ArgFormState {
fn new(meta: &CommandMeta) -> Self {
let fields = meta
.args
.iter()
.map(|a| ArgField {
name: a.name.to_string(),
description: a.description.to_string(),
kind: a.kind.clone(),
required: a.required,
value: String::new(),
})
.collect();
Self {
command_id: meta.id.clone(),
command_title: meta.title.to_string(),
fields,
focused: 0,
}
}
fn collect_args(&self) -> HashMap<String, ArgValue> {
let mut map = HashMap::new();
for f in &self.fields {
if f.value.is_empty() {
continue;
}
let val = match &f.kind {
ArgKind::Bool => f
.value
.parse::<bool>()
.ok()
.map(ArgValue::Bool)
.unwrap_or(ArgValue::String(f.value.clone())),
ArgKind::Int => f
.value
.parse::<i64>()
.ok()
.map(ArgValue::Int)
.unwrap_or(ArgValue::String(f.value.clone())),
ArgKind::FilePath => ArgValue::FilePath(std::path::PathBuf::from(&f.value)),
_ => ArgValue::String(f.value.clone()),
};
map.insert(f.name.clone(), val);
}
map
}
}
pub struct CommandRunner {
all_commands: Vec<CommandId>,
metas: HashMap<CommandId, CommandMetaSnapshot>,
mode: Mode,
}
struct CommandMetaSnapshot {
title: String,
args: Vec<crate::commands::CommandArg>,
}
impl std::fmt::Debug for CommandRunner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CommandRunner").finish()
}
}
impl CommandRunner {
pub fn new(all_metas: &[&CommandMeta], history: &[HistoryEntry]) -> Self {
let mut all_commands: Vec<CommandId> = all_metas.iter().map(|m| m.id.clone()).collect();
all_commands.sort_by(|a, b| a.group.cmp(&b.group).then(a.name.cmp(&b.name)));
let metas: HashMap<CommandId, CommandMetaSnapshot> = all_metas
.iter()
.map(|m| {
(
m.id.clone(),
CommandMetaSnapshot {
title: m.title.to_string(),
args: m.args.clone(),
},
)
})
.collect();
let history_ids: Vec<CommandId> = history.iter().map(|e| e.id.clone()).collect();
let selector = SelectorState::new(all_commands.clone(), history_ids);
Self {
all_commands,
metas,
mode: Mode::Selector(selector),
}
}
fn confirm_selection(&self) -> Vec<Operation> {
let Mode::Selector(sel) = &self.mode else {
return vec![];
};
let Some(id) = sel.selected_command() else {
return vec![Operation::CommandSelectorLocal(
CommandSelectorOp::CollapseOrLeft,
)];
};
let Some(meta) = self.metas.get(&id) else {
return vec![];
};
if meta.args.is_empty() {
vec![Operation::RunCommand {
id,
args: HashMap::new(),
}]
} else {
vec![Operation::CommandSelectorLocal(CommandSelectorOp::Confirm)]
}
}
fn render_selector(&self, frame: &mut Frame, area: Rect, theme: &crate::theme::Theme) {
let Mode::Selector(sel) = &self.mode else {
return;
};
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(1)])
.split(area);
let filter_focused = sel.focus.is_focused(FOCUS_FILTER);
sel.filter.render(frame, outer[0], filter_focused, theme);
let panes = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(35), Constraint::Percentage(65)])
.split(outer[1]);
let hist_pane = TitledPane::new("Recent Commands", sel.focus.is_focused(FOCUS_HISTORY));
let hist_content = hist_pane.prepare(frame, panes[0], theme);
let hist_bg = hist_pane.bg(theme);
let (sel_bg, sel_fg, fg_dim) = (theme.selection_bg(), theme.selection_fg(), theme.fg_dim());
frame.render_widget(
sel.history_list.widget(|id, selected| {
cmd_list_item(id, selected, hist_bg, sel_bg, sel_fg, fg_dim)
}),
hist_content,
);
let tree_active = sel.focus.is_focused(FOCUS_TREE);
let tree_pane = TitledPane::new("Commands", tree_active);
let tree_content = tree_pane.prepare(frame, panes[1], theme);
let tree_bg = tree_pane.bg(theme);
if sel.filter.is_empty() {
sel.tree.render(frame, tree_content, tree_active, tree_bg, sel_bg, sel_fg, theme.tree_dir());
} else if sel.tree_filtered.is_empty() {
frame.render_widget(
Paragraph::new(Span::styled("(no matches)", Style::default().fg(theme.fg_dim()))),
tree_content,
);
} else {
frame.render_widget(
sel.tree_filtered.widget(|id, selected| {
cmd_list_item(id, selected, tree_bg, sel_bg, sel_fg, fg_dim)
}),
tree_content,
);
}
}
fn render_arg_form(&self, frame: &mut Frame, area: Rect, theme: &crate::theme::Theme) {
let Mode::ArgForm(form) = &self.mode else {
return;
};
let pane_bg = theme.bg_active();
frame.render_widget(Block::default().style(Style::new().bg(pane_bg)), area);
let title = format!(" Run: {} — {}", form.command_id, form.command_title);
let title_area = Rect { height: 1, ..area };
frame.render_widget(
Paragraph::new(Span::styled(
title,
Style::new().fg(theme.accent()).bg(pane_bg).add_modifier(Modifier::BOLD),
)),
title_area,
);
let inner = Rect {
y: area.y + 1,
height: area.height.saturating_sub(1),
..area
};
let mut constraints: Vec<Constraint> = form
.fields
.iter()
.flat_map(|_| {
[
Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), ]
})
.collect();
constraints.push(Constraint::Min(1)); let rows = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(inner);
for (i, field) in form.fields.iter().enumerate() {
let focused = i == form.focused;
let label_row = i * 4;
let input_row = label_row + 1;
let hint_row = label_row + 2;
let label_style = if focused {
Style::default().fg(theme.accent()).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Gray)
};
let req_marker = if field.required { " *" } else { "" };
frame.render_widget(
Paragraph::new(Span::styled(
format!(" {}{}", field.name, req_marker),
label_style,
)),
rows[label_row],
);
let cursor = if focused { "_" } else { "" };
let input_bg = if focused { theme.selection_bg() } else { theme.bg_inactive() };
let input_fg = if focused { theme.fg_active() } else { Color::Gray };
if input_row < rows.len() {
let input_area = rows[input_row];
frame.render_widget(
Paragraph::new(Span::styled(
format!(" {}{}", field.value, cursor),
Style::default().fg(input_fg).bg(input_bg),
))
.style(Style::default().bg(input_bg)),
input_area,
);
}
let kind_hint = match &field.kind {
ArgKind::String => "text",
ArgKind::Bool => "true / false",
ArgKind::Int => "integer",
ArgKind::FilePath => "file path",
ArgKind::Choice(opts) => {
let _ = opts;
"choice"
}
};
if hint_row < rows.len() {
frame.render_widget(
Paragraph::new(Span::styled(
format!(" {} · {}", field.description, kind_hint),
Style::default().fg(theme.fg_dim()),
)),
rows[hint_row],
);
}
}
let footer_idx = form.fields.len() * 4;
if footer_idx < rows.len() {
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
" [Enter]",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw(" Run "),
Span::styled(
"[Tab]",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" Next field "),
Span::styled(
"[Esc]",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::raw(" Back"),
])),
rows[footer_idx],
);
}
}
}
impl View for CommandRunner {
const KIND: crate::views::ViewKind = crate::views::ViewKind::Modal;
fn save_state(&mut self, _app: &mut crate::app_state::AppState) {}
fn handle_key(&self, key: KeyEvent) -> Vec<Operation> {
match &self.mode {
Mode::Selector(sel) => handle_key_selector(sel, key, self),
Mode::ArgForm(form) => handle_key_form(form, key),
}
}
fn handle_mouse(&self, mouse: MouseEvent) -> Vec<Operation> {
if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
return vec![];
}
let is_filter_row = mouse.row == 0;
if is_filter_row {
return vec![];
}
let Mode::Selector(sel) = &self.mode else {
return vec![];
};
let is_left_pane = mouse.column < 80;
if is_left_pane {
if !sel.focus.is_focused(FOCUS_HISTORY) {
return vec![Operation::CommandSelectorLocal(CommandSelectorOp::SwitchPane)];
}
} else if !sel.focus.is_focused(FOCUS_TREE) {
return vec![Operation::CommandSelectorLocal(CommandSelectorOp::SwitchPane)];
}
vec![]
}
fn handle_operation(&mut self, op: &Operation, _settings: &Settings) -> Option<Event> {
match op {
Operation::Focus(focus_op) => {
if let Mode::Selector(sel) = &mut self.mode {
match focus_op {
FocusOp::Next => sel.focus.focus_next(),
FocusOp::Prev => sel.focus.focus_prev(),
_ => {}
}
}
Some(Event::applied("command_runner", op.clone()))
}
Operation::CommandSelectorLocal(local) => {
match local {
CommandSelectorOp::FilterInput(field_op) => {
if let Mode::Selector(sel) = &mut self.mode {
sel.filter.apply(field_op);
sel.refilter();
}
}
CommandSelectorOp::SwitchPane => {
if let Mode::Selector(sel) = &mut self.mode {
let cur = sel.focus.current();
if cur == FOCUS_HISTORY || cur == FOCUS_FILTER {
sel.focus.set_focus(FOCUS_TREE);
} else {
sel.focus.set_focus(FOCUS_HISTORY);
}
}
}
CommandSelectorOp::CollapseOrLeft => {
if let Mode::Selector(sel) = &mut self.mode
&& sel.active_pane() == FOCUS_TREE
&& sel.filter.is_empty()
{
sel.tree.key_left();
}
}
CommandSelectorOp::ExpandOrRight => {
if let Mode::Selector(sel) = &mut self.mode
&& sel.active_pane() == FOCUS_TREE
&& sel.filter.is_empty()
{
sel.tree.key_right();
}
}
CommandSelectorOp::Confirm => {
if let Mode::Selector(sel) = &self.mode
&& let Some(id) = sel.selected_command()
&& let Some(snap) = self.metas.get(&id)
{
let meta = build_temp_meta(&id, snap);
let form = ArgFormState::new(&meta);
self.mode = Mode::ArgForm(form);
}
}
CommandSelectorOp::ArgNext => {
if let Mode::ArgForm(form) = &mut self.mode
&& form.focused + 1 < form.fields.len()
{
form.focused += 1;
}
}
CommandSelectorOp::ArgPrev => {
if let Mode::ArgForm(form) = &mut self.mode
&& form.focused > 0
{
form.focused -= 1;
}
}
CommandSelectorOp::ArgChanged(s) => {
if let Mode::ArgForm(form) = &mut self.mode
&& let Some(field) = form.fields.get_mut(form.focused)
{
field.value = s.clone();
}
}
CommandSelectorOp::ArgCancel => {
let history_ids: Vec<CommandId> = vec![]; self.mode = Mode::Selector(SelectorState::new(
self.all_commands.clone(),
history_ids,
));
}
}
Some(Event::applied("command_runner", op.clone()))
}
Operation::NavigateUp => {
if let Mode::Selector(sel) = &mut self.mode {
match sel.active_pane() {
FOCUS_HISTORY => sel.history_list.move_up(),
_ => {
if sel.filter.is_empty() {
sel.tree.key_up();
} else {
sel.tree_filtered.move_up();
}
}
}
}
Some(Event::applied("command_runner", op.clone()))
}
Operation::NavigateDown => {
if let Mode::Selector(sel) = &mut self.mode {
match sel.active_pane() {
FOCUS_HISTORY => sel.history_list.move_down(),
_ => {
if sel.filter.is_empty() {
sel.tree.key_down();
} else {
sel.tree_filtered.move_down();
}
}
}
}
Some(Event::applied("command_runner", op.clone()))
}
Operation::NavigatePageUp => {
const PAGE: usize = 10;
if let Mode::Selector(sel) = &mut self.mode {
match sel.active_pane() {
FOCUS_HISTORY => {
for _ in 0..PAGE {
sel.history_list.move_up();
}
}
_ => {
if sel.filter.is_empty() {
for _ in 0..PAGE {
sel.tree.key_up();
}
} else {
for _ in 0..PAGE {
sel.tree_filtered.move_up();
}
}
}
}
}
Some(Event::applied("command_runner", op.clone()))
}
Operation::NavigatePageDown => {
const PAGE: usize = 10;
if let Mode::Selector(sel) = &mut self.mode {
match sel.active_pane() {
FOCUS_HISTORY => {
for _ in 0..PAGE {
sel.history_list.move_down();
}
}
_ => {
if sel.filter.is_empty() {
for _ in 0..PAGE {
sel.tree.key_down();
}
} else {
for _ in 0..PAGE {
sel.tree_filtered.move_down();
}
}
}
}
}
Some(Event::applied("command_runner", op.clone()))
}
_ => None,
}
}
fn render(&self, frame: &mut Frame, area: Rect, theme: &crate::theme::Theme) {
match &self.mode {
Mode::Selector(_) => self.render_selector(frame, area, theme),
Mode::ArgForm(_) => self.render_arg_form(frame, area, theme),
}
}
}
fn handle_key_selector(
sel: &SelectorState,
key: KeyEvent,
runner: &CommandRunner,
) -> Vec<Operation> {
use CommandSelectorOp::*;
let cs = |op: CommandSelectorOp| vec![Operation::CommandSelectorLocal(op)];
match (key.modifiers, key.key) {
(_, Key::Enter) => runner.confirm_selection(),
(_, Key::Tab) => vec![Operation::Focus(FocusOp::Next)],
(_, Key::BackTab) => vec![Operation::Focus(FocusOp::Prev)],
(_, Key::ArrowUp) => vec![Operation::NavigateUp],
(_, Key::ArrowDown) => vec![Operation::NavigateDown],
(_, Key::PageUp) => vec![Operation::NavigatePageUp],
(_, Key::PageDown) => vec![Operation::NavigatePageDown],
(_, Key::ArrowLeft) if sel.active_pane() == FOCUS_TREE && sel.filter.is_empty() => {
cs(CollapseOrLeft)
}
(_, Key::ArrowRight) if sel.active_pane() == FOCUS_TREE && sel.filter.is_empty() => {
cs(ExpandOrRight)
}
_ => {
if let Some(field_op) = crate::widgets::input_field::key_to_op(key) {
vec![Operation::CommandSelectorLocal(CommandSelectorOp::FilterInput(field_op))]
} else {
vec![]
}
}
}
}
fn handle_key_form(form: &ArgFormState, key: KeyEvent) -> Vec<Operation> {
use CommandSelectorOp::*;
let cs = |op: CommandSelectorOp| vec![Operation::CommandSelectorLocal(op)];
match (key.modifiers, key.key) {
(_, Key::Escape) => cs(ArgCancel),
(_, Key::Enter) => {
let args = form.collect_args();
vec![Operation::RunCommand {
id: form.command_id.clone(),
args,
}]
}
(m, Key::Tab) if m.contains(Modifiers::SHIFT) => cs(ArgPrev),
(_, Key::ArrowUp) => cs(ArgPrev),
(_, Key::Tab) | (_, Key::ArrowDown) => cs(ArgNext),
_ => {
match (key.modifiers, key.key) {
(m, Key::Char(c)) if !m.contains(Modifiers::CTRL) && !m.contains(Modifiers::ALT) => {
let mut v = form.fields.get(form.focused).map(|f| f.value.clone()).unwrap_or_default();
v.push(c);
cs(ArgChanged(v))
}
(_, Key::Backspace) => {
let mut v = form.fields.get(form.focused).map(|f| f.value.clone()).unwrap_or_default();
v.pop();
cs(ArgChanged(v))
}
_ => vec![],
}
}
}
}
fn cmd_list_item(
id: &CommandId,
selected: bool,
pane_bg: Color,
sel_bg: Color,
sel_fg: Color,
fg_dim: Color,
) -> ListItem<'static> {
let bg = if selected { sel_bg } else { pane_bg };
ListItem::new(Line::from(vec![
Span::styled(
format!("{}/", id.group),
Style::default().fg(fg_dim).bg(bg),
),
Span::styled(
id.name.to_string(),
Style::default()
.fg(if selected { sel_fg } else { Color::Gray })
.bg(bg)
.add_modifier(if selected { Modifier::BOLD } else { Modifier::empty() }),
),
]))
}
fn build_temp_meta(id: &CommandId, snap: &CommandMetaSnapshot) -> CommandMeta {
use std::borrow::Cow;
CommandMeta {
id: id.clone(),
title: Cow::Owned(snap.title.clone()),
description: Cow::Borrowed(""),
args: snap.args.clone(),
contexts: vec![],
visibility: meta::Visibility::Hidden,
}
}