facett-menu 0.1.1

facett — uniform overlay menu system (theme-aware right-click context menus, a fuzzy command palette overlay, and dropdown menu bars over one headless-addressable Command set)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
//! **facett-menu** — a uniform, theme-aware **overlay menu system** that sits
//! *above* any facet: right-click [`context_menu`]s, a floating [`CommandPalette`]
//! overlay (Ctrl-K / Ctrl-Shift-P), and classic dropdown [`menu_bar`]s. See
//! `.nornir/design/facett-menu.md`.
//!
//! ## One command set, three surfaces
//! A single [`Command`] type drives all three: the same action is reachable by
//! right-click, by typing in the palette, *and* by id from a headless test
//! ([`CommandPalette::invoke`]) — the house rule that nothing is pointer-only.
//! Commands carry a stable `id`, a `label`, a `group` (section / top-level menu),
//! an optional shortcut hint, an optional icon glyph, and an `enabled` flag.
//!
//! ## Theming
//! Every surface reads the facett [`Theme`](facett_core::Theme) from the egui
//! context ([`facett_core::theme`]) — `panel_bg` / `panel_stroke` / `accent` /
//! `glow` / `text` / `text_dim` — so menus match every component and the sci-fi
//! look. The selected palette row is painted with the theme `accent` plus a
//! layered `glow` bloom; nothing is hardcoded.
//!
//! ```no_run
//! # use facett_menu::{Command, CommandPalette, menu_bar, context_menu};
//! let cmds = vec![
//!     Command::new("copy", "Copy", "Edit"),
//!     Command::new("case.new", "New case", "Case"),
//! ];
//! let mut palette = CommandPalette::default();
//! // each frame, after drawing the deck:
//! # let ctx = egui::Context::default();
//! if let Some(id) = palette.ui(&ctx, &cmds) {
//!     // dispatch `id` ...
//! }
//! ```

use egui::{Align2, Color32, Key, Order, Stroke, Ui, vec2};
use facett_core::theme;

/// A user-invokable action. The same command appears in context menus, the
/// command palette, and menu bars — and can be fired by id from a headless test.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Command {
    /// Stable, headless-addressable id (`"copy"`, `"case.new"`).
    pub id: &'static str,
    /// Shown to the user.
    pub label: String,
    /// Section heading / top-level menu name (`"Edit"`, `"View"`, `"Case"`).
    pub group: &'static str,
    /// Extra search terms (matched by the palette, never shown).
    pub keywords: Vec<&'static str>,
    /// A human-readable shortcut hint shown right-aligned (`"Ctrl+C"`). Display
    /// only — the host owns the real key binding.
    pub shortcut: Option<&'static str>,
    /// Disabled commands are dimmed, unclickable, and never matched by the palette.
    pub enabled: bool,
    /// Optional leading glyph (e.g. an egui-phosphor icon char).
    pub icon: Option<&'static str>,
}

impl Command {
    /// A new, enabled command with no shortcut / icon / keywords.
    pub fn new(id: &'static str, label: impl Into<String>, group: &'static str) -> Self {
        Self { id, label: label.into(), group, keywords: Vec::new(), shortcut: None, enabled: true, icon: None }
    }
    /// Builder: attach a display shortcut hint.
    pub fn shortcut(mut self, s: &'static str) -> Self {
        self.shortcut = Some(s);
        self
    }
    /// Builder: attach a leading icon glyph.
    pub fn icon(mut self, glyph: &'static str) -> Self {
        self.icon = Some(glyph);
        self
    }
    /// Builder: extra search keywords (palette only).
    pub fn keywords(mut self, kw: &[&'static str]) -> Self {
        self.keywords = kw.to_vec();
        self
    }
    /// Builder: set the enabled flag (disabled = dimmed + unmatchable).
    pub fn enabled(mut self, on: bool) -> Self {
        self.enabled = on;
        self
    }

    /// The text the palette fuzzy-matches against: label + id + keywords.
    fn haystack(&self) -> String {
        let mut h = format!("{} {}", self.label, self.id);
        for k in &self.keywords {
            h.push(' ');
            h.push_str(k);
        }
        h.to_lowercase()
    }
}

/// Subsequence fuzzy score: `None` if `needle`'s chars don't appear in order in
/// `haystack`. Otherwise a higher score is a better match — contiguous runs and
/// matches at word starts (after a space / `.` / `_`) are rewarded, and a tighter
/// overall span scores higher. An empty needle matches everything with score 0.
fn fuzzy_score(needle: &str, haystack: &str) -> Option<i32> {
    if needle.is_empty() {
        return Some(0);
    }
    let hay: Vec<char> = haystack.chars().collect();
    let mut score = 0i32;
    let mut hi = 0usize;
    let mut first = None;
    let mut last = 0usize;
    let mut prev_matched = false;
    for nc in needle.chars() {
        // advance to the next occurrence of nc
        let mut found = false;
        while hi < hay.len() {
            if hay[hi] == nc {
                found = true;
                break;
            }
            hi += 1;
        }
        if !found {
            return None;
        }
        if first.is_none() {
            first = Some(hi);
        }
        last = hi;
        score += 1;
        if prev_matched {
            score += 5; // contiguous run bonus
        }
        let at_word_start = hi == 0 || matches!(hay[hi - 1], ' ' | '.' | '_' | '-' | '/');
        if at_word_start {
            score += 3;
        }
        prev_matched = true;
        hi += 1;
    }
    // tighter span is better; penalise sprawl gently
    let span = last - first.unwrap_or(0);
    score -= span as i32 / 4;
    Some(score)
}

/// The commands an enabled palette query matches, best-ranked first. Public so a
/// host (or a headless test) can reproduce exactly what the palette would show.
/// Ties keep registry order (stable sort) so the layout is deterministic.
pub fn rank<'a>(query: &str, commands: &'a [Command]) -> Vec<&'a Command> {
    let q = query.to_lowercase();
    let mut scored: Vec<(i32, usize, &Command)> = commands
        .iter()
        .enumerate()
        .filter(|(_, c)| c.enabled)
        .filter_map(|(i, c)| fuzzy_score(&q, &c.haystack()).map(|s| (s, i, c)))
        .collect();
    // higher score first; ties → original order
    scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
    scored.into_iter().map(|(_, _, c)| c).collect()
}

/// A floating, theme-aware **command palette** overlay. Opened by Ctrl-K /
/// Ctrl-Shift-P (or [`CommandPalette::open`]), it paints a scrim + a centered
/// search box + a fuzzy-filtered, keyboard-navigable command list above
/// everything (`Order::Foreground`). Pure data between frames; draw it once per
/// frame *after* your deck so it lands on top.
#[derive(Default)]
pub struct CommandPalette {
    open: bool,
    query: String,
    cursor: usize,
    invoked: Option<&'static str>,
}

impl CommandPalette {
    /// Open the palette, clearing the query and selection.
    pub fn open(&mut self) {
        self.open = true;
        self.query.clear();
        self.cursor = 0;
    }
    /// Close the palette (does not clear `invoked`).
    pub fn close(&mut self) {
        self.open = false;
    }
    /// Is the overlay currently showing?
    pub fn is_open(&self) -> bool {
        self.open
    }
    /// The last command fired (by pick or [`invoke`](Self::invoke)).
    pub fn invoked(&self) -> Option<&'static str> {
        self.invoked
    }
    /// Current query text (observable for tests).
    pub fn query(&self) -> &str {
        &self.query
    }
    /// Current highlighted-row index, into [`rank`]'s result for the query.
    pub fn cursor(&self) -> usize {
        self.cursor
    }

    /// Move the selection by `delta`, **wrapping** within `len` rows. No-op when
    /// `len == 0`. Up at the top wraps to the bottom; down at the bottom wraps to
    /// the top. Split out so headless tests can drive nav without drawing.
    pub fn move_cursor(&mut self, delta: i32, len: usize) {
        if len == 0 {
            self.cursor = 0;
            return;
        }
        let n = len as i32;
        self.cursor = (((self.cursor as i32 + delta) % n + n) % n) as usize;
    }

    /// Headless: fire a command by id without drawing (CLI / test parity). Sets
    /// `invoked`, closes the palette, and echoes the id back.
    pub fn invoke(&mut self, id: &'static str) -> &'static str {
        self.invoked = Some(id);
        self.open = false;
        id
    }

    /// Observable state for the introspection dump.
    pub fn state_json(&self, commands: &[Command]) -> serde_json::Value {
        serde_json::json!({
            "open": self.open,
            "query": self.query,
            "cursor": self.cursor,
            "commands": commands.iter().map(|c| c.id).collect::<Vec<_>>(),
            "hits": rank(&self.query, commands).iter().map(|c| c.id).collect::<Vec<_>>(),
            "invoked": self.invoked,
        })
    }

    /// Read the open/close hotkeys for this frame (Ctrl-K / Ctrl-Shift-P open,
    /// Esc closes). Call from a host that wants the palette toggled even on a
    /// frame it doesn't draw; [`ui`](Self::ui) calls this itself.
    pub fn handle_hotkeys(&mut self, ctx: &egui::Context) {
        ctx.input(|i| {
            let cmd = i.modifiers.command;
            if cmd && i.key_pressed(Key::K) {
                self.open = true;
                self.query.clear();
                self.cursor = 0;
            }
            if cmd && i.modifiers.shift && i.key_pressed(Key::P) {
                self.open = true;
                self.query.clear();
                self.cursor = 0;
            }
            if i.key_pressed(Key::Escape) {
                self.open = false;
            }
        });
    }

    /// Draw the overlay (scrim + search box + fuzzy-filtered list) above
    /// everything, handling Ctrl-K to open, arrows (wrapping) to navigate, Enter
    /// to pick, Esc to close. Returns the chosen command id, if any. No-op (and
    /// returns `None`) while closed.
    pub fn ui(&mut self, ctx: &egui::Context, commands: &[Command]) -> Option<&'static str> {
        self.handle_hotkeys(ctx);
        if !self.open {
            return None;
        }

        // Theme: read via a throwaway pass-free path — the context holds it.
        let th = ctx.data(|d| d.get_temp::<facett_core::Theme>(egui::Id::new("facett_theme"))).unwrap_or_default();
        let screen = ctx.content_rect();

        // Scrim behind the palette.
        egui::Area::new("facett_palette_scrim".into())
            .order(Order::Foreground)
            .fixed_pos(screen.min)
            .interactable(false)
            .show(ctx, |ui| {
                ui.painter().rect_filled(screen, 0.0, Color32::from_black_alpha(160));
            });

        let mut chosen = None;
        egui::Area::new("facett_palette".into())
            .order(Order::Foreground)
            .anchor(Align2::CENTER_TOP, vec2(0.0, 80.0))
            .show(ctx, |ui| {
                egui::Frame::popup(ui.style())
                    .fill(th.panel_bg)
                    .stroke(Stroke::new(1.0, th.panel_stroke))
                    .show(ui, |ui| {
                        ui.set_width(520.0);

                        let edit = ui.add(
                            egui::TextEdit::singleline(&mut self.query).hint_text("Type a command…").desired_width(f32::INFINITY),
                        );
                        edit.request_focus();

                        let hits = rank(&self.query, commands);

                        // Keyboard nav (wrapping) + Enter/Esc. Read before the
                        // TextEdit had a chance to swallow the arrows is not
                        // possible here, but arrows aren't consumed by a
                        // singleline edit, so this is fine.
                        let (down, up, enter) = ui.input(|i| {
                            (i.key_pressed(Key::ArrowDown), i.key_pressed(Key::ArrowUp), i.key_pressed(Key::Enter))
                        });
                        if down {
                            self.move_cursor(1, hits.len());
                        }
                        if up {
                            self.move_cursor(-1, hits.len());
                        }
                        if hits.is_empty() {
                            self.cursor = 0;
                        } else if self.cursor >= hits.len() {
                            self.cursor = hits.len() - 1;
                        }

                        ui.separator();

                        for (i, c) in hits.iter().enumerate() {
                            let sel = i == self.cursor;
                            let row = row_text(c);
                            let resp = ui.selectable_label(sel, row);
                            if sel {
                                // accent underline + glow bloom on the selected row
                                let r = resp.rect;
                                let p = ui.painter();
                                for w in 1..=4 {
                                    let a = (70 / w) as u8;
                                    let g = Color32::from_rgba_unmultiplied(th.glow.r(), th.glow.g(), th.glow.b(), a);
                                    p.line_segment(
                                        [r.left_bottom() + vec2(0.0, w as f32), r.right_bottom() + vec2(0.0, w as f32)],
                                        Stroke::new(1.0, g),
                                    );
                                }
                                p.line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.5, th.accent));
                            }
                            if resp.clicked() {
                                chosen = Some(c.id);
                            }
                        }

                        if hits.is_empty() {
                            ui.weak("No matching commands");
                        }

                        if enter {
                            chosen = hits.get(self.cursor).map(|c| c.id);
                        }
                    });
            });

        if let Some(id) = chosen {
            self.open = false;
            self.invoked = Some(id);
        }
        chosen
    }
}

/// One palette/menu row label: `icon  label … shortcut` (icon/shortcut omitted
/// when absent). The group is *not* appended here — the menu bar / context menu
/// already group by it; the palette shows it via the trailing dim group below.
fn row_text(c: &Command) -> String {
    let mut s = String::new();
    if let Some(ic) = c.icon {
        s.push_str(ic);
        s.push(' ');
    }
    s.push_str(&c.label);
    s.push_str("   ·  ");
    s.push_str(c.group);
    if let Some(sc) = c.shortcut {
        s.push_str("   ");
        s.push_str(sc);
    }
    s
}

/// A right-click **context menu** built from the same [`Command`] set. Call from
/// inside `response.context_menu(|ui| { … })` — or use [`attach_context_menu`] to
/// wrap a [`Response`](egui::Response) directly. Commands are grouped by their
/// `group` with separators between groups; disabled commands render dimmed and
/// unclickable. Returns the chosen id (and closes the menu) when one is picked.
pub fn context_menu(ui: &mut Ui, commands: &[Command]) -> Option<&'static str> {
    let th = theme(ui);
    let mut chosen = None;
    let mut last_group: Option<&'static str> = None;
    for c in commands {
        if last_group.is_some() && last_group != Some(c.group) {
            ui.separator();
        }
        last_group = Some(c.group);

        let btn = egui::Button::new(menu_label(c)).wrap_mode(egui::TextWrapMode::Extend);
        let resp = ui.add_enabled(c.enabled, btn);
        // theme-aware hover accent
        if resp.hovered() && c.enabled {
            let r = resp.rect;
            ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, th.accent));
        }
        if resp.clicked() {
            chosen = Some(c.id);
            ui.close();
        }
    }
    chosen
}

/// Attach a theme-aware right-click [`context_menu`] to any
/// [`Response`](egui::Response), threading the picked command id out through
/// `on_pick`. The closure fires at most once, on the frame an item is clicked.
///
/// ```no_run
/// # use facett_menu::{Command, attach_context_menu};
/// # let ui: &mut egui::Ui = todo!();
/// let cmds = [Command::new("copy", "Copy", "Edit")];
/// let resp = ui.label("right-click me");
/// attach_context_menu(&resp, &cmds, |id| { /* dispatch id */ let _ = id; });
/// ```
pub fn attach_context_menu(response: &egui::Response, commands: &[Command], mut on_pick: impl FnMut(&'static str)) {
    response.context_menu(|ui| {
        if let Some(id) = context_menu(ui, commands) {
            on_pick(id);
        }
    });
}

/// A classic **menu bar** from grouped commands: each distinct `group` becomes a
/// top-level dropdown (`File` / `Edit` / …) holding its commands, in first-seen
/// order. Theme-aware via the facett `Theme`. Returns the chosen id, if any.
/// Call inside an `egui::menu::bar(ui, |ui| { … })` or a top panel.
pub fn menu_bar(ui: &mut Ui, commands: &[Command]) -> Option<&'static str> {
    let mut chosen = None;
    egui::MenuBar::new().ui(ui, |ui| {
        // first-seen group order
        let mut groups: Vec<&'static str> = Vec::new();
        for c in commands {
            if !groups.contains(&c.group) {
                groups.push(c.group);
            }
        }
        for g in groups {
            ui.menu_button(g, |ui| {
                let in_group: Vec<&Command> = commands.iter().filter(|c| c.group == g).collect();
                for c in in_group {
                    let btn = egui::Button::new(menu_label(c)).wrap_mode(egui::TextWrapMode::Extend);
                    let resp = ui.add_enabled(c.enabled, btn);
                    if resp.clicked() {
                        chosen = Some(c.id);
                        ui.close();
                    }
                }
            });
        }
    });
    chosen
}

/// A menu/context-menu row label: `icon  label … shortcut` (no group — the menu
/// already groups). Right-padding before the shortcut keeps it loosely aligned.
fn menu_label(c: &Command) -> String {
    let mut s = String::new();
    if let Some(ic) = c.icon {
        s.push_str(ic);
        s.push(' ');
    }
    s.push_str(&c.label);
    if let Some(sc) = c.shortcut {
        s.push_str("      ");
        s.push_str(sc);
    }
    s
}

#[cfg(test)]
mod tests {
    use super::*;

    fn sample() -> Vec<Command> {
        vec![
            Command::new("copy", "Copy", "Edit").shortcut("Ctrl+C"),
            Command::new("cut", "Cut", "Edit").enabled(false),
            Command::new("paste", "Paste", "Edit"),
            Command::new("case.new", "New case", "Case").keywords(&["create", "investigation"]),
            Command::new("case.open", "Open case", "Case"),
            Command::new("view.zoom", "Zoom in", "View"),
        ]
    }

    #[test]
    fn fuzzy_matches_subsequence_only() {
        // "nc" is a subsequence of "new case"
        assert!(fuzzy_score("nc", "new case").is_some());
        // "zzz" is not in "copy"
        assert!(fuzzy_score("zzz", "copy").is_none());
        // empty needle matches with score 0
        assert_eq!(fuzzy_score("", "anything"), Some(0));
    }

    #[test]
    fn fuzzy_prefers_word_start_and_contiguous() {
        // contiguous prefix "cop" beats scattered "cpy"
        let contiguous = fuzzy_score("cop", "copy").unwrap();
        let scattered = fuzzy_score("cy", "copy").unwrap();
        assert!(contiguous > scattered, "contiguous {contiguous} should beat scattered {scattered}");
    }

    #[test]
    fn rank_filters_disabled_and_ranks_relevant_first() {
        let cmds = sample();
        // "Cut" is disabled → never appears
        let hits = rank("", &cmds);
        assert!(hits.iter().all(|c| c.id != "cut"), "disabled command must be filtered out");
        // querying "case" surfaces the two case commands ahead of the rest
        let hits = rank("case", &cmds);
        assert!(hits.len() >= 2);
        assert!(hits[0].group == "Case" && hits[1].group == "Case", "case.* should rank first, got {:?}", hits.iter().map(|c| c.id).collect::<Vec<_>>());
    }

    #[test]
    fn rank_matches_keywords_not_just_label() {
        let cmds = sample();
        // "investigation" is a keyword of case.new, not in any label
        let hits = rank("investigation", &cmds);
        assert_eq!(hits.first().map(|c| c.id), Some("case.new"));
    }

    #[test]
    fn cursor_wraps_both_directions() {
        let mut p = CommandPalette::default();
        let len = 3;
        // down past the end wraps to 0
        p.move_cursor(1, len); // 1
        p.move_cursor(1, len); // 2
        p.move_cursor(1, len); // wrap → 0
        assert_eq!(p.cursor(), 0);
        // up from 0 wraps to len-1
        p.move_cursor(-1, len);
        assert_eq!(p.cursor(), len - 1);
        // empty list keeps cursor at 0
        p.move_cursor(1, 0);
        assert_eq!(p.cursor(), 0);
    }

    #[test]
    fn invoke_sets_invoked_and_closes() {
        let mut p = CommandPalette::default();
        p.open();
        assert!(p.is_open());
        let id = p.invoke("copy");
        assert_eq!(id, "copy");
        assert_eq!(p.invoked(), Some("copy"));
        assert!(!p.is_open(), "invoke closes the palette");
    }

    #[test]
    fn state_json_carries_hits_and_invoked() {
        let cmds = sample();
        let mut p = CommandPalette::default();
        p.open();
        p.invoke("case.new");
        let j = p.state_json(&cmds);
        assert_eq!(j["invoked"], "case.new");
        // every registered command id is listed
        assert_eq!(j["commands"].as_array().unwrap().len(), cmds.len());
        // disabled "cut" is absent from hits
        let hits: Vec<&str> = j["hits"].as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect();
        assert!(!hits.contains(&"cut"));
    }

    #[test]
    fn command_builders_compose() {
        let c = Command::new("x", "X", "G").shortcut("Ctrl+X").icon(">").keywords(&["a", "b"]).enabled(false);
        assert_eq!(c.shortcut, Some("Ctrl+X"));
        assert_eq!(c.icon, Some(">"));
        assert_eq!(c.keywords, vec!["a", "b"]);
        assert!(!c.enabled);
    }

    #[test]
    fn disabled_command_never_matched_by_palette() {
        // even an exact id query can't surface a disabled command
        let cmds = sample();
        let hits = rank("cut", &cmds);
        assert!(hits.iter().all(|c| c.id != "cut"));
    }
}