Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-menu/src/lib.rs

//! **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 — plus
//! [`MenuView`], the Elm-shaped [`Facet`](facett_core::Facet) wrapping a menubar +
//! palette (headless-drivable via `update_json`/`state_json`, emitting an
//! [`Effect::Selected`] the host runs). See `.nornir/design.md` + `.nornir/guide.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;
use serde::{Deserialize, Serialize};

/// **Introspection / emit marker** — record one functional-status row for the
/// nornir test matrix. Wraps [`nornir_testmatrix::functional_status`] behind the
/// `testmatrix` feature; a compiled-out `#[inline]` no-op otherwise (and no
/// nornir dep in the default build). `component` is the reporting surface
/// (`"facett-menu/palette"`), `check` what it verified, `ok` the verdict,
/// `detail` a short human note. The command surfaces call this so `nornir test`
/// SEES each menu pick.
#[inline]
pub(crate) fn functional_status(component: &str, check: &str, ok: bool, detail: &str) {
    #[cfg(feature = "testmatrix")]
    nornir_testmatrix::functional_status(component, check, ok, detail);
    #[cfg(not(feature = "testmatrix"))]
    {
        let _ = (component, check, ok, detail);
    }
}

/// 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.
/// Serde-round-trippable so it can live inside a serializable [`MenuState`].
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Command {
    /// Stable, headless-addressable id (`"copy"`, `"case.new"`). **Owned** so a
    /// runtime source (e.g. a `facett_app::NavModel`) can build commands, not just
    /// compile-time-static literals.
    pub id: String,
    /// Shown to the user.
    pub label: String,
    /// Section heading / top-level menu name (`"Edit"`, `"View"`, `"Case"`).
    pub group: String,
    /// Extra search terms (matched by the palette, never shown).
    pub keywords: Vec<String>,
    /// A human-readable shortcut hint shown right-aligned (`"Ctrl+C"`). Display
    /// only — the host owns the real key binding.
    pub shortcut: Option<String>,
    /// 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<String>,
}

impl Command {
    /// A new, enabled command with no shortcut / icon / keywords.
    pub fn new(id: impl Into<String>, label: impl Into<String>, group: impl Into<String>) -> Self {
        Self { id: id.into(), label: label.into(), group: group.into(), keywords: Vec::new(), shortcut: None, enabled: true, icon: None }
    }
    /// Builder: attach a display shortcut hint.
    pub fn shortcut(mut self, s: impl Into<String>) -> Self {
        self.shortcut = Some(s.into());
        self
    }
    /// Builder: attach a leading icon glyph.
    pub fn icon(mut self, glyph: impl Into<String>) -> Self {
        self.icon = Some(glyph.into());
        self
    }
    /// Builder: extra search keywords (palette only).
    pub fn keywords(mut self, kw: impl IntoIterator<Item = impl Into<String>>) -> Self {
        self.keywords = kw.into_iter().map(Into::into).collect();
        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<String>,
}

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<&str> {
        self.invoked.as_deref()
    }
    /// 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: impl Into<String>) -> String {
        let id = id.into();
        self.invoked = Some(id.clone());
        self.open = false;
        functional_status("facett-menu/palette", "invoke", true, &id);
        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<String> {
        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_f32, 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_f32, g),
                                    );
                                }
                                p.line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.5_f32, th.accent));
                            }
                            if resp.clicked() {
                                chosen = Some(c.id.clone());
                            }
                        }

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

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

        if let Some(id) = &chosen {
            self.open = false;
            self.invoked = Some(id.clone());
            functional_status("facett-menu/palette", "pick", true, 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<String> {
    let th = theme(ui);
    let mut chosen = None;
    let mut last_group: Option<&str> = None;
    for c in commands {
        if last_group.is_some() && last_group != Some(c.group.as_str()) {
            ui.separator();
        }
        last_group = Some(c.group.as_str());

        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_f32, th.accent));
        }
        if resp.clicked() {
            chosen = Some(c.id.clone());
            functional_status("facett-menu/context", "pick", true, &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(&str)) {
    response.context_menu(|ui| {
        if let Some(id) = context_menu(ui, commands) {
            on_pick(&id);
        }
    });
}

/// The **canonical Tools top-menu label** (`🧰 Tools`). The shared convention across
/// every suite app: the utilities/Tools menu (where e.g. the zone-populate
/// [`ZonePane`](../facett_zoneui/struct.ZonePane.html) is dropped) uses THIS group
/// string, and [`menu_bar`] always renders it as the **LAST** top-level entry — one
/// place, so the ordering is identical in every app (korp / dwarves / nornir / …).
pub const TOOLS_MENU: &str = "🧰 Tools";

/// Is `group` the Tools top-menu? Matches the canonical [`TOOLS_MENU`], any group
/// carrying the 🧰 toolbox glyph, or a plain case-insensitive `"Tools"` — so an app
/// that hasn't adopted the constant yet still gets Tools sorted last.
#[must_use]
pub fn is_tools_menu(group: &str) -> bool {
    group == TOOLS_MENU || group.contains('🧰') || group.trim().eq_ignore_ascii_case("tools")
}

/// The **top-level menu order** a [`menu_bar`] renders: distinct `group`s in
/// first-seen order, EXCEPT every Tools menu ([`is_tools_menu`]) is moved to the
/// END (keeping their own first-seen order) — the shared "Tools is always the last
/// top menu" convention, computed in ONE place so every app agrees. Pure; the
/// RED-when-broken seam the menu-order test drives.
#[must_use]
pub fn menubar_group_order(commands: &[Command]) -> Vec<&str> {
    let mut groups: Vec<&str> = Vec::new();
    for c in commands {
        if !groups.contains(&c.group.as_str()) {
            groups.push(c.group.as_str());
        }
    }
    // Stable partition: non-Tools groups keep first-seen order, Tools go last.
    let (tools, rest): (Vec<&str>, Vec<&str>) = groups.into_iter().partition(|g| is_tools_menu(g));
    rest.into_iter().chain(tools).collect()
}

/// A classic **menu bar** from grouped commands: each distinct `group` becomes a
/// top-level dropdown (`File` / `Edit` / …) holding its commands, in first-seen
/// order — EXCEPT the [`TOOLS_MENU`] (🧰 Tools) is always rendered **last** (the
/// shared convention, via [`menubar_group_order`]). Theme-aware via the facett
/// `Theme`. Returns the chosen id, if any. Call inside an `egui::menu::bar` / a top panel.
pub fn menu_bar(ui: &mut Ui, commands: &[Command]) -> Option<String> {
    let mut chosen = None;
    egui::MenuBar::new().ui(ui, |ui| {
        // Tools-last top-menu order (shared convention, one place).
        let groups = menubar_group_order(commands);
        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.clone());
                        functional_status("facett-menu/menubar", "pick", true, &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
}

// ─────────────────────────────────────────────────────────────────────────────
// The Elm-shaped Facet: `MenuView`
// ─────────────────────────────────────────────────────────────────────────────
//
// The three free surfaces above (`context_menu` / `menu_bar` / `CommandPalette`)
// are the immediate-mode primitives a host can splice anywhere. `MenuView` wraps
// the **same** `Command` set into the canonical facett Elm split — one
// serializable [`MenuState`] (FC-3), one [`Msg`] input surface (FC-2), a pure
// [`view`](MenuView::view) (FC-9), and an [`Effect`] the host runs (FC-8) — so a
// menubar + command palette can be dropped into a `FacetDeck` and driven
// head-lessly by JSON (`update_json`/`state_json`), exactly like every other
// facett component.

/// **The Model (FC-1 / FC-3).** All of a [`MenuView`]'s observable state in one
/// serializable, round-trippable struct: the `commands` registry, whether the
/// command `palette_open` overlay is showing, the palette `query` + highlighted
/// `cursor` row, and the id of the `last_selected` command (the thing the host
/// last acted on). `commands` lives in the model so a headless driver can read —
/// and a host can hot-swap — the whole action set.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
pub struct MenuState {
    /// The full action registry (drives menubar, context menu, and palette).
    pub commands: Vec<Command>,
    /// Is the fuzzy command-palette overlay showing?
    #[serde(default)]
    pub palette_open: bool,
    /// Current palette query text.
    #[serde(default)]
    pub query: String,
    /// Highlighted row index into [`rank`]'s result for the current query.
    #[serde(default)]
    pub cursor: usize,
    /// The id of the command last selected (by any surface) — the last thing the
    /// host was asked to run.
    #[serde(default)]
    pub last_selected: Option<String>,
}

/// **Every input a [`MenuView`] accepts (FC-2).** The only legal way to mutate
/// [`MenuState`] — funneled through [`MenuView::update`]. Externally-tagged serde
/// so a headless driver can post JSON: `"open_palette"`, `{"set_query":"case"}`,
/// `{"move_cursor":1}`, `"select_highlighted"`, `{"select":"case.new"}`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Msg {
    /// Open the palette (clears query + cursor).
    OpenPalette,
    /// Close the palette (query preserved).
    ClosePalette,
    /// Set the palette query (resets the cursor to the top hit).
    SetQuery(String),
    /// Move the highlighted row by ±n, wrapping within the current hit list.
    MoveCursor(i32),
    /// Select the currently-highlighted palette row (Enter). No-op if no hits.
    SelectHighlighted,
    /// Select a command by id (a menubar / context-menu pick, or headless).
    Select(String),
    /// Replace the whole command registry (host hot-swap).
    SetCommands(Vec<Command>),
}

/// **Side work as data (FC-8).** A menu does no I/O of its own — selecting a
/// command hands the host its `id`; the host owns what running it means.
#[derive(Clone, Debug, PartialEq)]
pub enum Effect {
    /// The user selected the command with this id — run it.
    Selected(String),
}

/// The [`Facet`](facett_core::Facet) driver: a menubar + a fuzzy command-palette
/// overlay over one [`Command`] set, as a single [`Elm`](facett_core::Elm)
/// component. Theme-aware, headless-drivable via `update_json`/`state_json`, and
/// it emits an [`Effect::Selected`] the host handles — nothing is pointer-only.
pub struct MenuView {
    /// Tab / heading label.
    pub title: String,
    /// All observable state (FC-3).
    state: MenuState,
}

impl MenuView {
    /// New menu over `commands`, titled `title`, with the palette closed.
    pub fn new(title: impl Into<String>, commands: Vec<Command>) -> Self {
        Self { title: title.into(), state: MenuState { commands, ..Default::default() } }
    }

    /// The command registry.
    pub fn commands(&self) -> &[Command] {
        &self.state.commands
    }

    /// Is the palette overlay currently open?
    pub fn is_palette_open(&self) -> bool {
        self.state.palette_open
    }

    /// The id of the command last selected (by any surface), if any.
    pub fn last_selected(&self) -> Option<&str> {
        self.state.last_selected.as_deref()
    }

    /// The commands the current query matches, best-first (what the palette shows).
    pub fn hits(&self) -> Vec<&Command> {
        rank(&self.state.query, &self.state.commands)
    }

    /// Select `id`: record it, close the palette, and emit [`Effect::Selected`].
    fn select(&mut self, id: String) -> Vec<Effect> {
        functional_status("facett-menu/menuview", "select", true, &id);
        self.state.last_selected = Some(id.clone());
        self.state.palette_open = false;
        vec![Effect::Selected(id)]
    }

    /// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; return the
    /// [`Effect`]s the host should run (a `Selected` on a pick, else empty).
    pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
        match msg {
            Msg::OpenPalette => {
                self.state.palette_open = true;
                self.state.query.clear();
                self.state.cursor = 0;
                Vec::new()
            }
            Msg::ClosePalette => {
                self.state.palette_open = false;
                Vec::new()
            }
            Msg::SetQuery(q) => {
                self.state.query = q;
                self.state.cursor = 0;
                Vec::new()
            }
            Msg::MoveCursor(delta) => {
                let len = self.hits().len();
                if len == 0 {
                    self.state.cursor = 0;
                } else {
                    let n = len as i32;
                    self.state.cursor = (((self.state.cursor as i32 + delta) % n + n) % n) as usize;
                }
                Vec::new()
            }
            Msg::SelectHighlighted => {
                match self.hits().get(self.state.cursor).map(|c| c.id.clone()) {
                    Some(id) => self.select(id),
                    None => Vec::new(),
                }
            }
            Msg::Select(id) => self.select(id),
            Msg::SetCommands(cmds) => {
                self.state.commands = cmds;
                self.state.cursor = 0;
                Vec::new()
            }
        }
    }

    /// A richer, headless-readable snapshot than the raw model: adds the live
    /// `hits` (the ids the palette would show for the current query), so a robot
    /// test can assert the filter narrowed without rendering.
    pub fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "title": self.title,
            "palette_open": self.state.palette_open,
            "query": self.state.query,
            "cursor": self.state.cursor,
            "commands": self.state.commands.iter().map(|c| &c.id).collect::<Vec<_>>(),
            "hits": self.hits().iter().map(|c| &c.id).collect::<Vec<_>>(),
            "last_selected": self.state.last_selected,
        })
    }

    /// **FC-9 / FC-4** — a **pure** function of `&self`: it paints the menubar and
    /// (when open) the palette overlay, and *returns* the [`Msg`]s it produced,
    /// mutating neither the model nor any I/O. The [`Facet::ui`] bridge applies
    /// them through [`update`](Self::update).
    pub fn view(&self, ui: &mut Ui) -> Vec<Msg> {
        let mut msgs = Vec::new();
        let th = theme(ui);

        // Ctrl-K / Ctrl-Shift-P opens the palette; Esc closes it. Read-only.
        let (open_hot, esc) = ui.ctx().input(|i| {
            let cmd = i.modifiers.command;
            let open = (cmd && i.key_pressed(Key::K)) || (cmd && i.modifiers.shift && i.key_pressed(Key::P));
            (open, i.key_pressed(Key::Escape))
        });
        if open_hot && !self.state.palette_open {
            msgs.push(Msg::OpenPalette);
        }
        if esc && self.state.palette_open {
            msgs.push(Msg::ClosePalette);
        }

        // The menubar: each group is a top-level dropdown; a pick emits Select.
        egui::MenuBar::new().ui(ui, |ui| {
            let mut groups: Vec<&str> = Vec::new();
            for c in &self.state.commands {
                if !groups.contains(&c.group.as_str()) {
                    groups.push(c.group.as_str());
                }
            }
            for g in groups {
                ui.menu_button(g, |ui| {
                    for c in self.state.commands.iter().filter(|c| c.group == g) {
                        let btn = egui::Button::new(menu_label(c)).wrap_mode(egui::TextWrapMode::Extend);
                        if ui.add_enabled(c.enabled, btn).clicked() {
                            msgs.push(Msg::Select(c.id.clone()));
                            ui.close();
                        }
                    }
                });
            }
            // A hint that Ctrl-K opens the palette.
            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
                ui.label(egui::RichText::new("⌘K").color(th.text_dim).small());
            });
        });

        // The palette overlay (scrim + search box + fuzzy list) above everything.
        if self.state.palette_open {
            let ctx = ui.ctx().clone();
            let screen = ctx.content_rect();

            egui::Area::new("facett_menuview_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 hits = self.hits();
            egui::Area::new("facett_menuview_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_f32, th.panel_stroke))
                        .show(ui, |ui| {
                            ui.set_width(520.0);

                            // Query box over a LOCAL buffer (pure w.r.t. `self`).
                            let mut q = self.state.query.clone();
                            let edit = ui.add(
                                egui::TextEdit::singleline(&mut q).hint_text("Type a command…").desired_width(f32::INFINITY),
                            );
                            edit.request_focus();
                            if q != self.state.query {
                                msgs.push(Msg::SetQuery(q));
                            }

                            let (down, up, enter) = ui.input(|i| {
                                (i.key_pressed(Key::ArrowDown), i.key_pressed(Key::ArrowUp), i.key_pressed(Key::Enter))
                            });
                            if down {
                                msgs.push(Msg::MoveCursor(1));
                            }
                            if up {
                                msgs.push(Msg::MoveCursor(-1));
                            }

                            ui.separator();

                            for (i, c) in hits.iter().enumerate() {
                                let sel = i == self.state.cursor;
                                let resp = ui.selectable_label(sel, row_text(c));
                                if sel {
                                    let r = resp.rect;
                                    let p = ui.painter();
                                    for w in 1..=4 {
                                        let a = (70 / w) as u8;
                                        let glow = 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_f32, glow),
                                        );
                                    }
                                    p.line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.5_f32, th.accent));
                                }
                                if resp.clicked() {
                                    msgs.push(Msg::Select(c.id.clone()));
                                }
                            }
                            if hits.is_empty() {
                                ui.weak("No matching commands");
                            }
                            if enter {
                                msgs.push(Msg::SelectHighlighted);
                            }
                        });
                });
        }

        // ── render-lane emit: this pure-view path RAN ─────────────────────────
        #[cfg(feature = "testmatrix")]
        facett_core::testmatrix::emit(
            "facett-menu::MenuView::view",
            "ui_render",
            !self.state.commands.is_empty(),
            &format!(
                "palette_open={} commands={} hits={}",
                self.state.palette_open,
                self.state.commands.len(),
                self.hits().len(),
            ),
        );

        msgs
    }
}

// ── FC-2 / FC-3 / FC-8 / FC-9: the canonical Elm split ────────────────────────
impl facett_core::Elm for MenuView {
    type Model = MenuState;
    type Msg = Msg;
    type Effect = Effect;

    fn title(&self) -> &str {
        &self.title
    }
    fn state(&self) -> &MenuState {
        &self.state
    }
    fn update(&mut self, msg: Msg) -> Vec<Effect> {
        MenuView::update(self, msg)
    }
    fn view(&self, ui: &mut Ui) -> Vec<Msg> {
        MenuView::view(self, ui)
    }
}

// The bridge macro writes `impl Facet for MenuView`. **Form 3**
// (`custom_state_json`) because the menu publishes a RICHER `state_json` than
// plain `serde(state())` — it adds the derived `hits` list a robot reads to prove
// the fuzzy filter narrowed. `update_json` routes JSON straight through `update`,
// so the palette is fully headless-drivable.
facett_core::impl_facet_via_elm!(MenuView, custom_state_json, {
    fn state_json(&self) -> serde_json::Value {
        MenuView::state_json(self)
    }

    fn update_json(&mut self, msg_json: &str) {
        if let Ok(msg) = serde_json::from_str::<Msg>(msg_json) {
            let _ = <Self as facett_core::Elm>::update(self, msg);
        }
    }

    fn caps(&self) -> facett_core::FacetCaps {
        facett_core::FacetCaps::NONE.themeable()
    }
});

#[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"),
        ]
    }

    /// **RED-when-broken: the Tools menu is ALWAYS the last top-level menu.** Even
    /// though the Tools group is declared FIRST here, `menubar_group_order` must sort
    /// it last (the shared convention, one place). If Tools stopped sorting last, the
    /// final element would not be the Tools group and this fails.
    #[test]
    fn tools_menu_is_always_the_last_top_menu() {
        // Tools declared FIRST, then normal menus — the worst case for "last".
        let cmds = vec![
            Command::new("zone.populate", "Populate a zone…", TOOLS_MENU),
            Command::new("copy", "Copy", "Edit"),
            Command::new("case.new", "New case", "Case"),
            Command::new("view.zoom", "Zoom in", "View"),
        ];
        let order = menubar_group_order(&cmds);
        assert_eq!(order.last().copied(), Some(TOOLS_MENU), "Tools must be the LAST top menu, got {order:?}");
        // The other menus keep their first-seen relative order, before Tools.
        assert_eq!(order, vec!["Edit", "Case", "View", TOOLS_MENU]);
        // Recognised however an app spells it (constant, glyph, or plain "Tools").
        assert!(is_tools_menu(TOOLS_MENU) && is_tools_menu("🧰 Utilities") && is_tools_menu("tools"));
        assert!(!is_tools_menu("Edit"));
    }

    /// A second Tools-ish menu also lands at the end (both tools groups last).
    #[test]
    fn multiple_tools_groups_all_sort_last() {
        let cmds = vec![
            Command::new("a", "A", "File"),
            Command::new("t1", "T1", TOOLS_MENU),
            Command::new("b", "B", "Edit"),
            Command::new("t2", "T2", "🧰 Extra"),
        ];
        let order = menubar_group_order(&cmds);
        assert_eq!(&order[..2], &["File", "Edit"], "non-tools keep first-seen order first");
        assert!(order[2..].iter().all(|g| is_tools_menu(g)), "all tools menus trail: {order:?}");
    }

    #[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.as_str()).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.as_str()), 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.as_deref(), Some("Ctrl+X"));
        assert_eq!(c.icon.as_deref(), 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"));
    }

    // ── MenuView (Elm) ────────────────────────────────────────────────────────
    use facett_core::harness;

    #[test]
    fn menuview_update_selects_and_emits_effect() {
        let mut m = MenuView::new("Menu", sample());
        // a direct pick emits Selected(id) and records it
        let fx = m.update(Msg::Select("paste".into()));
        assert_eq!(fx, vec![Effect::Selected("paste".into())]);
        assert_eq!(m.last_selected(), Some("paste"));
    }

    #[test]
    fn menuview_palette_open_filter_select_via_harness() {
        // open → type "case" → highlighted → select-highlighted, all headless.
        let mut m = MenuView::new("Menu", sample());
        let fx = harness::drive(&mut m, [
            Msg::OpenPalette,
            Msg::SetQuery("case".into()),
        ]);
        assert!(fx.is_empty(), "opening/typing emits no effect");
        assert!(m.is_palette_open());
        // the fuzzy filter narrowed to the two Case commands
        let hits: Vec<&str> = m.hits().iter().map(|c| c.id.as_str()).collect();
        assert!(hits.iter().all(|id| id.starts_with("case.")), "filtered to case.*, got {hits:?}");
        // the highlighted (top) row is a case command; selecting it emits it
        let fx = m.update(Msg::SelectHighlighted);
        assert_eq!(fx.len(), 1);
        match &fx[0] {
            Effect::Selected(id) => assert!(id.starts_with("case."), "selected {id}"),
        }
        // selecting closed the palette
        assert!(!m.is_palette_open());
    }

    #[test]
    fn menuview_state_json_carries_hits_and_selection() {
        let mut m = MenuView::new("Menu", sample());
        m.update(Msg::OpenPalette);
        m.update(Msg::SetQuery("copy".into()));
        let j = MenuView::state_json(&m);
        assert_eq!(j["palette_open"], true);
        assert_eq!(j["query"], "copy");
        let hits: Vec<&str> = j["hits"].as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect();
        assert_eq!(hits.first(), Some(&"copy"), "an exact query surfaces its command first, got {hits:?}");
        assert!(!hits.contains(&"cut"), "disabled command never in hits");
    }

    #[test]
    fn menuview_update_json_drives_the_palette() {
        // the Facet JSON seam (title/ui/state_json/update_json) drives it headless.
        use facett_core::Facet;
        let mut m = MenuView::new("Menu", sample());
        Facet::update_json(&mut m, "\"open_palette\"");
        Facet::update_json(&mut m, "{\"set_query\":\"paste\"}");
        let j = Facet::state_json(&m);
        assert_eq!(j["palette_open"], true);
        let hits: Vec<&str> = j["hits"].as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect();
        assert_eq!(hits, vec!["paste"], "query paste narrows to one hit");
        // select it by id through the JSON seam
        Facet::update_json(&mut m, "{\"select\":\"paste\"}");
        assert_eq!(m.last_selected(), Some("paste"));
        assert_eq!(Facet::state_json(&m)["palette_open"], false);
    }

    #[test]
    fn menuview_cursor_wraps_within_hits() {
        let mut m = MenuView::new("Menu", sample());
        m.update(Msg::OpenPalette); // empty query → all enabled commands
        let n = m.hits().len();
        assert!(n >= 2);
        for _ in 0..n {
            m.update(Msg::MoveCursor(1));
        }
        assert_eq!(MenuView::state_json(&m)["cursor"], 0, "wrapped back to top");
        m.update(Msg::MoveCursor(-1));
        assert_eq!(MenuView::state_json(&m)["cursor"], (n - 1) as u64);
    }

    #[test]
    fn menuview_set_commands_hot_swaps_registry() {
        let mut m = MenuView::new("Menu", sample());
        m.update(Msg::SetCommands(vec![Command::new("only", "Only", "G")]));
        assert_eq!(m.commands().len(), 1);
        assert_eq!(m.commands()[0].id, "only");
    }
}