diff --git a/src/lib.rs b/src/lib.rs index 522e4e7..4581bb1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -77,6 +77,7 @@ pub mod lua; pub mod lua_bindings; pub mod lua_isolation; pub mod mcp; +pub mod menu; pub mod message_bus; pub mod minibuffer; // T M10.10: frontend-side optimistic-apply infrastructure (predicate diff --git a/src/menu.rs b/src/menu.rs new file mode 100644 index 0000000..5ce07a5 --- /dev/null +++ b/src/menu.rs @@ -0,0 +1,624 @@ +// 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")); + } +}