use crossterm::{
event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
Frame, Terminal,
backend::CrosstermBackend,
layout::{Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
};
use std::io;
#[derive(Debug, Clone)]
pub struct SelectableTask {
pub name: String,
pub description: Option<String>,
}
#[derive(Debug)]
pub enum PickerResult {
Selected(String),
Cancelled,
}
struct TaskPicker {
tasks: Vec<SelectableTask>,
filter: String,
filtered_indices: Vec<usize>,
list_state: ListState,
}
impl TaskPicker {
fn new(tasks: Vec<SelectableTask>) -> Self {
let filtered_indices: Vec<usize> = (0..tasks.len()).collect();
let mut list_state = ListState::default();
if !filtered_indices.is_empty() {
list_state.select(Some(0));
}
Self {
tasks,
filter: String::new(),
filtered_indices,
list_state,
}
}
fn update_filter(&mut self) {
let filter_lower = self.filter.to_lowercase();
self.filtered_indices = self
.tasks
.iter()
.enumerate()
.filter(|(_, task)| {
if filter_lower.is_empty() {
return true;
}
let name_match = task.name.to_lowercase().contains(&filter_lower);
let desc_match = task
.description
.as_ref()
.is_some_and(|d| d.to_lowercase().contains(&filter_lower));
name_match || desc_match
})
.map(|(i, _)| i)
.collect();
if self.filtered_indices.is_empty() {
self.list_state.select(None);
} else {
self.list_state.select(Some(0));
}
}
fn select_previous(&mut self) {
if self.filtered_indices.is_empty() {
return;
}
let i = match self.list_state.selected() {
Some(i) => {
if i == 0 {
self.filtered_indices.len() - 1
} else {
i - 1
}
}
None => 0,
};
self.list_state.select(Some(i));
}
fn select_next(&mut self) {
if self.filtered_indices.is_empty() {
return;
}
let i = match self.list_state.selected() {
Some(i) => {
if i >= self.filtered_indices.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.list_state.select(Some(i));
}
fn selected_task(&self) -> Option<String> {
self.list_state.selected().and_then(|i| {
self.filtered_indices
.get(i)
.map(|&idx| self.tasks[idx].name.clone())
})
}
}
pub fn run_picker(tasks: Vec<SelectableTask>) -> io::Result<PickerResult> {
if tasks.is_empty() {
return Ok(PickerResult::Cancelled);
}
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut picker = TaskPicker::new(tasks);
let result = run_event_loop(&mut terminal, &mut picker);
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
result
}
fn run_event_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
picker: &mut TaskPicker,
) -> io::Result<PickerResult> {
loop {
terminal.draw(|f| draw_ui(f, picker))?;
if event::poll(std::time::Duration::from_millis(100))?
&& let Event::Key(key) = event::read()?
{
if key.kind != KeyEventKind::Press {
continue;
}
match key.code {
KeyCode::Esc => return Ok(PickerResult::Cancelled),
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
return Ok(PickerResult::Cancelled);
}
KeyCode::Enter => {
if let Some(task) = picker.selected_task() {
return Ok(PickerResult::Selected(task));
}
}
KeyCode::Up | KeyCode::Char('k') => picker.select_previous(),
KeyCode::Down | KeyCode::Char('j') => picker.select_next(),
KeyCode::Char(c) => {
picker.filter.push(c);
picker.update_filter();
}
KeyCode::Backspace => {
picker.filter.pop();
picker.update_filter();
}
_ => {}
}
}
}
}
fn draw_ui(f: &mut Frame, picker: &mut TaskPicker) {
let area = f.area();
let chunks = Layout::vertical([
Constraint::Length(3), Constraint::Min(5), Constraint::Length(1), ])
.split(area);
draw_filter_input(f, picker, chunks[0]);
draw_task_list(f, picker, chunks[1]);
draw_help_footer(f, chunks[2]);
}
fn draw_filter_input(f: &mut Frame, picker: &TaskPicker, area: Rect) {
let filter_text = if picker.filter.is_empty() {
"Type to filter...".to_string()
} else {
picker.filter.clone()
};
let style = if picker.filter.is_empty() {
Style::default().fg(Color::DarkGray)
} else {
Style::default().fg(Color::Cyan)
};
let input = Paragraph::new(filter_text).style(style).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray))
.title(Span::styled(
" Select a task ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)),
);
f.render_widget(input, area);
let filter_len = u16::try_from(picker.filter.len()).unwrap_or(u16::MAX);
let cursor_x = area.x + 1 + filter_len;
let cursor_y = area.y + 1;
f.set_cursor_position((cursor_x.min(area.x + area.width - 2), cursor_y));
}
fn draw_task_list(f: &mut Frame, picker: &mut TaskPicker, area: Rect) {
let items: Vec<ListItem> = picker
.filtered_indices
.iter()
.map(|&idx| {
let task = &picker.tasks[idx];
let mut spans = vec![
Span::styled("● ", Style::default().fg(Color::Cyan)),
Span::styled(&task.name, Style::default().fg(Color::Cyan)),
];
if let Some(desc) = &task.description {
let padding = 30_usize.saturating_sub(task.name.len());
spans.push(Span::raw(" ".repeat(padding)));
spans.push(Span::styled(desc, Style::default().fg(Color::DarkGray)));
}
ListItem::new(Line::from(spans))
})
.collect();
let list = List::new(items)
.block(
Block::default()
.borders(Borders::LEFT | Borders::RIGHT)
.border_style(Style::default().fg(Color::DarkGray)),
)
.highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("> ");
f.render_stateful_widget(list, area, &mut picker.list_state);
}
fn draw_help_footer(f: &mut Frame, area: Rect) {
let help = Line::from(vec![
Span::styled("↑/↓", Style::default().fg(Color::Cyan)),
Span::raw(" navigate │ "),
Span::styled("enter", Style::default().fg(Color::Cyan)),
Span::raw(" select │ "),
Span::styled("esc", Style::default().fg(Color::Cyan)),
Span::raw(" cancel │ type to filter"),
]);
let footer = Paragraph::new(help)
.style(Style::default().fg(Color::DarkGray))
.centered();
f.render_widget(footer, area);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_task_picker_filter() {
let tasks = vec![
SelectableTask {
name: "build".to_string(),
description: Some("Build the project".to_string()),
},
SelectableTask {
name: "test".to_string(),
description: Some("Run tests".to_string()),
},
SelectableTask {
name: "bun.install".to_string(),
description: Some("Install bun dependencies".to_string()),
},
];
let mut picker = TaskPicker::new(tasks);
assert_eq!(picker.filtered_indices.len(), 3);
picker.filter = "bun".to_string();
picker.update_filter();
assert_eq!(picker.filtered_indices.len(), 1);
assert_eq!(picker.filtered_indices[0], 2);
}
#[test]
fn test_task_picker_navigation() {
let tasks = vec![
SelectableTask {
name: "a".to_string(),
description: None,
},
SelectableTask {
name: "b".to_string(),
description: None,
},
SelectableTask {
name: "c".to_string(),
description: None,
},
];
let mut picker = TaskPicker::new(tasks);
assert_eq!(picker.list_state.selected(), Some(0));
picker.select_next();
assert_eq!(picker.list_state.selected(), Some(1));
picker.select_next();
assert_eq!(picker.list_state.selected(), Some(2));
picker.select_next();
assert_eq!(picker.list_state.selected(), Some(0));
picker.select_previous();
assert_eq!(picker.list_state.selected(), Some(2));
}
}