use crate::command::KeyBinding;
use crate::view::Callback;
use std::rc::Rc;
#[derive(Clone)]
pub struct Command {
pub id: &'static str,
pub label: String,
pub shortcut: Option<KeyBinding>,
pub action: Callback,
pub category: Option<String>,
}
impl Command {
pub fn builder(id: &'static str, label: impl Into<String>) -> CommandBuilder {
CommandBuilder {
id,
label: label.into(),
shortcut: None,
action: None,
category: None,
}
}
#[deprecated(since = "0.1.0", note = "Use Command::builder instead")]
#[allow(clippy::new_ret_no_self)]
pub fn new(id: &'static str, label: impl Into<String>) -> CommandBuilder {
Self::builder(id, label)
}
}
pub struct CommandBuilder {
id: &'static str,
label: String,
shortcut: Option<KeyBinding>,
action: Option<Callback>,
category: Option<String>,
}
impl CommandBuilder {
pub fn shortcut(mut self, binding: KeyBinding) -> Self {
self.shortcut = Some(binding);
self
}
pub fn category(mut self, category: impl Into<String>) -> Self {
self.category = Some(category.into());
self
}
pub fn action(mut self, callback: impl Fn() + 'static) -> Self {
self.action = Some(Rc::new(callback));
self
}
pub fn build(self) -> Command {
Command {
id: self.id,
label: self.label,
shortcut: self.shortcut,
action: self.action.unwrap_or_else(|| Rc::new(|| {})),
category: self.category,
}
}
}
#[derive(Clone, Default)]
pub struct Commands {
commands: Vec<Command>,
}
impl Commands {
pub fn new() -> Self {
Self::default()
}
pub fn with_command(mut self, command: Command) -> Self {
self.commands.push(command);
self
}
#[deprecated(since = "0.1.0", note = "Use with_command instead to avoid confusion with std::ops::Add")]
#[allow(clippy::should_implement_trait)]
pub fn add(self, command: Command) -> Self {
self.with_command(command)
}
pub fn all(&self) -> &[Command] {
&self.commands
}
pub fn filter(&self, query: &str) -> Vec<&Command> {
if query.is_empty() {
return self.commands.iter().collect();
}
let query_lower = query.to_lowercase();
let mut matches: Vec<(&Command, i32)> = self
.commands
.iter()
.filter_map(|cmd| {
let score = fuzzy_score(&cmd.label.to_lowercase(), &query_lower);
if score > 0 {
Some((cmd, score))
} else {
None
}
})
.collect();
matches.sort_by(|a, b| b.1.cmp(&a.1));
matches.into_iter().map(|(cmd, _)| cmd).collect()
}
pub fn by_category(&self) -> Vec<(String, Vec<&Command>)> {
let mut categories: std::collections::HashMap<String, Vec<&Command>> =
std::collections::HashMap::new();
for cmd in &self.commands {
let cat = cmd.category.clone().unwrap_or_else(|| "Other".to_string());
categories.entry(cat).or_default().push(cmd);
}
let mut result: Vec<(String, Vec<&Command>)> = categories.into_iter().collect();
result.sort_by(|a, b| {
match (&a.0[..], &b.0[..]) {
("File", "File") => std::cmp::Ordering::Equal,
("File", _) => std::cmp::Ordering::Less,
(_, "File") => std::cmp::Ordering::Greater,
("Other", "Other") => std::cmp::Ordering::Equal,
("Other", _) => std::cmp::Ordering::Greater,
(_, "Other") => std::cmp::Ordering::Less,
_ => a.0.cmp(&b.0),
}
});
result
}
pub fn find(&self, id: &str) -> Option<&Command> {
self.commands.iter().find(|cmd| cmd.id == id)
}
pub fn execute(&self, id: &str) {
if let Some(cmd) = self.find(id) {
(cmd.action)();
}
}
}
#[derive(Clone)]
pub enum MenuItem {
Command(&'static str),
Submenu(String, Vec<MenuItem>),
Separator,
}
impl MenuItem {
pub fn command(id: &'static str) -> Self {
MenuItem::Command(id)
}
pub fn submenu(label: impl Into<String>, items: Vec<MenuItem>) -> Self {
MenuItem::Submenu(label.into(), items)
}
pub fn separator() -> Self {
MenuItem::Separator
}
}
#[derive(Clone, Default)]
pub struct MenuDefinition {
pub items: Vec<MenuItem>,
}
impl MenuDefinition {
pub fn new() -> Self {
Self::default()
}
#[allow(clippy::should_implement_trait)]
pub fn add(mut self, item: MenuItem) -> Self {
self.items.push(item);
self
}
pub fn menu(self, label: impl Into<String>, items: Vec<MenuItem>) -> Self {
self.add(MenuItem::Submenu(label.into(), items))
}
}
fn fuzzy_score(text: &str, query: &str) -> i32 {
if query.is_empty() {
return 1;
}
let text_chars: Vec<char> = text.chars().collect();
let query_chars: Vec<char> = query.chars().collect();
let mut text_idx = 0;
let mut query_idx = 0;
let mut score = 0;
let mut consecutive = 0;
while text_idx < text_chars.len() && query_idx < query_chars.len() {
if text_chars[text_idx] == query_chars[query_idx] {
consecutive += 1;
score += consecutive * 2;
if text_idx == 0 || !text_chars[text_idx - 1].is_alphanumeric() {
score += 5;
}
query_idx += 1;
} else {
consecutive = 0;
}
text_idx += 1;
}
if query_idx == query_chars.len() {
score
} else {
0
}
}
#[cfg(test)]
#[allow(deprecated)]
mod tests {
use super::*;
#[test]
fn test_fuzzy_score() {
assert!(fuzzy_score("save", "save") > 0);
assert!(fuzzy_score("save file", "save") > 0);
assert!(fuzzy_score("save file", "sf") > 0);
assert_eq!(fuzzy_score("save", "xyz"), 0);
}
#[test]
fn test_commands_filter() {
let commands = Commands::new()
.add(Command::new("file.save", "Save File").build())
.add(Command::new("file.open", "Open File").build())
.add(Command::new("edit.undo", "Undo").build());
let matches = commands.filter("file");
assert_eq!(matches.len(), 2);
let matches = commands.filter("save");
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].id, "file.save");
}
#[test]
fn test_commands_by_category() {
let commands = Commands::new()
.add(Command::new("file.save", "Save").category("File").build())
.add(Command::new("file.open", "Open").category("File").build())
.add(Command::new("edit.undo", "Undo").category("Edit").build());
let cats = commands.by_category();
assert_eq!(cats.len(), 2);
assert_eq!(cats[0].0, "File"); assert_eq!(cats[0].1.len(), 2);
}
}