use std::collections::HashMap;
use anyhow::{Result, anyhow};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct PaletteCommandId(String);
impl PaletteCommandId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl<T: Into<String>> From<T> for PaletteCommandId {
fn from(value: T) -> Self {
Self(value.into())
}
}
impl std::fmt::Display for PaletteCommandId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommandDescriptor {
pub id: PaletteCommandId,
pub label: String,
pub category: String,
pub keybinding: Option<String>,
pub icon: Option<String>,
}
#[derive(Debug, Default)]
pub struct CommandPalette {
commands: HashMap<PaletteCommandId, CommandDescriptor>,
}
impl CommandPalette {
pub fn new() -> Self {
Self::default()
}
pub fn register(&mut self, descriptor: CommandDescriptor) -> Result<()> {
if self.commands.contains_key(&descriptor.id) {
return Err(anyhow!("command '{}' is already registered", descriptor.id));
}
self.commands.insert(descriptor.id.clone(), descriptor);
Ok(())
}
pub fn unregister(&mut self, id: &PaletteCommandId) -> Result<CommandDescriptor> {
self.commands
.remove(id)
.ok_or_else(|| anyhow!("command '{}' is not registered", id))
}
pub fn get(&self, id: &PaletteCommandId) -> Option<&CommandDescriptor> {
self.commands.get(id)
}
pub fn search(&self, query: &str) -> Vec<&CommandDescriptor> {
let query_lower = query.to_lowercase();
self.commands
.values()
.filter(|descriptor| {
descriptor.label.to_lowercase().contains(&query_lower)
|| descriptor.category.to_lowercase().contains(&query_lower)
})
.collect()
}
pub fn commands(&self) -> Vec<&CommandDescriptor> {
self.commands.values().collect()
}
pub fn commands_in_category(&self, category: &str) -> Vec<&CommandDescriptor> {
let category_lower = category.to_lowercase();
self.commands
.values()
.filter(|descriptor| descriptor.category.to_lowercase() == category_lower)
.collect()
}
pub fn len(&self) -> usize {
self.commands.len()
}
pub fn is_empty(&self) -> bool {
self.commands.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_descriptor(id: &str, label: &str, category: &str) -> CommandDescriptor {
CommandDescriptor {
id: PaletteCommandId::new(id),
label: label.to_string(),
category: category.to_string(),
keybinding: None,
icon: None,
}
}
#[test]
fn register_and_get() {
let mut palette = CommandPalette::new();
palette
.register(make_descriptor("file.save", "Save File", "File"))
.unwrap();
let result = palette.get(&PaletteCommandId::new("file.save"));
assert!(result.is_some());
assert_eq!(result.unwrap().label, "Save File");
}
#[test]
fn register_duplicate_returns_error() {
let mut palette = CommandPalette::new();
palette
.register(make_descriptor("file.save", "Save File", "File"))
.unwrap();
assert!(
palette
.register(make_descriptor("file.save", "Save Again", "File"))
.is_err()
);
}
#[test]
fn unregister_removes_command() {
let mut palette = CommandPalette::new();
palette
.register(make_descriptor("file.save", "Save File", "File"))
.unwrap();
let removed = palette
.unregister(&PaletteCommandId::new("file.save"))
.unwrap();
assert_eq!(removed.label, "Save File");
assert!(palette.get(&PaletteCommandId::new("file.save")).is_none());
}
#[test]
fn search_matches_label_case_insensitive() {
let mut palette = CommandPalette::new();
palette
.register(make_descriptor("file.save", "Save File", "File"))
.unwrap();
palette
.register(make_descriptor("edit.undo", "Undo", "Edit"))
.unwrap();
let results = palette.search("SAVE");
assert_eq!(results.len(), 1);
}
#[test]
fn search_matches_category() {
let mut palette = CommandPalette::new();
palette
.register(make_descriptor("file.save", "Save", "File"))
.unwrap();
palette
.register(make_descriptor("edit.undo", "Undo", "Edit"))
.unwrap();
let results = palette.search("edit");
assert_eq!(results.len(), 1);
}
#[test]
fn search_empty_returns_all() {
let mut palette = CommandPalette::new();
palette
.register(make_descriptor("a", "Alpha", "Cat1"))
.unwrap();
palette
.register(make_descriptor("b", "Beta", "Cat2"))
.unwrap();
assert_eq!(palette.search("").len(), 2);
}
#[test]
fn commands_in_category_filters() {
let mut palette = CommandPalette::new();
palette
.register(make_descriptor("file.save", "Save", "File"))
.unwrap();
palette
.register(make_descriptor("file.open", "Open", "File"))
.unwrap();
palette
.register(make_descriptor("edit.undo", "Undo", "Edit"))
.unwrap();
assert_eq!(palette.commands_in_category("File").len(), 2);
assert_eq!(palette.commands_in_category("Edit").len(), 1);
assert!(palette.commands_in_category("View").is_empty());
}
#[test]
fn len_and_is_empty() {
let mut palette = CommandPalette::new();
assert!(palette.is_empty());
assert_eq!(palette.len(), 0);
palette.register(make_descriptor("a", "A", "C")).unwrap();
assert!(!palette.is_empty());
assert_eq!(palette.len(), 1);
}
#[test]
fn descriptor_with_keybinding_and_icon() {
let mut palette = CommandPalette::new();
let descriptor = CommandDescriptor {
id: PaletteCommandId::new("file.save"),
label: "Save File".to_string(),
category: "File".to_string(),
keybinding: Some("Cmd+S".to_string()),
icon: Some("save-icon".to_string()),
};
palette.register(descriptor).unwrap();
let result = palette.get(&PaletteCommandId::new("file.save")).unwrap();
assert_eq!(result.keybinding.as_deref(), Some("Cmd+S"));
assert_eq!(result.icon.as_deref(), Some("save-icon"));
}
#[test]
fn register_after_unregister() {
let mut palette = CommandPalette::new();
palette.register(make_descriptor("a", "V1", "C")).unwrap();
palette.unregister(&PaletteCommandId::new("a")).unwrap();
palette.register(make_descriptor("a", "V2", "C")).unwrap();
assert_eq!(
palette.get(&PaletteCommandId::new("a")).unwrap().label,
"V2"
);
}
#[test]
fn default_palette_is_empty() {
let palette = CommandPalette::default();
assert!(palette.is_empty());
}
#[test]
fn palette_command_id_display() {
let id = PaletteCommandId::new("file.save");
assert_eq!(format!("{}", id), "file.save");
}
}