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");
}
}