use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PaletteAction {
Resume,
Reset,
Quit,
Inspect,
}
#[derive(Debug, Clone)]
pub struct PaletteCommand {
pub name: String,
pub description: String,
pub action: PaletteAction,
}
#[derive(Debug)]
pub struct CommandPaletteState {
pub filter: String,
pub selected_index: usize,
pub commands: Vec<PaletteCommand>,
}
impl CommandPaletteState {
pub fn new() -> Self {
CommandPaletteState {
filter: String::new(),
selected_index: 0,
commands: Vec::new(),
}
}
pub fn open(&mut self, current_mode: &super::app::AppMode) {
self.filter.clear();
self.selected_index = 0;
self.commands = build_commands(current_mode);
}
pub fn close(&mut self) {
self.filter.clear();
self.selected_index = 0;
}
pub fn move_up(&mut self) {
if self.selected_index > 0 {
self.selected_index -= 1;
}
}
pub fn move_down(&mut self) {
let filtered = self.filtered_commands();
if self.selected_index + 1 < filtered.len() {
self.selected_index += 1;
}
}
pub fn push_filter(&mut self, c: char) {
self.filter.push(c);
self.selected_index = 0;
}
pub fn pop_filter(&mut self) {
self.filter.pop();
self.selected_index = 0;
}
pub fn filtered_commands(&self) -> Vec<&PaletteCommand> {
if self.filter.is_empty() {
self.commands.iter().collect()
} else {
let lower_filter = self.filter.to_lowercase();
self.commands
.iter()
.filter(|cmd| {
cmd.name.to_lowercase().contains(&lower_filter)
|| cmd.description.to_lowercase().contains(&lower_filter)
})
.collect()
}
}
pub fn selected_action(&self) -> Option<PaletteAction> {
let filtered = self.filtered_commands();
filtered
.get(self.selected_index)
.map(|cmd| cmd.action.clone())
}
}
fn build_commands(mode: &super::app::AppMode) -> Vec<PaletteCommand> {
use super::app::AppMode;
let mut cmds = Vec::new();
match mode {
AppMode::Paused => {
cmds.push(PaletteCommand {
name: "Resume".to_string(),
description: "Resume the paused workflow".to_string(),
action: PaletteAction::Resume,
});
cmds.push(PaletteCommand {
name: "Reset".to_string(),
description: "Reset to last safe boundary".to_string(),
action: PaletteAction::Reset,
});
}
AppMode::Running => {
}
_ => {}
}
cmds.push(PaletteCommand {
name: "Inspect".to_string(),
description: "Inspect the selected step".to_string(),
action: PaletteAction::Inspect,
});
cmds.push(PaletteCommand {
name: "Quit".to_string(),
description: "Quit the TUI".to_string(),
action: PaletteAction::Quit,
});
cmds
}
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}
pub fn draw_command_palette(frame: &mut Frame, palette: &CommandPaletteState) {
let area = centered_rect(50, 40, frame.area());
frame.render_widget(Clear, area);
let block = Block::default()
.title(" Command Palette ")
.borders(Borders::ALL)
.style(Style::default().fg(Color::White).bg(Color::DarkGray));
let inner = block.inner(area);
frame.render_widget(block, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(1)])
.split(inner);
let filter_text = if palette.filter.is_empty() {
"Type to filter...".to_string()
} else {
palette.filter.clone()
};
let filter_style = if palette.filter.is_empty() {
Style::default().fg(Color::DarkGray)
} else {
Style::default().fg(Color::White)
};
let filter_widget = Paragraph::new(filter_text).style(filter_style).block(
Block::default()
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(Color::Gray)),
);
frame.render_widget(filter_widget, chunks[0]);
let filtered = palette.filtered_commands();
let items: Vec<ListItem> = filtered
.iter()
.enumerate()
.map(|(i, cmd)| {
let style = if i == palette.selected_index {
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let line = Line::from(vec![
Span::styled(&cmd.name, style.add_modifier(Modifier::BOLD)),
Span::styled(format!(" {}", cmd.description), style),
]);
ListItem::new(line)
})
.collect();
let list = List::new(items);
frame.render_widget(list, chunks[1]);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::app::AppMode;
#[test]
fn test_palette_commands_when_running() {
let cmds = build_commands(&AppMode::Running);
assert_eq!(cmds.len(), 2);
assert_eq!(cmds[0].action, PaletteAction::Inspect);
assert_eq!(cmds[1].action, PaletteAction::Quit);
}
#[test]
fn test_palette_commands_when_paused() {
let cmds = build_commands(&AppMode::Paused);
assert_eq!(cmds.len(), 4);
assert_eq!(cmds[0].action, PaletteAction::Resume);
assert_eq!(cmds[1].action, PaletteAction::Reset);
}
#[test]
fn test_filter() {
let mut state = CommandPaletteState::new();
state.open(&AppMode::Running);
assert_eq!(state.filtered_commands().len(), 2);
state.push_filter('q');
let filtered = state.filtered_commands();
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].action, PaletteAction::Quit);
}
#[test]
fn test_navigation() {
let mut state = CommandPaletteState::new();
state.open(&AppMode::Paused);
assert_eq!(state.selected_index, 0);
state.move_down();
assert_eq!(state.selected_index, 1);
state.move_up();
assert_eq!(state.selected_index, 0);
state.move_up();
assert_eq!(state.selected_index, 0);
}
}