1use egui::{Align2, Color32, Key, Order, Stroke, Ui, vec2};
35use facett_core::theme;
36
37#[derive(Clone, Debug, PartialEq, Eq)]
40pub struct Command {
41 pub id: &'static str,
43 pub label: String,
45 pub group: &'static str,
47 pub keywords: Vec<&'static str>,
49 pub shortcut: Option<&'static str>,
52 pub enabled: bool,
54 pub icon: Option<&'static str>,
56}
57
58impl Command {
59 pub fn new(id: &'static str, label: impl Into<String>, group: &'static str) -> Self {
61 Self { id, label: label.into(), group, keywords: Vec::new(), shortcut: None, enabled: true, icon: None }
62 }
63 pub fn shortcut(mut self, s: &'static str) -> Self {
65 self.shortcut = Some(s);
66 self
67 }
68 pub fn icon(mut self, glyph: &'static str) -> Self {
70 self.icon = Some(glyph);
71 self
72 }
73 pub fn keywords(mut self, kw: &[&'static str]) -> Self {
75 self.keywords = kw.to_vec();
76 self
77 }
78 pub fn enabled(mut self, on: bool) -> Self {
80 self.enabled = on;
81 self
82 }
83
84 fn haystack(&self) -> String {
86 let mut h = format!("{} {}", self.label, self.id);
87 for k in &self.keywords {
88 h.push(' ');
89 h.push_str(k);
90 }
91 h.to_lowercase()
92 }
93}
94
95fn fuzzy_score(needle: &str, haystack: &str) -> Option<i32> {
100 if needle.is_empty() {
101 return Some(0);
102 }
103 let hay: Vec<char> = haystack.chars().collect();
104 let mut score = 0i32;
105 let mut hi = 0usize;
106 let mut first = None;
107 let mut last = 0usize;
108 let mut prev_matched = false;
109 for nc in needle.chars() {
110 let mut found = false;
112 while hi < hay.len() {
113 if hay[hi] == nc {
114 found = true;
115 break;
116 }
117 hi += 1;
118 }
119 if !found {
120 return None;
121 }
122 if first.is_none() {
123 first = Some(hi);
124 }
125 last = hi;
126 score += 1;
127 if prev_matched {
128 score += 5; }
130 let at_word_start = hi == 0 || matches!(hay[hi - 1], ' ' | '.' | '_' | '-' | '/');
131 if at_word_start {
132 score += 3;
133 }
134 prev_matched = true;
135 hi += 1;
136 }
137 let span = last - first.unwrap_or(0);
139 score -= span as i32 / 4;
140 Some(score)
141}
142
143pub fn rank<'a>(query: &str, commands: &'a [Command]) -> Vec<&'a Command> {
147 let q = query.to_lowercase();
148 let mut scored: Vec<(i32, usize, &Command)> = commands
149 .iter()
150 .enumerate()
151 .filter(|(_, c)| c.enabled)
152 .filter_map(|(i, c)| fuzzy_score(&q, &c.haystack()).map(|s| (s, i, c)))
153 .collect();
154 scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
156 scored.into_iter().map(|(_, _, c)| c).collect()
157}
158
159#[derive(Default)]
165pub struct CommandPalette {
166 open: bool,
167 query: String,
168 cursor: usize,
169 invoked: Option<&'static str>,
170}
171
172impl CommandPalette {
173 pub fn open(&mut self) {
175 self.open = true;
176 self.query.clear();
177 self.cursor = 0;
178 }
179 pub fn close(&mut self) {
181 self.open = false;
182 }
183 pub fn is_open(&self) -> bool {
185 self.open
186 }
187 pub fn invoked(&self) -> Option<&'static str> {
189 self.invoked
190 }
191 pub fn query(&self) -> &str {
193 &self.query
194 }
195 pub fn cursor(&self) -> usize {
197 self.cursor
198 }
199
200 pub fn move_cursor(&mut self, delta: i32, len: usize) {
204 if len == 0 {
205 self.cursor = 0;
206 return;
207 }
208 let n = len as i32;
209 self.cursor = (((self.cursor as i32 + delta) % n + n) % n) as usize;
210 }
211
212 pub fn invoke(&mut self, id: &'static str) -> &'static str {
215 self.invoked = Some(id);
216 self.open = false;
217 id
218 }
219
220 pub fn state_json(&self, commands: &[Command]) -> serde_json::Value {
222 serde_json::json!({
223 "open": self.open,
224 "query": self.query,
225 "cursor": self.cursor,
226 "commands": commands.iter().map(|c| c.id).collect::<Vec<_>>(),
227 "hits": rank(&self.query, commands).iter().map(|c| c.id).collect::<Vec<_>>(),
228 "invoked": self.invoked,
229 })
230 }
231
232 pub fn handle_hotkeys(&mut self, ctx: &egui::Context) {
236 ctx.input(|i| {
237 let cmd = i.modifiers.command;
238 if cmd && i.key_pressed(Key::K) {
239 self.open = true;
240 self.query.clear();
241 self.cursor = 0;
242 }
243 if cmd && i.modifiers.shift && i.key_pressed(Key::P) {
244 self.open = true;
245 self.query.clear();
246 self.cursor = 0;
247 }
248 if i.key_pressed(Key::Escape) {
249 self.open = false;
250 }
251 });
252 }
253
254 pub fn ui(&mut self, ctx: &egui::Context, commands: &[Command]) -> Option<&'static str> {
259 self.handle_hotkeys(ctx);
260 if !self.open {
261 return None;
262 }
263
264 let th = ctx.data(|d| d.get_temp::<facett_core::Theme>(egui::Id::new("facett_theme"))).unwrap_or_default();
266 let screen = ctx.content_rect();
267
268 egui::Area::new("facett_palette_scrim".into())
270 .order(Order::Foreground)
271 .fixed_pos(screen.min)
272 .interactable(false)
273 .show(ctx, |ui| {
274 ui.painter().rect_filled(screen, 0.0, Color32::from_black_alpha(160));
275 });
276
277 let mut chosen = None;
278 egui::Area::new("facett_palette".into())
279 .order(Order::Foreground)
280 .anchor(Align2::CENTER_TOP, vec2(0.0, 80.0))
281 .show(ctx, |ui| {
282 egui::Frame::popup(ui.style())
283 .fill(th.panel_bg)
284 .stroke(Stroke::new(1.0, th.panel_stroke))
285 .show(ui, |ui| {
286 ui.set_width(520.0);
287
288 let edit = ui.add(
289 egui::TextEdit::singleline(&mut self.query).hint_text("Type a command…").desired_width(f32::INFINITY),
290 );
291 edit.request_focus();
292
293 let hits = rank(&self.query, commands);
294
295 let (down, up, enter) = ui.input(|i| {
300 (i.key_pressed(Key::ArrowDown), i.key_pressed(Key::ArrowUp), i.key_pressed(Key::Enter))
301 });
302 if down {
303 self.move_cursor(1, hits.len());
304 }
305 if up {
306 self.move_cursor(-1, hits.len());
307 }
308 if hits.is_empty() {
309 self.cursor = 0;
310 } else if self.cursor >= hits.len() {
311 self.cursor = hits.len() - 1;
312 }
313
314 ui.separator();
315
316 for (i, c) in hits.iter().enumerate() {
317 let sel = i == self.cursor;
318 let row = row_text(c);
319 let resp = ui.selectable_label(sel, row);
320 if sel {
321 let r = resp.rect;
323 let p = ui.painter();
324 for w in 1..=4 {
325 let a = (70 / w) as u8;
326 let g = Color32::from_rgba_unmultiplied(th.glow.r(), th.glow.g(), th.glow.b(), a);
327 p.line_segment(
328 [r.left_bottom() + vec2(0.0, w as f32), r.right_bottom() + vec2(0.0, w as f32)],
329 Stroke::new(1.0, g),
330 );
331 }
332 p.line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.5, th.accent));
333 }
334 if resp.clicked() {
335 chosen = Some(c.id);
336 }
337 }
338
339 if hits.is_empty() {
340 ui.weak("No matching commands");
341 }
342
343 if enter {
344 chosen = hits.get(self.cursor).map(|c| c.id);
345 }
346 });
347 });
348
349 if let Some(id) = chosen {
350 self.open = false;
351 self.invoked = Some(id);
352 }
353 chosen
354 }
355}
356
357fn row_text(c: &Command) -> String {
361 let mut s = String::new();
362 if let Some(ic) = c.icon {
363 s.push_str(ic);
364 s.push(' ');
365 }
366 s.push_str(&c.label);
367 s.push_str(" · ");
368 s.push_str(c.group);
369 if let Some(sc) = c.shortcut {
370 s.push_str(" ");
371 s.push_str(sc);
372 }
373 s
374}
375
376pub fn context_menu(ui: &mut Ui, commands: &[Command]) -> Option<&'static str> {
382 let th = theme(ui);
383 let mut chosen = None;
384 let mut last_group: Option<&'static str> = None;
385 for c in commands {
386 if last_group.is_some() && last_group != Some(c.group) {
387 ui.separator();
388 }
389 last_group = Some(c.group);
390
391 let btn = egui::Button::new(menu_label(c)).wrap_mode(egui::TextWrapMode::Extend);
392 let resp = ui.add_enabled(c.enabled, btn);
393 if resp.hovered() && c.enabled {
395 let r = resp.rect;
396 ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, th.accent));
397 }
398 if resp.clicked() {
399 chosen = Some(c.id);
400 ui.close();
401 }
402 }
403 chosen
404}
405
406pub fn attach_context_menu(response: &egui::Response, commands: &[Command], mut on_pick: impl FnMut(&'static str)) {
418 response.context_menu(|ui| {
419 if let Some(id) = context_menu(ui, commands) {
420 on_pick(id);
421 }
422 });
423}
424
425pub fn menu_bar(ui: &mut Ui, commands: &[Command]) -> Option<&'static str> {
430 let mut chosen = None;
431 egui::MenuBar::new().ui(ui, |ui| {
432 let mut groups: Vec<&'static str> = Vec::new();
434 for c in commands {
435 if !groups.contains(&c.group) {
436 groups.push(c.group);
437 }
438 }
439 for g in groups {
440 ui.menu_button(g, |ui| {
441 let in_group: Vec<&Command> = commands.iter().filter(|c| c.group == g).collect();
442 for c in in_group {
443 let btn = egui::Button::new(menu_label(c)).wrap_mode(egui::TextWrapMode::Extend);
444 let resp = ui.add_enabled(c.enabled, btn);
445 if resp.clicked() {
446 chosen = Some(c.id);
447 ui.close();
448 }
449 }
450 });
451 }
452 });
453 chosen
454}
455
456fn menu_label(c: &Command) -> String {
459 let mut s = String::new();
460 if let Some(ic) = c.icon {
461 s.push_str(ic);
462 s.push(' ');
463 }
464 s.push_str(&c.label);
465 if let Some(sc) = c.shortcut {
466 s.push_str(" ");
467 s.push_str(sc);
468 }
469 s
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475
476 fn sample() -> Vec<Command> {
477 vec![
478 Command::new("copy", "Copy", "Edit").shortcut("Ctrl+C"),
479 Command::new("cut", "Cut", "Edit").enabled(false),
480 Command::new("paste", "Paste", "Edit"),
481 Command::new("case.new", "New case", "Case").keywords(&["create", "investigation"]),
482 Command::new("case.open", "Open case", "Case"),
483 Command::new("view.zoom", "Zoom in", "View"),
484 ]
485 }
486
487 #[test]
488 fn fuzzy_matches_subsequence_only() {
489 assert!(fuzzy_score("nc", "new case").is_some());
491 assert!(fuzzy_score("zzz", "copy").is_none());
493 assert_eq!(fuzzy_score("", "anything"), Some(0));
495 }
496
497 #[test]
498 fn fuzzy_prefers_word_start_and_contiguous() {
499 let contiguous = fuzzy_score("cop", "copy").unwrap();
501 let scattered = fuzzy_score("cy", "copy").unwrap();
502 assert!(contiguous > scattered, "contiguous {contiguous} should beat scattered {scattered}");
503 }
504
505 #[test]
506 fn rank_filters_disabled_and_ranks_relevant_first() {
507 let cmds = sample();
508 let hits = rank("", &cmds);
510 assert!(hits.iter().all(|c| c.id != "cut"), "disabled command must be filtered out");
511 let hits = rank("case", &cmds);
513 assert!(hits.len() >= 2);
514 assert!(hits[0].group == "Case" && hits[1].group == "Case", "case.* should rank first, got {:?}", hits.iter().map(|c| c.id).collect::<Vec<_>>());
515 }
516
517 #[test]
518 fn rank_matches_keywords_not_just_label() {
519 let cmds = sample();
520 let hits = rank("investigation", &cmds);
522 assert_eq!(hits.first().map(|c| c.id), Some("case.new"));
523 }
524
525 #[test]
526 fn cursor_wraps_both_directions() {
527 let mut p = CommandPalette::default();
528 let len = 3;
529 p.move_cursor(1, len); p.move_cursor(1, len); p.move_cursor(1, len); assert_eq!(p.cursor(), 0);
534 p.move_cursor(-1, len);
536 assert_eq!(p.cursor(), len - 1);
537 p.move_cursor(1, 0);
539 assert_eq!(p.cursor(), 0);
540 }
541
542 #[test]
543 fn invoke_sets_invoked_and_closes() {
544 let mut p = CommandPalette::default();
545 p.open();
546 assert!(p.is_open());
547 let id = p.invoke("copy");
548 assert_eq!(id, "copy");
549 assert_eq!(p.invoked(), Some("copy"));
550 assert!(!p.is_open(), "invoke closes the palette");
551 }
552
553 #[test]
554 fn state_json_carries_hits_and_invoked() {
555 let cmds = sample();
556 let mut p = CommandPalette::default();
557 p.open();
558 p.invoke("case.new");
559 let j = p.state_json(&cmds);
560 assert_eq!(j["invoked"], "case.new");
561 assert_eq!(j["commands"].as_array().unwrap().len(), cmds.len());
563 let hits: Vec<&str> = j["hits"].as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect();
565 assert!(!hits.contains(&"cut"));
566 }
567
568 #[test]
569 fn command_builders_compose() {
570 let c = Command::new("x", "X", "G").shortcut("Ctrl+X").icon(">").keywords(&["a", "b"]).enabled(false);
571 assert_eq!(c.shortcut, Some("Ctrl+X"));
572 assert_eq!(c.icon, Some(">"));
573 assert_eq!(c.keywords, vec!["a", "b"]);
574 assert!(!c.enabled);
575 }
576
577 #[test]
578 fn disabled_command_never_matched_by_palette() {
579 let cmds = sample();
581 let hits = rank("cut", &cmds);
582 assert!(hits.iter().all(|c| c.id != "cut"));
583 }
584}