Skip to main content

facett_menu/
lib.rs

1//! **facett-menu** — a uniform, theme-aware **overlay menu system** that sits
2//! *above* any facet: right-click [`context_menu`]s, a floating [`CommandPalette`]
3//! overlay (Ctrl-K / Ctrl-Shift-P), and classic dropdown [`menu_bar`]s. See
4//! `.nornir/design/facett-menu.md`.
5//!
6//! ## One command set, three surfaces
7//! A single [`Command`] type drives all three: the same action is reachable by
8//! right-click, by typing in the palette, *and* by id from a headless test
9//! ([`CommandPalette::invoke`]) — the house rule that nothing is pointer-only.
10//! Commands carry a stable `id`, a `label`, a `group` (section / top-level menu),
11//! an optional shortcut hint, an optional icon glyph, and an `enabled` flag.
12//!
13//! ## Theming
14//! Every surface reads the facett [`Theme`](facett_core::Theme) from the egui
15//! context ([`facett_core::theme`]) — `panel_bg` / `panel_stroke` / `accent` /
16//! `glow` / `text` / `text_dim` — so menus match every component and the sci-fi
17//! look. The selected palette row is painted with the theme `accent` plus a
18//! layered `glow` bloom; nothing is hardcoded.
19//!
20//! ```no_run
21//! # use facett_menu::{Command, CommandPalette, menu_bar, context_menu};
22//! let cmds = vec![
23//!     Command::new("copy", "Copy", "Edit"),
24//!     Command::new("case.new", "New case", "Case"),
25//! ];
26//! let mut palette = CommandPalette::default();
27//! // each frame, after drawing the deck:
28//! # let ctx = egui::Context::default();
29//! if let Some(id) = palette.ui(&ctx, &cmds) {
30//!     // dispatch `id` ...
31//! }
32//! ```
33
34use egui::{Align2, Color32, Key, Order, Stroke, Ui, vec2};
35use facett_core::theme;
36
37/// A user-invokable action. The same command appears in context menus, the
38/// command palette, and menu bars — and can be fired by id from a headless test.
39#[derive(Clone, Debug, PartialEq, Eq)]
40pub struct Command {
41    /// Stable, headless-addressable id (`"copy"`, `"case.new"`).
42    pub id: &'static str,
43    /// Shown to the user.
44    pub label: String,
45    /// Section heading / top-level menu name (`"Edit"`, `"View"`, `"Case"`).
46    pub group: &'static str,
47    /// Extra search terms (matched by the palette, never shown).
48    pub keywords: Vec<&'static str>,
49    /// A human-readable shortcut hint shown right-aligned (`"Ctrl+C"`). Display
50    /// only — the host owns the real key binding.
51    pub shortcut: Option<&'static str>,
52    /// Disabled commands are dimmed, unclickable, and never matched by the palette.
53    pub enabled: bool,
54    /// Optional leading glyph (e.g. an egui-phosphor icon char).
55    pub icon: Option<&'static str>,
56}
57
58impl Command {
59    /// A new, enabled command with no shortcut / icon / keywords.
60    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    /// Builder: attach a display shortcut hint.
64    pub fn shortcut(mut self, s: &'static str) -> Self {
65        self.shortcut = Some(s);
66        self
67    }
68    /// Builder: attach a leading icon glyph.
69    pub fn icon(mut self, glyph: &'static str) -> Self {
70        self.icon = Some(glyph);
71        self
72    }
73    /// Builder: extra search keywords (palette only).
74    pub fn keywords(mut self, kw: &[&'static str]) -> Self {
75        self.keywords = kw.to_vec();
76        self
77    }
78    /// Builder: set the enabled flag (disabled = dimmed + unmatchable).
79    pub fn enabled(mut self, on: bool) -> Self {
80        self.enabled = on;
81        self
82    }
83
84    /// The text the palette fuzzy-matches against: label + id + keywords.
85    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
95/// Subsequence fuzzy score: `None` if `needle`'s chars don't appear in order in
96/// `haystack`. Otherwise a higher score is a better match — contiguous runs and
97/// matches at word starts (after a space / `.` / `_`) are rewarded, and a tighter
98/// overall span scores higher. An empty needle matches everything with score 0.
99fn 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        // advance to the next occurrence of nc
111        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; // contiguous run bonus
129        }
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    // tighter span is better; penalise sprawl gently
138    let span = last - first.unwrap_or(0);
139    score -= span as i32 / 4;
140    Some(score)
141}
142
143/// The commands an enabled palette query matches, best-ranked first. Public so a
144/// host (or a headless test) can reproduce exactly what the palette would show.
145/// Ties keep registry order (stable sort) so the layout is deterministic.
146pub 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    // higher score first; ties → original order
155    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/// A floating, theme-aware **command palette** overlay. Opened by Ctrl-K /
160/// Ctrl-Shift-P (or [`CommandPalette::open`]), it paints a scrim + a centered
161/// search box + a fuzzy-filtered, keyboard-navigable command list above
162/// everything (`Order::Foreground`). Pure data between frames; draw it once per
163/// frame *after* your deck so it lands on top.
164#[derive(Default)]
165pub struct CommandPalette {
166    open: bool,
167    query: String,
168    cursor: usize,
169    invoked: Option<&'static str>,
170}
171
172impl CommandPalette {
173    /// Open the palette, clearing the query and selection.
174    pub fn open(&mut self) {
175        self.open = true;
176        self.query.clear();
177        self.cursor = 0;
178    }
179    /// Close the palette (does not clear `invoked`).
180    pub fn close(&mut self) {
181        self.open = false;
182    }
183    /// Is the overlay currently showing?
184    pub fn is_open(&self) -> bool {
185        self.open
186    }
187    /// The last command fired (by pick or [`invoke`](Self::invoke)).
188    pub fn invoked(&self) -> Option<&'static str> {
189        self.invoked
190    }
191    /// Current query text (observable for tests).
192    pub fn query(&self) -> &str {
193        &self.query
194    }
195    /// Current highlighted-row index, into [`rank`]'s result for the query.
196    pub fn cursor(&self) -> usize {
197        self.cursor
198    }
199
200    /// Move the selection by `delta`, **wrapping** within `len` rows. No-op when
201    /// `len == 0`. Up at the top wraps to the bottom; down at the bottom wraps to
202    /// the top. Split out so headless tests can drive nav without drawing.
203    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    /// Headless: fire a command by id without drawing (CLI / test parity). Sets
213    /// `invoked`, closes the palette, and echoes the id back.
214    pub fn invoke(&mut self, id: &'static str) -> &'static str {
215        self.invoked = Some(id);
216        self.open = false;
217        id
218    }
219
220    /// Observable state for the introspection dump.
221    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    /// Read the open/close hotkeys for this frame (Ctrl-K / Ctrl-Shift-P open,
233    /// Esc closes). Call from a host that wants the palette toggled even on a
234    /// frame it doesn't draw; [`ui`](Self::ui) calls this itself.
235    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    /// Draw the overlay (scrim + search box + fuzzy-filtered list) above
255    /// everything, handling Ctrl-K to open, arrows (wrapping) to navigate, Enter
256    /// to pick, Esc to close. Returns the chosen command id, if any. No-op (and
257    /// returns `None`) while closed.
258    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        // Theme: read via a throwaway pass-free path — the context holds it.
265        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        // Scrim behind the palette.
269        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                        // Keyboard nav (wrapping) + Enter/Esc. Read before the
296                        // TextEdit had a chance to swallow the arrows is not
297                        // possible here, but arrows aren't consumed by a
298                        // singleline edit, so this is fine.
299                        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                                // accent underline + glow bloom on the selected row
322                                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
357/// One palette/menu row label: `icon  label … shortcut` (icon/shortcut omitted
358/// when absent). The group is *not* appended here — the menu bar / context menu
359/// already group by it; the palette shows it via the trailing dim group below.
360fn 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
376/// A right-click **context menu** built from the same [`Command`] set. Call from
377/// inside `response.context_menu(|ui| { … })` — or use [`attach_context_menu`] to
378/// wrap a [`Response`](egui::Response) directly. Commands are grouped by their
379/// `group` with separators between groups; disabled commands render dimmed and
380/// unclickable. Returns the chosen id (and closes the menu) when one is picked.
381pub 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        // theme-aware hover accent
394        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
406/// Attach a theme-aware right-click [`context_menu`] to any
407/// [`Response`](egui::Response), threading the picked command id out through
408/// `on_pick`. The closure fires at most once, on the frame an item is clicked.
409///
410/// ```no_run
411/// # use facett_menu::{Command, attach_context_menu};
412/// # let ui: &mut egui::Ui = todo!();
413/// let cmds = [Command::new("copy", "Copy", "Edit")];
414/// let resp = ui.label("right-click me");
415/// attach_context_menu(&resp, &cmds, |id| { /* dispatch id */ let _ = id; });
416/// ```
417pub 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
425/// A classic **menu bar** from grouped commands: each distinct `group` becomes a
426/// top-level dropdown (`File` / `Edit` / …) holding its commands, in first-seen
427/// order. Theme-aware via the facett `Theme`. Returns the chosen id, if any.
428/// Call inside an `egui::menu::bar(ui, |ui| { … })` or a top panel.
429pub fn menu_bar(ui: &mut Ui, commands: &[Command]) -> Option<&'static str> {
430    let mut chosen = None;
431    egui::MenuBar::new().ui(ui, |ui| {
432        // first-seen group order
433        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
456/// A menu/context-menu row label: `icon  label … shortcut` (no group — the menu
457/// already groups). Right-padding before the shortcut keeps it loosely aligned.
458fn 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        // "nc" is a subsequence of "new case"
490        assert!(fuzzy_score("nc", "new case").is_some());
491        // "zzz" is not in "copy"
492        assert!(fuzzy_score("zzz", "copy").is_none());
493        // empty needle matches with score 0
494        assert_eq!(fuzzy_score("", "anything"), Some(0));
495    }
496
497    #[test]
498    fn fuzzy_prefers_word_start_and_contiguous() {
499        // contiguous prefix "cop" beats scattered "cpy"
500        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        // "Cut" is disabled → never appears
509        let hits = rank("", &cmds);
510        assert!(hits.iter().all(|c| c.id != "cut"), "disabled command must be filtered out");
511        // querying "case" surfaces the two case commands ahead of the rest
512        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        // "investigation" is a keyword of case.new, not in any label
521        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        // down past the end wraps to 0
530        p.move_cursor(1, len); // 1
531        p.move_cursor(1, len); // 2
532        p.move_cursor(1, len); // wrap → 0
533        assert_eq!(p.cursor(), 0);
534        // up from 0 wraps to len-1
535        p.move_cursor(-1, len);
536        assert_eq!(p.cursor(), len - 1);
537        // empty list keeps cursor at 0
538        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        // every registered command id is listed
562        assert_eq!(j["commands"].as_array().unwrap().len(), cmds.len());
563        // disabled "cut" is absent from hits
564        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        // even an exact id query can't surface a disabled command
580        let cmds = sample();
581        let hits = rank("cut", &cmds);
582        assert!(hits.iter().all(|c| c.id != "cut"));
583    }
584}