Skip to main content

telex/
command_system.rs

1//! Unified command system for Command Palette and Menu Bar.
2//!
3//! Commands are defined once and can be used in both the Command Palette
4//! (fuzzy search) and Menu Bar (dropdown menus).
5
6use crate::command::KeyBinding;
7use crate::view::Callback;
8use std::rc::Rc;
9
10/// A command that can be executed from the Command Palette or Menu Bar.
11#[derive(Clone)]
12pub struct Command {
13    /// Unique identifier for the command (e.g., "file.save").
14    pub id: &'static str,
15    /// Display label for the command (e.g., "Save File").
16    pub label: String,
17    /// Optional keyboard shortcut.
18    pub shortcut: Option<KeyBinding>,
19    /// The action to execute when the command is triggered.
20    pub action: Callback,
21    /// Optional category for menu grouping (e.g., "File", "Edit").
22    pub category: Option<String>,
23}
24
25impl Command {
26    /// Create a new command builder with the given ID and label.
27    pub fn builder(id: &'static str, label: impl Into<String>) -> CommandBuilder {
28        CommandBuilder {
29            id,
30            label: label.into(),
31            shortcut: None,
32            action: None,
33            category: None,
34        }
35    }
36
37    /// Create a new command with the given ID and label (deprecated, use builder).
38    #[deprecated(since = "0.1.0", note = "Use Command::builder instead")]
39    #[allow(clippy::new_ret_no_self)]
40    pub fn new(id: &'static str, label: impl Into<String>) -> CommandBuilder {
41        Self::builder(id, label)
42    }
43}
44
45/// Builder for creating commands.
46pub struct CommandBuilder {
47    id: &'static str,
48    label: String,
49    shortcut: Option<KeyBinding>,
50    action: Option<Callback>,
51    category: Option<String>,
52}
53
54impl CommandBuilder {
55    /// Set the keyboard shortcut for this command.
56    pub fn shortcut(mut self, binding: KeyBinding) -> Self {
57        self.shortcut = Some(binding);
58        self
59    }
60
61    /// Set the category for menu grouping.
62    pub fn category(mut self, category: impl Into<String>) -> Self {
63        self.category = Some(category.into());
64        self
65    }
66
67    /// Set the action callback for this command.
68    pub fn action(mut self, callback: impl Fn() + 'static) -> Self {
69        self.action = Some(Rc::new(callback));
70        self
71    }
72
73    /// Build the command.
74    pub fn build(self) -> Command {
75        Command {
76            id: self.id,
77            label: self.label,
78            shortcut: self.shortcut,
79            action: self.action.unwrap_or_else(|| Rc::new(|| {})),
80            category: self.category,
81        }
82    }
83}
84
85/// A collection of commands.
86#[derive(Clone, Default)]
87pub struct Commands {
88    commands: Vec<Command>,
89}
90
91impl Commands {
92    /// Create a new empty command collection.
93    pub fn new() -> Self {
94        Self::default()
95    }
96
97    /// Add a command to the collection.
98    pub fn with_command(mut self, command: Command) -> Self {
99        self.commands.push(command);
100        self
101    }
102
103    /// Add a command to the collection (deprecated, use with_command).
104    #[deprecated(since = "0.1.0", note = "Use with_command instead to avoid confusion with std::ops::Add")]
105    #[allow(clippy::should_implement_trait)]
106    pub fn add(self, command: Command) -> Self {
107        self.with_command(command)
108    }
109
110    /// Get all commands.
111    pub fn all(&self) -> &[Command] {
112        &self.commands
113    }
114
115    /// Filter commands by a fuzzy search query.
116    pub fn filter(&self, query: &str) -> Vec<&Command> {
117        if query.is_empty() {
118            return self.commands.iter().collect();
119        }
120
121        let query_lower = query.to_lowercase();
122        let mut matches: Vec<(&Command, i32)> = self
123            .commands
124            .iter()
125            .filter_map(|cmd| {
126                let score = fuzzy_score(&cmd.label.to_lowercase(), &query_lower);
127                if score > 0 {
128                    Some((cmd, score))
129                } else {
130                    None
131                }
132            })
133            .collect();
134
135        // Sort by score (higher is better)
136        matches.sort_by(|a, b| b.1.cmp(&a.1));
137        matches.into_iter().map(|(cmd, _)| cmd).collect()
138    }
139
140    /// Get commands grouped by category.
141    pub fn by_category(&self) -> Vec<(String, Vec<&Command>)> {
142        let mut categories: std::collections::HashMap<String, Vec<&Command>> =
143            std::collections::HashMap::new();
144
145        for cmd in &self.commands {
146            let cat = cmd.category.clone().unwrap_or_else(|| "Other".to_string());
147            categories.entry(cat).or_default().push(cmd);
148        }
149
150        // Sort categories alphabetically, but put "File" first and "Other" last
151        let mut result: Vec<(String, Vec<&Command>)> = categories.into_iter().collect();
152        result.sort_by(|a, b| {
153            match (&a.0[..], &b.0[..]) {
154                ("File", "File") => std::cmp::Ordering::Equal,
155                ("File", _) => std::cmp::Ordering::Less,
156                (_, "File") => std::cmp::Ordering::Greater,
157                ("Other", "Other") => std::cmp::Ordering::Equal,
158                ("Other", _) => std::cmp::Ordering::Greater,
159                (_, "Other") => std::cmp::Ordering::Less,
160                _ => a.0.cmp(&b.0),
161            }
162        });
163
164        result
165    }
166
167    /// Find a command by ID.
168    pub fn find(&self, id: &str) -> Option<&Command> {
169        self.commands.iter().find(|cmd| cmd.id == id)
170    }
171
172    /// Execute a command by ID.
173    pub fn execute(&self, id: &str) {
174        if let Some(cmd) = self.find(id) {
175            (cmd.action)();
176        }
177    }
178}
179
180/// A menu item for the menu bar.
181#[derive(Clone)]
182pub enum MenuItem {
183    /// A command reference by ID.
184    Command(&'static str),
185    /// A submenu with a label and items.
186    Submenu(String, Vec<MenuItem>),
187    /// A visual separator.
188    Separator,
189}
190
191impl MenuItem {
192    /// Create a command menu item.
193    pub fn command(id: &'static str) -> Self {
194        MenuItem::Command(id)
195    }
196
197    /// Create a submenu.
198    pub fn submenu(label: impl Into<String>, items: Vec<MenuItem>) -> Self {
199        MenuItem::Submenu(label.into(), items)
200    }
201
202    /// Create a separator.
203    pub fn separator() -> Self {
204        MenuItem::Separator
205    }
206}
207
208/// Menu bar definition.
209#[derive(Clone, Default)]
210pub struct MenuDefinition {
211    /// Top-level menu items (usually submenus like "File", "Edit", etc.).
212    pub items: Vec<MenuItem>,
213}
214
215impl MenuDefinition {
216    /// Create a new menu definition.
217    pub fn new() -> Self {
218        Self::default()
219    }
220
221    /// Add a top-level menu item.
222    #[allow(clippy::should_implement_trait)]
223    pub fn add(mut self, item: MenuItem) -> Self {
224        self.items.push(item);
225        self
226    }
227
228    /// Add a menu (submenu with label).
229    pub fn menu(self, label: impl Into<String>, items: Vec<MenuItem>) -> Self {
230        self.add(MenuItem::Submenu(label.into(), items))
231    }
232}
233
234/// Simple fuzzy matching score.
235/// Returns a positive score if the query matches, 0 otherwise.
236fn fuzzy_score(text: &str, query: &str) -> i32 {
237    if query.is_empty() {
238        return 1;
239    }
240
241    let text_chars: Vec<char> = text.chars().collect();
242    let query_chars: Vec<char> = query.chars().collect();
243
244    // Try to find all query characters in order in the text
245    let mut text_idx = 0;
246    let mut query_idx = 0;
247    let mut score = 0;
248    let mut consecutive = 0;
249
250    while text_idx < text_chars.len() && query_idx < query_chars.len() {
251        if text_chars[text_idx] == query_chars[query_idx] {
252            // Bonus for consecutive matches
253            consecutive += 1;
254            score += consecutive * 2;
255
256            // Bonus for matching at word boundaries
257            if text_idx == 0 || !text_chars[text_idx - 1].is_alphanumeric() {
258                score += 5;
259            }
260
261            query_idx += 1;
262        } else {
263            consecutive = 0;
264        }
265        text_idx += 1;
266    }
267
268    // All query characters must be found
269    if query_idx == query_chars.len() {
270        score
271    } else {
272        0
273    }
274}
275
276#[cfg(test)]
277#[allow(deprecated)]
278mod tests {
279    use super::*;
280
281    #[test]
282    fn test_fuzzy_score() {
283        // Exact match
284        assert!(fuzzy_score("save", "save") > 0);
285
286        // Prefix match
287        assert!(fuzzy_score("save file", "save") > 0);
288
289        // Fuzzy match
290        assert!(fuzzy_score("save file", "sf") > 0);
291
292        // No match
293        assert_eq!(fuzzy_score("save", "xyz"), 0);
294    }
295
296    #[test]
297    fn test_commands_filter() {
298        let commands = Commands::new()
299            .add(Command::new("file.save", "Save File").build())
300            .add(Command::new("file.open", "Open File").build())
301            .add(Command::new("edit.undo", "Undo").build());
302
303        let matches = commands.filter("file");
304        assert_eq!(matches.len(), 2);
305
306        let matches = commands.filter("save");
307        assert_eq!(matches.len(), 1);
308        assert_eq!(matches[0].id, "file.save");
309    }
310
311    #[test]
312    fn test_commands_by_category() {
313        let commands = Commands::new()
314            .add(Command::new("file.save", "Save").category("File").build())
315            .add(Command::new("file.open", "Open").category("File").build())
316            .add(Command::new("edit.undo", "Undo").category("Edit").build());
317
318        let cats = commands.by_category();
319        assert_eq!(cats.len(), 2);
320        assert_eq!(cats[0].0, "File"); // File comes first
321        assert_eq!(cats[0].1.len(), 2);
322    }
323}