use std::{cell::Cell, io};
use crossterm::event::KeyCode;
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::Modifier,
text::{Line, Span},
widgets::Paragraph,
};
use super::{
chrome, fuzzy, input,
layout::{centered_rect, fit},
list,
modal::ModalSignal,
nav,
overlay::{self, PopupFlow, popup},
shortcut_hints, style,
terminal::Tui,
};
use crate::theme::{Palette, Skin};
const CATEGORY_WIDTH: usize = 12;
const SEARCH_LABEL: &str = "search ";
const LABEL_WIDTH: usize = 16;
pub struct CommandItem<'a> {
pub label: &'a str,
pub category: &'a str,
pub key_hint: &'a str,
pub enabled: bool,
}
struct PaletteState {
query: String,
cursor: usize,
offset: Cell<usize>,
}
enum Row<'a> {
Header(&'a str),
Item {
item: &'a CommandItem<'a>,
index: usize,
},
}
struct RowLayout<'a> {
rows: Vec<Row<'a>>,
selectable: Vec<usize>,
section_starts: Vec<usize>,
}
pub fn command_palette(
tui: &mut Tui,
skin: &Skin,
title: &str,
items: &[CommandItem<'_>],
render_bg: impl Fn(&mut Frame),
) -> io::Result<ModalSignal<usize>> {
if items.is_empty() {
return Ok(ModalSignal::Cancelled);
}
let mut state = PaletteState {
query: String::new(),
cursor: 0,
offset: Cell::new(0),
};
popup(
tui,
&mut state,
|area, _| {
centered_rect(
fit(area.width * 2 / 3, 40, area.width),
fit(area.height * 2 / 3, 8, area.height),
area,
)
},
|frame, _| render_bg(frame),
|frame, rect, state: &PaletteState| {
let inner = overlay::framed(frame, rect, skin, title);
render_body(frame, inner, skin, items, state);
let count = layout_rows(items, &state.query).selectable.len();
let cursor = state.cursor.min(count.saturating_sub(1));
let badge = chrome::position_badge(cursor, count);
chrome::render_badge(frame, rect, skin, &badge);
},
|state, key| match key.code {
KeyCode::Esc => PopupFlow::Cancelled,
KeyCode::Enter => {
let layout = layout_rows(items, &state.query);
match selected_index(&layout, state.cursor) {
Some(index) => PopupFlow::Done(index),
None => PopupFlow::Continue,
}
}
KeyCode::Up => {
let count = layout_rows(items, &state.query).selectable.len();
state.cursor = nav::cycle(state.cursor, count, -1);
PopupFlow::Continue
}
KeyCode::Down => {
let count = layout_rows(items, &state.query).selectable.len();
state.cursor = nav::cycle(state.cursor, count, 1);
PopupFlow::Continue
}
KeyCode::Tab => {
jump_section(state, items, 1);
PopupFlow::Continue
}
KeyCode::BackTab => {
jump_section(state, items, -1);
PopupFlow::Continue
}
KeyCode::Backspace => {
state.query.pop();
state.cursor = 0;
PopupFlow::Continue
}
KeyCode::Char(ch) => {
state.query.push(ch);
state.cursor = 0;
PopupFlow::Continue
}
_ => PopupFlow::Continue,
},
)
}
fn selected_index(layout: &RowLayout, cursor: usize) -> Option<usize> {
let row = *layout.selectable.get(cursor)?;
match layout.rows[row] {
Row::Item { index, .. } => Some(index),
Row::Header(_) => None,
}
}
fn jump_section(
state: &mut PaletteState,
items: &[CommandItem<'_>],
direction: isize,
) {
let starts = layout_rows(items, &state.query).section_starts;
if starts.is_empty() {
return;
}
let current = starts
.iter()
.rposition(|&start| start <= state.cursor)
.unwrap_or(0);
let next = nav::cycle(current, starts.len(), direction);
state.cursor = starts[next];
}
fn layout_rows<'a>(items: &'a [CommandItem<'a>], query: &str) -> RowLayout<'a> {
if query.trim().is_empty() {
grouped_rows(items)
} else {
ranked_rows(items, query.trim())
}
}
fn grouped_rows<'a>(items: &'a [CommandItem<'a>]) -> RowLayout<'a> {
let mut rows: Vec<Row<'a>> = Vec::new();
let mut selectable: Vec<usize> = Vec::new();
let mut section_starts: Vec<usize> = Vec::new();
let mut current_category: Option<&str> = None;
let mut section_has_selectable = false;
for (index, item) in items.iter().enumerate() {
if current_category != Some(item.category) {
rows.push(Row::Header(item.category));
current_category = Some(item.category);
section_has_selectable = false;
}
if item.enabled {
if !section_has_selectable {
section_starts.push(selectable.len());
section_has_selectable = true;
}
selectable.push(rows.len());
}
rows.push(Row::Item { item, index });
}
RowLayout {
rows,
selectable,
section_starts,
}
}
fn ranked_rows<'a>(items: &'a [CommandItem<'a>], query: &str) -> RowLayout<'a> {
let mut scored: Vec<(u32, usize)> = items
.iter()
.enumerate()
.filter_map(|(index, item)| {
let haystack = format!("{} {}", item.category, item.label);
fuzzy::score(&haystack, query).map(|score| (score, index))
})
.collect();
scored.sort_by(|left, right| right.0.cmp(&left.0));
let mut rows: Vec<Row<'a>> = Vec::new();
let mut selectable: Vec<usize> = Vec::new();
for (_, index) in scored {
let item = &items[index];
if item.enabled {
selectable.push(rows.len());
}
rows.push(Row::Item { item, index });
}
RowLayout {
rows,
selectable,
section_starts: Vec::new(),
}
}
fn render_body(
frame: &mut Frame,
inner: Rect,
skin: &Skin,
items: &[CommandItem<'_>],
state: &PaletteState,
) {
let palette = &skin.palette;
let grouped = state.query.trim().is_empty();
let layout = layout_rows(items, &state.query);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(1),
Constraint::Length(shortcut_hints::footer_height(1)),
])
.split(inner);
let mut search =
vec![Span::styled(SEARCH_LABEL, style::secondary(palette))];
search.extend(input::query_spans(
&state.query,
palette,
(rows[0].width as usize).saturating_sub(SEARCH_LABEL.len()),
));
frame.render_widget(Paragraph::new(Line::from(search)), rows[0]);
let header_style =
style::fg(palette.accent_dim).add_modifier(Modifier::BOLD);
let entries: Vec<Line<'static>> = layout
.rows
.iter()
.map(|row| match row {
Row::Header(title) => {
Line::from(Span::styled(title.to_uppercase(), header_style))
}
Row::Item { item, .. } => {
item_line(item, &state.query, palette, grouped)
}
})
.collect();
let selected = layout
.selectable
.get(state.cursor.min(layout.selectable.len().saturating_sub(1)))
.copied()
.unwrap_or(0);
list::render(
frame,
rows[1],
skin,
list::ListView {
rows: entries,
selected,
offset: &state.offset,
},
);
let hint = footer_hint(skin, rows[2].width as usize, grouped);
frame.render_widget(Paragraph::new(hint), rows[2]);
}
fn item_line(
item: &CommandItem<'_>,
query: &str,
palette: &Palette,
grouped: bool,
) -> Line<'static> {
let mut spans = vec![Span::raw(" ")];
if !grouped {
spans.push(Span::styled(
format!("{:<CATEGORY_WIDTH$}", item.category),
style::secondary(palette),
));
}
let label = format!("{:<LABEL_WIDTH$}", item.label);
if item.enabled {
spans.extend(fuzzy::highlight(
&label,
query,
style::secondary(palette),
palette,
));
spans.push(Span::styled(
item.key_hint.to_string(),
style::fg(palette.accent).add_modifier(Modifier::BOLD),
));
} else {
spans.push(Span::styled(label, style::secondary(palette)));
spans.push(Span::styled(
item.key_hint.to_string(),
style::secondary(palette),
));
}
Line::from(spans)
}
fn footer_hint(skin: &Skin, width: usize, grouped: bool) -> Line<'static> {
let mut hints: Vec<(&str, &str)> =
vec![("\u{2191}\u{2193}", "move"), ("enter", "run")];
if grouped {
hints.push(("tab", "section"));
}
hints.push(("esc", "close"));
shortcut_hints::lines(&hints, skin.palette.accent_dim, width)
.into_iter()
.next()
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
fn items() -> Vec<CommandItem<'static>> {
vec![
CommandItem {
label: "add task",
category: "Tasks",
key_hint: "a",
enabled: true,
},
CommandItem {
label: "delete",
category: "Tasks",
key_hint: "d",
enabled: false,
},
CommandItem {
label: "view summary",
category: "Views",
key_hint: "2",
enabled: true,
},
]
}
#[test]
fn empty_query_groups_and_lists_only_enabled_as_selectable() {
let items = items();
let layout = layout_rows(&items, "");
assert_eq!(layout.rows.len(), 5);
assert_eq!(layout.selectable, vec![1, 4]);
assert_eq!(layout.section_starts, vec![0, 1]);
}
#[test]
fn disabled_item_is_rendered_but_not_selectable() {
let items = items();
let layout = layout_rows(&items, "");
assert!(matches!(layout.rows[2], Row::Item { index: 1, .. }));
assert!(!layout.selectable.contains(&2));
}
#[test]
fn query_flattens_ranks_and_drops_headers() {
let items = items();
let layout = layout_rows(&items, "task");
assert!(layout.rows.iter().all(|r| matches!(r, Row::Item { .. })));
assert!(layout.section_starts.is_empty());
assert_eq!(layout.selectable.len(), 1);
assert_eq!(selected_index(&layout, 0), Some(0));
}
#[test]
fn non_matching_query_leaves_nothing_selectable() {
let items = items();
let layout = layout_rows(&items, "zzzzz");
assert!(layout.selectable.is_empty());
assert_eq!(selected_index(&layout, 0), None);
}
}