1use crate::command::KeyBinding;
7use crate::view::Callback;
8use std::rc::Rc;
9
10#[derive(Clone)]
12pub struct Command {
13 pub id: &'static str,
15 pub label: String,
17 pub shortcut: Option<KeyBinding>,
19 pub action: Callback,
21 pub category: Option<String>,
23}
24
25impl Command {
26 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 #[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
45pub 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 pub fn shortcut(mut self, binding: KeyBinding) -> Self {
57 self.shortcut = Some(binding);
58 self
59 }
60
61 pub fn category(mut self, category: impl Into<String>) -> Self {
63 self.category = Some(category.into());
64 self
65 }
66
67 pub fn action(mut self, callback: impl Fn() + 'static) -> Self {
69 self.action = Some(Rc::new(callback));
70 self
71 }
72
73 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#[derive(Clone, Default)]
87pub struct Commands {
88 commands: Vec<Command>,
89}
90
91impl Commands {
92 pub fn new() -> Self {
94 Self::default()
95 }
96
97 pub fn with_command(mut self, command: Command) -> Self {
99 self.commands.push(command);
100 self
101 }
102
103 #[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 pub fn all(&self) -> &[Command] {
112 &self.commands
113 }
114
115 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 matches.sort_by(|a, b| b.1.cmp(&a.1));
137 matches.into_iter().map(|(cmd, _)| cmd).collect()
138 }
139
140 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 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 pub fn find(&self, id: &str) -> Option<&Command> {
169 self.commands.iter().find(|cmd| cmd.id == id)
170 }
171
172 pub fn execute(&self, id: &str) {
174 if let Some(cmd) = self.find(id) {
175 (cmd.action)();
176 }
177 }
178}
179
180#[derive(Clone)]
182pub enum MenuItem {
183 Command(&'static str),
185 Submenu(String, Vec<MenuItem>),
187 Separator,
189}
190
191impl MenuItem {
192 pub fn command(id: &'static str) -> Self {
194 MenuItem::Command(id)
195 }
196
197 pub fn submenu(label: impl Into<String>, items: Vec<MenuItem>) -> Self {
199 MenuItem::Submenu(label.into(), items)
200 }
201
202 pub fn separator() -> Self {
204 MenuItem::Separator
205 }
206}
207
208#[derive(Clone, Default)]
210pub struct MenuDefinition {
211 pub items: Vec<MenuItem>,
213}
214
215impl MenuDefinition {
216 pub fn new() -> Self {
218 Self::default()
219 }
220
221 #[allow(clippy::should_implement_trait)]
223 pub fn add(mut self, item: MenuItem) -> Self {
224 self.items.push(item);
225 self
226 }
227
228 pub fn menu(self, label: impl Into<String>, items: Vec<MenuItem>) -> Self {
230 self.add(MenuItem::Submenu(label.into(), items))
231 }
232}
233
234fn 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 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 consecutive += 1;
254 score += consecutive * 2;
255
256 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 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 assert!(fuzzy_score("save", "save") > 0);
285
286 assert!(fuzzy_score("save file", "save") > 0);
288
289 assert!(fuzzy_score("save file", "sf") > 0);
291
292 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"); assert_eq!(cats[0].1.len(), 2);
322 }
323}