use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ActionCategory {
Navigation,
Search,
Settings,
Debug,
Custom(String),
}
impl std::fmt::Display for ActionCategory {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Navigation => write!(f, "Navigation"),
Self::Search => write!(f, "Search"),
Self::Settings => write!(f, "Settings"),
Self::Debug => write!(f, "Debug"),
Self::Custom(name) => write!(f, "{name}"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Action {
pub id: String,
pub label: String,
pub description: Option<String>,
pub category: ActionCategory,
pub shortcut: Option<String>,
}
impl Action {
#[must_use]
pub fn new(id: impl Into<String>, label: impl Into<String>, category: ActionCategory) -> Self {
Self {
id: id.into(),
label: label.into(),
description: None,
category,
shortcut: None,
}
}
#[must_use]
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
self.description = Some(desc.into());
self
}
#[must_use]
pub fn with_shortcut(mut self, shortcut: impl Into<String>) -> Self {
self.shortcut = Some(shortcut.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PaletteState {
Closed,
Open,
}
pub struct CommandPalette {
actions: Vec<Action>,
query: String,
selected: usize,
state: PaletteState,
}
impl CommandPalette {
#[must_use]
pub const fn new() -> Self {
Self {
actions: Vec::new(),
query: String::new(),
selected: 0,
state: PaletteState::Closed,
}
}
pub fn register(&mut self, action: Action) {
self.actions.push(action);
}
pub fn open(&mut self) {
self.state = PaletteState::Open;
self.query.clear();
self.selected = 0;
}
pub fn close(&mut self) {
self.state = PaletteState::Closed;
self.query.clear();
self.selected = 0;
}
pub fn toggle(&mut self) {
match self.state {
PaletteState::Closed => self.open(),
PaletteState::Open => self.close(),
}
}
#[must_use]
pub const fn state(&self) -> &PaletteState {
&self.state
}
#[must_use]
pub fn query(&self) -> &str {
&self.query
}
pub fn push_char(&mut self, ch: char) {
self.query.push(ch);
self.selected = 0;
}
pub fn pop_char(&mut self) {
self.query.pop();
self.selected = 0;
}
#[must_use]
pub fn filtered(&self) -> Vec<&Action> {
if self.query.is_empty() {
return self.actions.iter().collect();
}
let query_lower = self.query.to_lowercase();
self.actions
.iter()
.filter(|a| {
a.label.to_lowercase().contains(&query_lower)
|| a.id.to_lowercase().contains(&query_lower)
|| a.description
.as_ref()
.is_some_and(|d| d.to_lowercase().contains(&query_lower))
})
.collect()
}
#[must_use]
pub const fn selected(&self) -> usize {
self.selected
}
pub fn select_prev(&mut self) {
let count = self.filtered().len();
if count > 0 {
self.selected = if self.selected == 0 {
count - 1
} else {
self.selected - 1
};
}
}
pub fn select_next(&mut self) {
let count = self.filtered().len();
if count > 0 {
self.selected = (self.selected + 1) % count;
}
}
#[must_use]
pub fn confirm(&self) -> Option<String> {
let filtered = self.filtered();
filtered.get(self.selected).map(|a| a.id.clone())
}
#[must_use]
pub const fn len(&self) -> usize {
self.actions.len()
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.actions.is_empty()
}
}
impl Default for CommandPalette {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_palette() -> CommandPalette {
let mut palette = CommandPalette::new();
palette.register(
Action::new("nav.search", "Go to Search", ActionCategory::Navigation)
.with_shortcut("Ctrl+1"),
);
palette.register(
Action::new("nav.index", "Go to Indexing", ActionCategory::Navigation)
.with_shortcut("Ctrl+2"),
);
palette.register(
Action::new("debug.logs", "Show Logs", ActionCategory::Debug)
.with_description("Display structured log output"),
);
palette.register(Action::new(
"settings.theme",
"Change Theme",
ActionCategory::Settings,
));
palette
}
#[test]
fn palette_starts_closed() {
let palette = CommandPalette::new();
assert_eq!(palette.state(), &PaletteState::Closed);
assert!(palette.is_empty());
}
#[test]
fn palette_register_and_count() {
let palette = sample_palette();
assert_eq!(palette.len(), 4);
}
#[test]
fn palette_toggle() {
let mut palette = sample_palette();
assert_eq!(palette.state(), &PaletteState::Closed);
palette.toggle();
assert_eq!(palette.state(), &PaletteState::Open);
palette.toggle();
assert_eq!(palette.state(), &PaletteState::Closed);
}
#[test]
fn palette_filter_empty_query() {
let palette = sample_palette();
assert_eq!(palette.filtered().len(), 4);
}
#[test]
fn palette_filter_by_label() {
let mut palette = sample_palette();
palette.open();
palette.push_char('s');
palette.push_char('e');
palette.push_char('a');
let results = palette.filtered();
assert!(results.iter().any(|a| a.id == "nav.search"));
}
#[test]
fn palette_filter_by_description() {
let mut palette = sample_palette();
palette.open();
for ch in "structured".chars() {
palette.push_char(ch);
}
let results = palette.filtered();
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, "debug.logs");
}
#[test]
fn palette_navigation() {
let mut palette = sample_palette();
palette.open();
assert_eq!(palette.selected(), 0);
palette.select_next();
assert_eq!(palette.selected(), 1);
palette.select_prev();
assert_eq!(palette.selected(), 0);
palette.select_prev();
assert_eq!(palette.selected(), 3);
}
#[test]
fn palette_confirm() {
let mut palette = sample_palette();
palette.open();
let id = palette.confirm();
assert_eq!(id, Some("nav.search".to_string()));
}
#[test]
fn palette_pop_char() {
let mut palette = sample_palette();
palette.open();
palette.push_char('x');
palette.push_char('y');
assert_eq!(palette.query(), "xy");
palette.pop_char();
assert_eq!(palette.query(), "x");
}
#[test]
fn action_serde_roundtrip() {
let action = Action::new("test.action", "Test Action", ActionCategory::Debug)
.with_description("A test action")
.with_shortcut("Ctrl+T");
let json = serde_json::to_string(&action).unwrap();
let decoded: Action = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.id, action.id);
assert_eq!(decoded.label, action.label);
assert_eq!(decoded.description, action.description);
assert_eq!(decoded.shortcut, action.shortcut);
}
#[test]
fn action_category_display() {
assert_eq!(ActionCategory::Navigation.to_string(), "Navigation");
assert_eq!(ActionCategory::Custom("Foo".to_string()).to_string(), "Foo");
}
}