mod layout;
mod render;
use layout::{layout_rows, selected_index};
use render::render_body;
use std::io;
use crossterm::event::KeyCode;
use ratatui::Frame;
use super::{
chrome,
filter_list::FilterList,
layout::{centered_rect, fit},
modal::ModalSignal,
overlay::{self, PopupFlow, popup_with_paste},
terminal::Tui,
};
use crate::theme::Skin;
pub struct CommandItem<'a> {
pub label: &'a str,
pub category: &'a str,
pub key_hint: &'a str,
pub enabled: bool,
}
enum Row<'a> {
Header(&'a str),
Item {
item: &'a CommandItem<'a>,
index: 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 = FilterList::new();
popup_with_paste(
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: &FilterList| {
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,
}
}
_ => {
let layout = layout_rows(items, &state.query);
state.handle_key(
key,
layout.selectable.len(),
&layout.section_starts,
);
PopupFlow::Continue
}
},
|state, text| {
state
.query
.extend(text.chars().filter(|ch| !ch.is_control()));
state.cursor = 0;
PopupFlow::Continue
},
)
}
#[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);
}
}