use ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph}
};
use crate::tui::themes::Palette;
pub struct Command {
pub id: String,
pub title: String,
pub hint: String
}
pub struct CommandPalette {
pub query: String,
commands: Vec<Command>,
filtered: Vec<usize>,
pub selected: usize
}
impl CommandPalette {
#[must_use]
pub fn new(commands: Vec<Command>) -> Self {
let filtered = (0..commands.len()).collect();
Self {
query: String::new(),
commands,
filtered,
selected: 0
}
}
#[allow(dead_code)]
pub fn set_commands(&mut self, commands: Vec<Command>) {
self.commands = commands;
self.query.clear();
self.selected = 0;
self.refilter();
}
pub fn push_char(&mut self, c: char) {
self.query.push(c);
self.refilter();
self.clamp_selected();
}
pub fn backspace(&mut self) {
self.query.pop();
self.refilter();
self.clamp_selected();
}
pub const fn next(&mut self) {
if self.filtered.is_empty() {
self.selected = 0;
return;
}
if self.selected + 1 < self.filtered.len() {
self.selected += 1;
}
}
pub const fn previous(&mut self) {
if self.filtered.is_empty() {
self.selected = 0;
return;
}
self.selected = self.selected.saturating_sub(1);
}
#[must_use]
pub fn selected_command(&self) -> Option<&Command> {
self.filtered
.get(self.selected)
.and_then(|&idx| self.commands.get(idx))
}
#[must_use]
pub fn filtered_commands(&self) -> Vec<&Command> {
self.filtered
.iter()
.filter_map(|&idx| self.commands.get(idx))
.collect()
}
fn refilter(&mut self) {
if self.query.is_empty() {
self.filtered = (0..self.commands.len()).collect();
return;
}
let needle = self.query.to_lowercase();
self.filtered = self
.commands
.iter()
.enumerate()
.filter(|(_, cmd)| {
is_subsequence(&needle, &cmd.title.to_lowercase())
|| is_subsequence(&needle, &cmd.hint.to_lowercase())
})
.map(|(idx, _)| idx)
.collect();
}
const fn clamp_selected(&mut self) {
if self.filtered.is_empty() {
self.selected = 0;
} else if self.selected >= self.filtered.len() {
self.selected = self.filtered.len() - 1;
}
}
}
fn is_subsequence(needle: &str, haystack: &str) -> bool {
let mut chars = haystack.chars();
for nc in needle.chars() {
loop {
match chars.next() {
Some(hc) if hc == nc => break,
Some(_) => {}
None => return false
}
}
}
true
}
pub fn render(frame: &mut Frame, area: Rect, palette: &Palette, cp: &CommandPalette) {
let items = cp.filtered_commands();
let width = 60u16.min(area.width.saturating_sub(4)).max(1);
let max_height = area.height.saturating_sub(4).max(3);
let item_count = u16::try_from(items.len()).unwrap_or(u16::MAX);
let height = item_count.saturating_add(3).min(max_height).max(3);
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
let modal = Rect {
x,
y,
width,
height
};
frame.render_widget(Clear, modal);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(Span::styled(
" Command ",
Style::default()
.fg(palette.accent)
.add_modifier(Modifier::BOLD)
))
.border_style(Style::default().fg(palette.accent))
.style(Style::default().bg(palette.bg));
let inner = block.inner(modal);
frame.render_widget(block, modal);
if inner.width == 0 || inner.height == 0 {
return;
}
let query_area = Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: 1
};
let query_line = Line::from(vec![
Span::styled("> ", Style::default().fg(palette.accent)),
Span::styled(cp.query.clone(), Style::default().fg(palette.fg)),
Span::styled("█", Style::default().fg(palette.accent)),
]);
frame.render_widget(Paragraph::new(query_line), query_area);
let list_height = inner.height.saturating_sub(1);
if list_height == 0 {
return;
}
let list_area = Rect {
x: inner.x,
y: inner.y + 1,
width: inner.width,
height: list_height
};
let list_items: Vec<ListItem> = items
.iter()
.enumerate()
.map(|(idx, cmd)| {
let is_selected = idx == cp.selected;
build_item_line(cmd, is_selected, inner.width, palette)
})
.collect();
let mut state = ListState::default();
if items.is_empty() {
state.select(None);
} else {
state.select(Some(cp.selected.min(items.len() - 1)));
}
let list = List::new(list_items).highlight_style(Style::default());
frame.render_stateful_widget(list, list_area, &mut state);
}
fn build_item_line<'a>(
cmd: &'a Command,
is_selected: bool,
width: u16,
palette: &Palette
) -> ListItem<'a> {
let marker = if is_selected { "▶ " } else { " " };
let title_color = if is_selected {
palette.accent
} else {
palette.fg
};
let mut title_style = Style::default().fg(title_color);
if is_selected {
title_style = title_style.add_modifier(Modifier::BOLD);
}
let used = marker.chars().count() + cmd.title.chars().count() + cmd.hint.chars().count();
let total = width as usize;
let pad = total.saturating_sub(used).max(1);
let line = Line::from(vec![
Span::styled(marker, Style::default().fg(palette.accent)),
Span::styled(cmd.title.as_str(), title_style),
Span::raw(" ".repeat(pad)),
Span::styled(cmd.hint.as_str(), Style::default().fg(palette.dim)),
]);
let mut item = ListItem::new(line);
if is_selected {
item = item.style(Style::default().bg(selected_row_bg(palette)));
}
item
}
const fn selected_row_bg(palette: &Palette) -> Color {
palette.border
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Vec<Command> {
vec![
Command {
id: "refresh".to_string(),
title: "Refresh Data".to_string(),
hint: "r".to_string()
},
Command {
id: "quit".to_string(),
title: "Quit Application".to_string(),
hint: "Q".to_string()
},
Command {
id: "theme".to_string(),
title: "Switch Theme".to_string(),
hint: "t".to_string()
},
]
}
#[test]
fn new_filters_all() {
let cp = CommandPalette::new(sample());
assert_eq!(cp.filtered_commands().len(), 3);
assert_eq!(cp.selected, 0);
assert!(cp.query.is_empty());
}
#[test]
fn push_char_filters_subsequence() {
let mut cp = CommandPalette::new(sample());
cp.push_char('q');
let titles: Vec<&str> = cp
.filtered_commands()
.iter()
.map(|c| c.title.as_str())
.collect();
assert_eq!(titles, vec!["Quit Application"]);
}
#[test]
fn push_char_subsequence_non_contiguous() {
let mut cp = CommandPalette::new(sample());
cp.push_char('r');
cp.push_char('d');
let titles: Vec<&str> = cp
.filtered_commands()
.iter()
.map(|c| c.title.as_str())
.collect();
assert_eq!(titles, vec!["Refresh Data"]);
}
#[test]
fn matches_on_hint() {
let mut cp = CommandPalette::new(sample());
cp.push_char('Q');
assert_eq!(cp.filtered_commands().len(), 1);
assert_eq!(cp.selected_command().unwrap().id, "quit");
}
#[test]
fn backspace_restores() {
let mut cp = CommandPalette::new(sample());
cp.push_char('q');
assert_eq!(cp.filtered_commands().len(), 1);
cp.backspace();
assert_eq!(cp.filtered_commands().len(), 3);
}
#[test]
fn next_clamps_at_the_end() {
let mut cp = CommandPalette::new(sample());
cp.next();
assert_eq!(cp.selected, 1);
cp.next();
assert_eq!(cp.selected, 2);
cp.next();
assert_eq!(cp.selected, 2);
}
#[test]
fn previous_clamps_at_the_top() {
let mut cp = CommandPalette::new(sample());
cp.previous();
assert_eq!(cp.selected, 0);
cp.next();
cp.previous();
assert_eq!(cp.selected, 0);
}
#[test]
fn selected_command_correct() {
let mut cp = CommandPalette::new(sample());
cp.next();
assert_eq!(cp.selected_command().unwrap().id, "quit");
}
#[test]
fn empty_query_shows_all() {
let mut cp = CommandPalette::new(sample());
cp.push_char('z');
assert_eq!(cp.filtered_commands().len(), 0);
cp.backspace();
assert_eq!(cp.filtered_commands().len(), 3);
}
#[test]
fn selected_clamped_after_refilter() {
let mut cp = CommandPalette::new(sample());
cp.selected = 2;
cp.push_char('q');
assert_eq!(cp.selected, 0);
assert_eq!(cp.selected_command().unwrap().id, "quit");
}
#[test]
fn no_match_has_no_selected_command() {
let mut cp = CommandPalette::new(sample());
cp.push_char('z');
assert!(cp.selected_command().is_none());
}
#[test]
fn set_commands_resets() {
let mut cp = CommandPalette::new(sample());
cp.push_char('q');
cp.set_commands(vec![Command {
id: "only".to_string(),
title: "Only One".to_string(),
hint: "o".to_string()
}]);
assert!(cp.query.is_empty());
assert_eq!(cp.selected, 0);
assert_eq!(cp.filtered_commands().len(), 1);
}
#[test]
fn case_insensitive() {
let mut cp = CommandPalette::new(sample());
cp.push_char('S');
cp.push_char('W');
assert_eq!(cp.selected_command().unwrap().id, "theme");
}
}