// menu.rs --- Menu registry: the items that populate the right-click context menu. //! Context-menu items. //! //! The right-click context menu (Q#CM2) is populated from a Lua //! registry that mirrors [`crate::command::CommandRegistry`]. Each //! [`MenuItem`] names a [`crate::command::Command`] to invoke and //! carries optional visibility controls: a coarse `context` tag //! (sugar) and/or a full `predicate` (the escape hatch), evaluated //! against a context table when the menu opens (Q#CM3). Items are //! grouped and ordered for layout; separators fall between groups. //! //! # Storage and lookup //! //! [`MenuRegistry`] owns the live items in insertion order. Unlike //! commands, items are not uniquely *named* --- two contexts may each //! contribute a "Copy". An optional `id` allows targeted removal or //! in-place replacement, so a user config can hide or override a //! builtin item (`pmacs.menu.remove("edit.copy")`, or re-`item` with //! the same `id`) without disturbing the rest of the menu. //! //! # Threading //! //! Single-threaded, behind `Rc>` next to the command and //! keymap registries. use mlua::Function; use thiserror::Error; use crate::command::SourceLocation; // --------------------------------------------------------------------------- // Contexts // --------------------------------------------------------------------------- /// Coarse context tags that are sugar for a visibility predicate /// (Q#CM3). Validated at registration so a typo (`"selecton"`) is a /// hard error rather than a silently invisible item --- the same /// typo-paranoia as the command spec's unknown-field check (R50). /// /// The tag → predicate mapping is owned by the menu builder in the /// core (it has the live context table); the registry only validates /// the vocabulary. pub const KNOWN_CONTEXTS: &[&str] = &["always", "selection", "symbol", "diagnostic"]; // --------------------------------------------------------------------------- // MenuItem + errors // --------------------------------------------------------------------------- /// A single context-menu entry. /// /// Cloning is cheap: `String`s clone trivially and `mlua::Function` is /// reference-counted internally. #[derive(Clone)] pub struct MenuItem { /// Optional stable identifier. Enables targeted [`MenuRegistry::remove`] /// and in-place override (re-adding with the same id replaces). pub id: Option, /// Human-readable label shown in the menu row. pub label: String, /// Name of the [`crate::command::Command`] this item invokes. /// Resolved at invoke time (like a keymap binding), not at /// registration --- the command may be defined later. pub command: String, /// Coarse visibility tag (one of [`KNOWN_CONTEXTS`]). Sugar for a /// predicate; ignored when `predicate` is set. pub context: Option, /// Full visibility predicate: `fn(context_table) -> bool`. The /// escape hatch for items whose availability the coarse tags can't /// express. Takes precedence over `context`. pub predicate: Option, /// Layout group. Items sharing a group render together; a separator /// falls between distinct groups (group first-appearance order). pub group: String, /// Sort key within a group (ascending). Ties break on insertion order. pub order: i64, /// Where the item was defined (Lua `file:line`). pub source: SourceLocation, } /// Errors raised by the menu registry. #[derive(Debug, Error)] pub enum MenuError { /// `item` was called with no label or an all-whitespace one. #[error("menu item label must be non-empty")] EmptyLabel, /// `item` was called without a `command` to invoke. #[error("menu item \"{label}\" requires a non-empty `command`")] EmptyCommand { /// The offending item's label. label: String, }, /// The `context` tag is not one of [`KNOWN_CONTEXTS`]. #[error( "menu item \"{label}\" has unknown context `{context}`; supported: always, selection, symbol, diagnostic" )] UnknownContext { /// The offending item's label. label: String, /// The offending context tag. context: String, }, /// The spec table contained a key the registry doesn't know about. #[error( "unknown field `{field}` in menu item spec; supported: id, label, command, context, predicate, group, order" )] UnknownField { /// The offending key. field: String, }, } // --------------------------------------------------------------------------- // MenuRegistry // --------------------------------------------------------------------------- /// Ordered registry of context-menu items. /// /// Insert via [`Self::add`] (validates, replacing in place on a /// matching `id`). Read the live set via [`Self::items`]; the menu /// builder sorts and groups at open time. Remove by id via /// [`Self::remove`]; reset via [`Self::clear`]. #[derive(Default)] pub struct MenuRegistry { items: Vec, } impl MenuRegistry { /// An empty registry. #[must_use] pub fn new() -> Self { Self::default() } /// Insert `item`, validating its metadata. If the item carries an /// `id` already present, the existing entry is replaced in place /// (preserving its slot) so re-running config and user overrides are /// idempotent; otherwise the item is appended. pub fn add(&mut self, item: MenuItem) -> Result<(), MenuError> { if item.label.trim().is_empty() { return Err(MenuError::EmptyLabel); } if item.command.trim().is_empty() { return Err(MenuError::EmptyCommand { label: item.label }); } if let Some(context) = &item.context && !KNOWN_CONTEXTS.contains(&context.as_str()) { return Err(MenuError::UnknownContext { label: item.label, context: context.clone(), }); } if let Some(id) = item.id.clone() && let Some(slot) = self .items .iter_mut() .find(|it| it.id.as_deref() == Some(&id)) { *slot = item; return Ok(()); } self.items.push(item); Ok(()) } /// The live items, in insertion order. #[must_use] pub fn items(&self) -> &[MenuItem] { &self.items } /// Remove every item whose `id` equals `id`. Returns `true` if any /// item was removed. pub fn remove(&mut self, id: &str) -> bool { let before = self.items.len(); self.items.retain(|it| it.id.as_deref() != Some(id)); self.items.len() != before } /// Drop every item. pub fn clear(&mut self) { self.items.clear(); } /// Number of registered items. #[must_use] pub fn len(&self) -> usize { self.items.len() } /// True iff no items are registered. #[must_use] pub fn is_empty(&self) -> bool { self.items.is_empty() } } // --------------------------------------------------------------------------- // Open-menu runtime state (Q#CM1) + TUI surface // --------------------------------------------------------------------------- use std::sync::{Arc, Mutex}; use crate::buffer::Buffer; use crate::cell::{CellCoord, CellGrid, Color, Glyph, Style}; use crate::view::{View, Viewport}; /// One rendered row of an open menu: either a selectable command or a /// non-selectable group divider. The resolved list is built in Lua /// (`pmacs.menu.build`, which evaluates predicates / context tags and /// groups items) and stored here; navigation skips `Separator` rows. #[derive(Clone, Debug, PartialEq, Eq)] pub enum MenuRow { /// A divider between groups. Separator, /// A selectable entry that invokes `command` when chosen. Item { /// Display label. label: String, /// Name of the command invoked on selection. command: String, }, } impl MenuRow { const fn is_item(&self) -> bool { matches!(self, MenuRow::Item { .. }) } } /// The live state of an open context menu (Q#CM1). Frontend-agnostic: /// the TUI [`MenuView`] overlay and (later) the GPU producer both render /// from this. `active` is an index into `rows` that always points at an /// [`MenuRow::Item`]. `anchor` is the absolute cell the menu opens at /// (the click point) — the TUI render origin; the GPU positions in /// pixels locally and ignores it. pub struct MenuState { /// Rows top-to-bottom (items + separators). pub rows: Vec, /// Highlighted row index (always an `Item`). pub active: usize, /// Absolute `(row, col)` cell the popup renders from. pub anchor: (u32, u32), /// Popup width in cells (max label + padding, clamped). pub width: u32, } /// Min / max popup width in cells. const MENU_MIN_WIDTH: u32 = 8; const MENU_MAX_WIDTH: u32 = 48; impl MenuState { /// Build from a resolved row list. Returns `None` when no row is an /// `Item` (an empty menu never opens). Width is the widest label /// plus padding, clamped to [`MENU_MIN_WIDTH`]..[`MENU_MAX_WIDTH`]. #[must_use] pub fn new(rows: Vec, anchor: (u32, u32)) -> Option { let active = rows.iter().position(MenuRow::is_item)?; let widest = rows .iter() .filter_map(|r| match r { MenuRow::Item { label, .. } => Some(label.chars().count() as u32), MenuRow::Separator => None, }) .max() .unwrap_or(0); let width = (widest + 2).clamp(MENU_MIN_WIDTH, MENU_MAX_WIDTH); Some(Self { rows, active, anchor, width, }) } /// Move the highlight one `Item` row forward (`delta >= 0`) or back /// (`delta < 0`), wrapping and skipping separators. Only the sign of /// `delta` matters — callers step one item at a time. pub fn step(&mut self, delta: isize) { let n = self.rows.len(); if n == 0 { return; } let forward = delta >= 0; for _ in 0..n { self.active = if forward { (self.active + 1) % n } else { (self.active + n - 1) % n }; if self.rows[self.active].is_item() { return; } } } /// The active item's command name. #[must_use] pub fn active_command(&self) -> Option<&str> { match self.rows.get(self.active)? { MenuRow::Item { command, .. } => Some(command), MenuRow::Separator => None, } } /// Map an absolute cell to the row index it covers, but only when /// that row is a selectable `Item` (separators and cells outside the /// popup rectangle return `None`). #[must_use] pub fn hit(&self, row: u32, col: u32) -> Option { let (arow, acol) = self.anchor; if row < arow || col < acol { return None; } let ri = (row - arow) as usize; let ci = col - acol; if ri >= self.rows.len() || ci >= self.width { return None; } self.rows[ri].is_item().then_some(ri) } } /// Shared handle to the open menu (`None` when closed). Held by /// [`crate::editor_core::EditorCore`] and read by [`MenuView`], mirroring /// the search store's `Arc` bridge between core state and the /// overlay that renders it. pub type SharedMenu = Arc>>; /// A fresh, closed shared menu. #[must_use] pub fn make_shared_menu() -> SharedMenu { Arc::new(Mutex::new(None)) } /// Popup background (non-selected rows) — a dim fill so the menu reads /// as a floating surface over the buffer text it occludes. fn menu_style() -> Style { Style { fg: Color::Indexed(252), bg: Color::Indexed(236), ..Style::default() } } /// Highlighted-row style (the active item). fn menu_selected_style() -> Style { Style { fg: Color::Indexed(231), bg: Color::Indexed(24), ..Style::default() } } /// TUI overlay that paints the open menu (Q#CM1). Persistent on the /// active window once attached (deduped by [`View::kind`]); renders /// nothing while the menu is closed, mirroring `SearchView`'s /// self-suppressing model. Owns every cell inside the popup rectangle, /// occluding the buffer text beneath. pub struct MenuView { menu: SharedMenu, } impl MenuView { /// Build a view reading `menu`. #[must_use] pub fn new(menu: SharedMenu) -> Self { Self { menu } } } impl View for MenuView { fn kind(&self) -> &'static str { "context-menu" } fn render(&mut self, _buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) { let guard = self.menu.lock().expect("menu mutex poisoned"); let Some(menu) = guard.as_ref() else { return; }; let top = viewport.cell_origin.row; let left = viewport.cell_origin.col; let bottom = top + viewport.cell_size.rows; let right = left + viewport.cell_size.cols; let (arow, acol) = menu.anchor; for (i, row) in menu.rows.iter().enumerate() { let r = arow + i as u32; if r < top || r >= bottom { continue; // clip rows that fall outside the window } let selected = i == menu.active; let row_style = if selected { menu_selected_style() } else { menu_style() }; // Paint the full-width row background first. for c in 0..menu.width { let col = acol + c; if col < left || col >= right { continue; } let cell = cells.at(CellCoord::new(r, col)); cell.glyph = Glyph::Char(' '); cell.style = row_style; cell.attachment = None; } match row { MenuRow::Separator => { for c in 0..menu.width { let col = acol + c; if col < left || col >= right { continue; } cells.at(CellCoord::new(r, col)).glyph = Glyph::Char('─'); } } MenuRow::Item { label, .. } => { // One cell of left padding; stop at the popup's right edge. let row_right = (acol + menu.width).min(right); for (col, ch) in (acol + 1..).zip(label.chars()) { if col >= row_right { break; } let cell = cells.at(CellCoord::new(r, col)); cell.glyph = Glyph::Char(ch); cell.style = row_style; cell.attachment = None; } } } } } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use mlua::Lua; fn item(id: Option<&str>, label: &str, command: &str) -> MenuItem { MenuItem { id: id.map(ToOwned::to_owned), label: label.to_owned(), command: command.to_owned(), context: None, predicate: None, group: String::new(), order: 0, source: SourceLocation::default(), } } #[test] fn add_then_items_round_trips() { let mut r = MenuRegistry::new(); r.add(item(None, "Copy", "edit.copy")).unwrap(); assert_eq!(r.len(), 1); assert_eq!(r.items()[0].label, "Copy"); assert_eq!(r.items()[0].command, "edit.copy"); } #[test] fn empty_label_is_rejected() { let mut r = MenuRegistry::new(); assert!(matches!( r.add(item(None, " ", "edit.copy")), Err(MenuError::EmptyLabel) )); } #[test] fn empty_command_is_rejected() { let mut r = MenuRegistry::new(); match r.add(item(None, "Copy", "")) { Err(MenuError::EmptyCommand { label }) => assert_eq!(label, "Copy"), other => panic!("expected EmptyCommand, got {other:?}"), } } #[test] fn unknown_context_is_rejected() { let mut r = MenuRegistry::new(); let mut it = item(None, "Copy", "edit.copy"); it.context = Some("selecton".into()); match r.add(it) { Err(MenuError::UnknownContext { label, context }) => { assert_eq!(label, "Copy"); assert_eq!(context, "selecton"); } other => panic!("expected UnknownContext, got {other:?}"), } } #[test] fn known_contexts_are_accepted() { let mut r = MenuRegistry::new(); for cx in KNOWN_CONTEXTS { let mut it = item(None, "X", "x.cmd"); it.context = Some((*cx).to_owned()); r.add(it).unwrap(); } assert_eq!(r.len(), KNOWN_CONTEXTS.len()); } #[test] fn predicate_is_stored() { let lua = Lua::new(); let mut r = MenuRegistry::new(); let mut it = item(None, "Paste", "edit.paste"); it.predicate = Some(lua.create_function(|_, ()| Ok(true)).unwrap()); r.add(it).unwrap(); assert!(r.items()[0].predicate.is_some()); } #[test] fn matching_id_replaces_in_place() { let mut r = MenuRegistry::new(); r.add(item(Some("a"), "First", "cmd.a")).unwrap(); r.add(item(Some("b"), "Second", "cmd.b")).unwrap(); // Override `a` in place: stays at slot 0 with the new label. r.add(item(Some("a"), "First!", "cmd.a")).unwrap(); assert_eq!(r.len(), 2); assert_eq!(r.items()[0].label, "First!"); assert_eq!(r.items()[1].label, "Second"); } #[test] fn remove_by_id_drops_the_item() { let mut r = MenuRegistry::new(); r.add(item(Some("a"), "A", "cmd.a")).unwrap(); r.add(item(None, "B", "cmd.b")).unwrap(); assert!(r.remove("a")); assert!(!r.remove("a")); // already gone assert_eq!(r.len(), 1); assert_eq!(r.items()[0].label, "B"); } #[test] fn clear_empties_the_registry() { let mut r = MenuRegistry::new(); r.add(item(None, "A", "cmd.a")).unwrap(); r.add(item(None, "B", "cmd.b")).unwrap(); r.clear(); assert!(r.is_empty()); } #[test] fn items_without_id_both_append() { let mut r = MenuRegistry::new(); r.add(item(None, "A", "cmd.a")).unwrap(); r.add(item(None, "A", "cmd.a")).unwrap(); // No id → no dedup; both are kept. assert_eq!(r.len(), 2); } // ---- MenuState (open-menu runtime) ------------------------------------- fn row_item(label: &str) -> MenuRow { MenuRow::Item { label: label.to_owned(), command: format!("cmd.{label}"), } } #[test] fn menu_state_new_requires_a_selectable_item() { assert!(MenuState::new(vec![], (0, 0)).is_none()); assert!(MenuState::new(vec![MenuRow::Separator], (0, 0)).is_none()); // active lands on the first item, skipping a leading separator. let m = MenuState::new(vec![MenuRow::Separator, row_item("A")], (2, 3)).unwrap(); assert_eq!(m.active, 1); assert_eq!(m.anchor, (2, 3)); } #[test] fn menu_state_step_skips_separators_and_wraps() { let rows = vec![row_item("A"), MenuRow::Separator, row_item("B")]; let mut m = MenuState::new(rows, (0, 0)).unwrap(); assert_eq!(m.active, 0); m.step(1); assert_eq!(m.active, 2); // jumps over the separator at row 1 m.step(1); assert_eq!(m.active, 0); // wraps to the top m.step(-1); assert_eq!(m.active, 2); // wraps back, still skipping the separator } #[test] fn menu_state_hit_maps_cells_to_item_rows_only() { let rows = vec![row_item("A"), MenuRow::Separator, row_item("B")]; let m = MenuState::new(rows, (5, 10)).unwrap(); // Row 5 = item A (anywhere within the popup width). assert_eq!(m.hit(5, 10), Some(0)); assert_eq!(m.hit(5, 10 + m.width - 1), Some(0)); // Row 6 = separator → not selectable. assert_eq!(m.hit(6, 10), None); // Row 7 = item B. assert_eq!(m.hit(7, 12), Some(2)); // Outside the popup rectangle in each direction. assert_eq!(m.hit(4, 10), None); // above assert_eq!(m.hit(8, 10), None); // below assert_eq!(m.hit(5, 9), None); // left assert_eq!(m.hit(5, 10 + m.width), None); // right } #[test] fn menu_state_active_command_reads_the_highlight() { let mut m = MenuState::new(vec![row_item("A"), row_item("B")], (0, 0)).unwrap(); assert_eq!(m.active_command(), Some("cmd.A")); m.step(1); assert_eq!(m.active_command(), Some("cmd.B")); } }