use crate::components::{Box, Text};
use crate::core::{Color, Element, FlexDirection};
#[derive(Debug, Clone)]
pub struct Command {
pub id: String,
pub label: String,
pub description: Option<String>,
pub shortcut: Option<String>,
pub category: Option<String>,
pub disabled: bool,
}
impl Command {
pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
Self {
id: id.into(),
label: label.into(),
description: None,
shortcut: None,
category: None,
disabled: false,
}
}
pub fn description(mut self, desc: impl Into<String>) -> Self {
self.description = Some(desc.into());
self
}
pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
self.shortcut = Some(shortcut.into());
self
}
pub fn category(mut self, category: impl Into<String>) -> Self {
self.category = Some(category.into());
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn matches(&self, query: &str) -> bool {
if query.is_empty() {
return true;
}
let query_lower = query.to_lowercase();
let label_lower = self.label.to_lowercase();
let id_lower = self.id.to_lowercase();
if label_lower.contains(&query_lower) {
return true;
}
if id_lower.contains(&query_lower) {
return true;
}
if let Some(desc) = &self.description {
if desc.to_lowercase().contains(&query_lower) {
return true;
}
}
if let Some(cat) = &self.category {
if cat.to_lowercase().contains(&query_lower) {
return true;
}
}
fuzzy_match(&label_lower, &query_lower)
}
pub fn match_score(&self, query: &str) -> i32 {
if query.is_empty() {
return 0;
}
let query_lower = query.to_lowercase();
let label_lower = self.label.to_lowercase();
if label_lower == query_lower {
return 100;
}
if label_lower.starts_with(&query_lower) {
return 80;
}
if label_lower.contains(&query_lower) {
return 60;
}
if self.id.to_lowercase().contains(&query_lower) {
return 40;
}
if fuzzy_match(&label_lower, &query_lower) {
return 20;
}
0
}
}
fn fuzzy_match(text: &str, pattern: &str) -> bool {
let mut pattern_chars = pattern.chars().peekable();
for c in text.chars() {
if let Some(&p) = pattern_chars.peek() {
if c == p {
pattern_chars.next();
}
}
}
pattern_chars.peek().is_none()
}
#[derive(Debug, Clone, Default)]
pub struct CommandPaletteState {
pub query: String,
pub selected: usize,
pub open: bool,
}
impl CommandPaletteState {
pub fn new() -> Self {
Self::default()
}
pub fn open(&mut self) {
self.open = true;
self.query.clear();
self.selected = 0;
}
pub fn close(&mut self) {
self.open = false;
self.query.clear();
self.selected = 0;
}
pub fn toggle(&mut self) {
if self.open {
self.close();
} else {
self.open();
}
}
pub fn set_query(&mut self, query: impl Into<String>) {
self.query = query.into();
self.selected = 0;
}
pub fn select_prev(&mut self, max: usize) {
if self.selected > 0 {
self.selected -= 1;
} else if max > 0 {
self.selected = max - 1;
}
}
pub fn select_next(&mut self, max: usize) {
if max > 0 && self.selected < max - 1 {
self.selected += 1;
} else {
self.selected = 0;
}
}
}
#[derive(Debug, Clone)]
pub struct CommandPaletteStyle {
pub border_color: Color,
pub background: Color,
pub text_color: Color,
pub selected_bg: Color,
pub selected_fg: Color,
pub disabled_color: Color,
pub shortcut_color: Color,
pub description_color: Color,
pub max_visible: usize,
pub width: usize,
}
impl Default for CommandPaletteStyle {
fn default() -> Self {
Self {
border_color: Color::White,
background: Color::Black,
text_color: Color::White,
selected_bg: Color::Blue,
selected_fg: Color::White,
disabled_color: Color::BrightBlack,
shortcut_color: Color::Cyan,
description_color: Color::BrightBlack,
max_visible: 10,
width: 60,
}
}
}
impl CommandPaletteStyle {
pub fn new() -> Self {
Self::default()
}
pub fn border_color(mut self, color: Color) -> Self {
self.border_color = color;
self
}
pub fn background(mut self, color: Color) -> Self {
self.background = color;
self
}
pub fn text_color(mut self, color: Color) -> Self {
self.text_color = color;
self
}
pub fn selected_bg(mut self, color: Color) -> Self {
self.selected_bg = color;
self
}
pub fn max_visible(mut self, max: usize) -> Self {
self.max_visible = max;
self
}
pub fn width(mut self, width: usize) -> Self {
self.width = width;
self
}
}
#[derive(Debug)]
pub struct CommandPalette {
commands: Vec<Command>,
state: CommandPaletteState,
style: CommandPaletteStyle,
placeholder: String,
title: Option<String>,
}
impl CommandPalette {
pub fn new(commands: Vec<Command>) -> Self {
Self {
commands,
state: CommandPaletteState::new(),
style: CommandPaletteStyle::default(),
placeholder: "> Type to search...".to_string(),
title: None,
}
}
pub fn state(mut self, state: CommandPaletteState) -> Self {
self.state = state;
self
}
pub fn style(mut self, style: CommandPaletteStyle) -> Self {
self.style = style;
self
}
pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
self.placeholder = placeholder.into();
self
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn filtered_commands(&self) -> Vec<&Command> {
let mut filtered: Vec<_> = self
.commands
.iter()
.filter(|cmd| cmd.matches(&self.state.query))
.collect();
filtered.sort_by(|a, b| {
b.match_score(&self.state.query)
.cmp(&a.match_score(&self.state.query))
});
filtered
}
pub fn selected_command(&self) -> Option<&Command> {
let filtered = self.filtered_commands();
filtered.get(self.state.selected).copied()
}
fn render_item(&self, cmd: &Command, is_selected: bool) -> String {
let mut line = String::new();
if is_selected {
line.push_str("> ");
} else {
line.push_str(" ");
}
line.push_str(&cmd.label);
if let Some(shortcut) = &cmd.shortcut {
let padding = self
.style
.width
.saturating_sub(line.len() + shortcut.len() + 2);
line.push_str(&" ".repeat(padding));
line.push_str(shortcut);
}
if line.len() > self.style.width {
line.truncate(self.style.width - 3);
line.push_str("...");
}
line
}
pub fn into_element(self) -> Element {
if !self.state.open {
return Box::new().into_element();
}
let filtered = self.filtered_commands();
let visible_count = filtered.len().min(self.style.max_visible);
let mut container = Box::new().flex_direction(FlexDirection::Column);
if let Some(title) = &self.title {
container =
container.child(Text::new(title).color(self.style.text_color).into_element());
}
let input_text = if self.state.query.is_empty() {
self.placeholder.clone()
} else {
format!("> {}", self.state.query)
};
container = container.child(
Text::new(input_text)
.color(self.style.text_color)
.into_element(),
);
container = container.child(
Text::new("─".repeat(self.style.width))
.color(self.style.border_color)
.into_element(),
);
for (i, cmd) in filtered.iter().take(visible_count).enumerate() {
let is_selected = i == self.state.selected;
let line = self.render_item(cmd, is_selected);
let (fg, bg) = if is_selected {
(self.style.selected_fg, self.style.selected_bg)
} else if cmd.disabled {
(self.style.disabled_color, self.style.background)
} else {
(self.style.text_color, self.style.background)
};
container = container.child(Text::new(line).color(fg).background(bg).into_element());
}
if filtered.len() > visible_count {
let more = filtered.len() - visible_count;
container = container.child(
Text::new(format!(" ... and {} more", more))
.color(self.style.description_color)
.into_element(),
);
}
if filtered.is_empty() {
container = container.child(
Text::new(" No commands found")
.color(self.style.description_color)
.into_element(),
);
}
container.into_element()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_command_creation() {
let cmd = Command::new("test.cmd", "Test Command");
assert_eq!(cmd.id, "test.cmd");
assert_eq!(cmd.label, "Test Command");
}
#[test]
fn test_command_builder() {
let cmd = Command::new("file.open", "Open File")
.description("Open a file from disk")
.shortcut("Ctrl+O")
.category("File");
assert_eq!(cmd.description, Some("Open a file from disk".to_string()));
assert_eq!(cmd.shortcut, Some("Ctrl+O".to_string()));
assert_eq!(cmd.category, Some("File".to_string()));
}
#[test]
fn test_command_matches() {
let cmd = Command::new("file.open", "Open File");
assert!(cmd.matches(""));
assert!(cmd.matches("open"));
assert!(cmd.matches("Open"));
assert!(cmd.matches("file"));
assert!(cmd.matches("of")); assert!(!cmd.matches("xyz"));
}
#[test]
fn test_command_match_score() {
let cmd = Command::new("file.open", "Open File");
assert!(cmd.match_score("Open File") > cmd.match_score("Open"));
assert!(cmd.match_score("Open") > cmd.match_score("pen"));
assert!(cmd.match_score("pen") > cmd.match_score("xyz"));
}
#[test]
fn test_fuzzy_match() {
assert!(fuzzy_match("open file", "of"));
assert!(fuzzy_match("open file", "opfl"));
assert!(fuzzy_match("command palette", "cmdp"));
assert!(!fuzzy_match("open", "xyz"));
}
#[test]
fn test_palette_state() {
let mut state = CommandPaletteState::new();
assert!(!state.open);
state.open();
assert!(state.open);
assert!(state.query.is_empty());
state.set_query("test");
assert_eq!(state.query, "test");
state.close();
assert!(!state.open);
}
#[test]
fn test_palette_state_navigation() {
let mut state = CommandPaletteState::new();
state.selected = 0;
state.select_next(5);
assert_eq!(state.selected, 1);
state.select_next(5);
assert_eq!(state.selected, 2);
state.select_prev(5);
assert_eq!(state.selected, 1);
state.select_prev(5);
assert_eq!(state.selected, 0);
state.select_prev(5);
assert_eq!(state.selected, 4);
}
#[test]
fn test_palette_filtered_commands() {
let commands = vec![
Command::new("file.open", "Open File"),
Command::new("file.save", "Save File"),
Command::new("edit.undo", "Undo"),
];
let palette = CommandPalette::new(commands);
let filtered = palette.filtered_commands();
assert_eq!(filtered.len(), 3);
let mut state = CommandPaletteState::new();
state.set_query("file");
let palette = CommandPalette::new(vec![
Command::new("file.open", "Open File"),
Command::new("file.save", "Save File"),
Command::new("edit.undo", "Undo"),
])
.state(state);
let filtered = palette.filtered_commands();
assert_eq!(filtered.len(), 2);
}
#[test]
fn test_palette_into_element() {
let commands = vec![Command::new("test", "Test")];
let mut state = CommandPaletteState::new();
state.open();
let palette = CommandPalette::new(commands).state(state);
let _ = palette.into_element();
}
#[test]
fn test_palette_style() {
let style = CommandPaletteStyle::new()
.width(80)
.max_visible(15)
.selected_bg(Color::Green);
assert_eq!(style.width, 80);
assert_eq!(style.max_visible, 15);
assert_eq!(style.selected_bg, Color::Green);
}
}