From 487c12cca9d1c4f5ef461beb40121f8d069589ec Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 27 Jun 2026 21:42:28 -0400 Subject: [PATCH 1/5] pmacs context menu: registry + open-menu state types (Q#CM1/Q#CM2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The data types for the right-click context menu, with no behavior yet (nothing opens a menu until the dispatch wiring lands). - `MenuRegistry` / `MenuItem` / `MenuError` (Q#CM2): the Lua-facing item registry, mirroring `CommandRegistry`. Items carry id / label / command / context tag / predicate / group / order; `context` is validated against a known vocabulary (typo -> hard error, R50-style), and a matching `id` replaces in place so config reloads and user overrides are idempotent. - `MenuState` / `MenuRow` / `SharedMenu` (Q#CM1): the open-menu runtime state, plus the self-suppressing TUI `MenuView` overlay (the `SearchView` pattern). `MenuRow` is Item|Separator; navigation and hit-testing skip separators. Pure additions behind `pub mod menu` — the lib still builds with the module unused. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U --- src/lib.rs | 1 + src/menu.rs | 624 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 625 insertions(+) create mode 100644 src/menu.rs 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")); + } +} From 8929bf0d258c8d3ffb1e0ccb1591fbaafd4d75aa Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 27 Jun 2026 22:03:58 -0400 Subject: [PATCH 2/5] pmacs context menu: core clipboard + menu methods + Lua surface (Q#CM1/Q#CM3/Q#CM6) The core-side machinery the menu and clipboard ride on, plus the Lua resolver. Still no dispatch wiring (that needs the protocol/frontend commit), so this builds but nothing is reachable yet. - Clipboard (Q#CM6): an in-core slot + `copy`/`cut`/`paste`/`select-all` on `EditorCore`, plus a one-shot `pending_clipboard` the dispatcher will drain. `region_bytes` / `word_at_cursor` (the latter feeds the `symbol` context). - Menu core (Q#CM1): `SharedMenu` field + `menu_open/close/step/ set_active_row/active_command/hit` + `ensure_menu_overlay`. - `pmacs.menu` install (item/list/remove/clear/_raw) and `ed.*` bindings (clipboard_copy/cut/paste, select_all, word_at_cursor); the `install` signature gains the menu registry, threaded through `lua.rs`. - `builtin/menus/default.lua`: `pmacs.menu.build` resolves visible items (predicate or context tag), groups/sorts, and emits rows (Q#CM3). The default items reference commands by name (resolved at invoke). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U --- builtin/menus/default.lua | 142 ++++++++++++++++ src/editor_core.rs | 341 ++++++++++++++++++++++++++++++++++++++ src/lua.rs | 23 ++- src/lua_bindings.rs | 325 +++++++++++++++++++++++++++++++++++- 4 files changed, 828 insertions(+), 3 deletions(-) create mode 100644 builtin/menus/default.lua diff --git a/builtin/menus/default.lua b/builtin/menus/default.lua new file mode 100644 index 0000000..649699b --- /dev/null +++ b/builtin/menus/default.lua @@ -0,0 +1,142 @@ +-- builtin/menus/default.lua --- default right-click context menu (Q#CM2/Q#CM3). +-- +-- Items are registered with `pmacs.menu.item` (Rust registry). Each +-- names a command to invoke and declares visibility via a coarse +-- `context` tag (sugar) or a full `predicate`, evaluated against a +-- context table when the menu opens. `group`/`order` drive layout; +-- separators fall between groups. +-- +-- `pmacs.menu.build()` (called from Rust at right-click) does the +-- resolution: filter visible items, group/sort, and return the rows. + +local ed = pmacs.editor + +-- The active buffer's live LSP attachment record, or nil --- guarded so +-- the menu never errors when LSP isn't configured/loaded, and never +-- triggers an attach just by opening. +local function lsp_attachment() + if pmacs.lsp == nil or pmacs.lsp.active_attachment == nil then + return nil + end + local ok, rec = pcall(pmacs.lsp.active_attachment) + if ok then return rec end + return nil +end + +-- Whether the 0-based (line, col) falls within diagnostic `d`'s range. +local function diag_contains(d, line, col) + if line < d.start_line or line > d.end_line then return false end + if line == d.start_line and col < d.start_col then return false end + if line == d.end_line and col > d.end_col then return false end + return true +end + +-- Evaluate a coarse `context` tag against the live context table +-- (Q#CM3). Sugar for a predicate; `symbol` needs a word-under-point and +-- an attached server, `diagnostic` needs a published diagnostic +-- spanning the cursor. +function pmacs.menu._context_eval(tag, cx) + if tag == "always" then + return true + elseif tag == "selection" then + return cx.has_selection + elseif tag == "symbol" then + return cx.word ~= nil and cx.attachment ~= nil + elseif tag == "diagnostic" then + if cx.attachment == nil then return false end + for _, d in ipairs(pmacs.diag.list(cx.attachment.uri)) do + if diag_contains(d, cx.line, cx.col) then return true end + end + return false + end + return false +end + +-- Whether `it` is visible in context `cx`. A failing predicate hides +-- the item rather than aborting the whole menu. +local function item_visible(it, cx) + if it.predicate ~= nil then + local ok, vis = pcall(it.predicate, cx) + return ok and vis and true or false + elseif it.context ~= nil then + return pmacs.menu._context_eval(it.context, cx) and true or false + end + return true +end + +-- Build the resolved, grouped, visibility-filtered rows for an open +-- menu (Q#CM3). Returns an array where each element is either +-- `{ separator = true }` or `{ label = ..., command = ... }`. +function pmacs.menu.build() + local cx = { + has_selection = ed.region() ~= nil, + word = ed.word_at_cursor(), + line = ed.cursor_line(), + col = ed.cursor_col(), + attachment = lsp_attachment(), + } + + -- Filter to visible items, tagging insertion order for a stable sort. + local visible = {} + for i, it in ipairs(pmacs.menu._raw()) do + if item_visible(it, cx) then + it.__i = i + visible[#visible + 1] = it + end + end + + -- Group order = first appearance in the registry; within a group, + -- sort by `order`, then by insertion for ties. + local gidx, next_g = {}, 1 + for _, it in ipairs(visible) do + local g = it.group or "" + if gidx[g] == nil then + gidx[g] = next_g + next_g = next_g + 1 + end + end + table.sort(visible, function(a, b) + local ga, gb = gidx[a.group or ""], gidx[b.group or ""] + if ga ~= gb then return ga < gb end + local oa, ob = a.order or 0, b.order or 0 + if oa ~= ob then return oa < ob end + return a.__i < b.__i + end) + + -- Emit rows, inserting a separator between distinct groups. + local rows, last_group = {}, nil + for _, it in ipairs(visible) do + local g = it.group or "" + if last_group ~= nil and g ~= last_group then + rows[#rows + 1] = { separator = true } + end + rows[#rows + 1] = { label = it.label, command = it.command } + last_group = g + end + return rows +end + +-- Default items. The edit group adapts to the selection; the symbol +-- group appears on an identifier with a server attached; the diagnostic +-- group appears when a diagnostic spans the cursor; history is always +-- available. Group order follows registration order. +pmacs.menu.item { id = "edit.cut", label = "Cut", command = "edit.cut", context = "selection", group = "edit", order = 10 } +pmacs.menu.item { id = "edit.copy", label = "Copy", command = "edit.copy", context = "selection", group = "edit", order = 20 } +pmacs.menu.item { id = "edit.paste", label = "Paste", command = "edit.paste", context = "always", group = "edit", order = 30 } +pmacs.menu.item { id = "edit.select-all", label = "Select All", command = "edit.select-all", context = "always", group = "edit", order = 40 } + +-- Symbol group (LSP). Shown when the cursor is on an identifier and a +-- language server is attached. Each invokes the existing async command, +-- which acts at the cursor (the right-click anchored it there). +pmacs.menu.item { id = "lsp.go-to-definition", label = "Go to Definition", command = "lsp.go-to-definition", context = "symbol", group = "symbol", order = 10 } +pmacs.menu.item { id = "lsp.find-references", label = "Find References", command = "lsp.find-references", context = "symbol", group = "symbol", order = 20 } +pmacs.menu.item { id = "lsp.rename", label = "Rename", command = "lsp.rename", context = "symbol", group = "symbol", order = 30 } +pmacs.menu.item { id = "lsp.hover", label = "Hover", command = "lsp.hover", context = "symbol", group = "symbol", order = 40 } + +-- Diagnostic group (LSP). Shown when a diagnostic spans the cursor. +-- "Quick Fix" runs code actions at the point (Q#CM10 defers streaming +-- the individual fix titles into the menu). +pmacs.menu.item { id = "lsp.quick-fix", label = "Quick Fix", command = "lsp.code-actions", context = "diagnostic", group = "diagnostic", order = 10 } + +pmacs.menu.item { id = "buffer.undo", label = "Undo", command = "buffer.undo", context = "always", group = "history", order = 10 } +pmacs.menu.item { id = "buffer.redo", label = "Redo", command = "buffer.redo", context = "always", group = "history", order = 20 } diff --git a/src/editor_core.rs b/src/editor_core.rs index c289158..6cfa0af 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -171,6 +171,22 @@ pub struct EditorCore { /// terminal and GPU frontends. Only the *prompt surface* differs /// (TUI bottom row vs GPU status band). pub search: Option, + /// In-core clipboard slot (Q#CM6) --- the bytes a paste inserts. + /// Written by copy/cut and by an inbound OS paste; read by paste. + /// The frontend-agnostic source of truth, so paste behaves + /// identically in the terminal and GPU frontends. + clipboard_slot: Vec, + /// One-shot outbound clipboard publish (Q#CM6). A copy/cut queues + /// `(originating frontend, bytes)`; the dispatcher drains it and + /// sends [`crate::protocol::InstanceSignal::Clipboard`] to that + /// frontend, which writes the OS clipboard (OSC 52 in the TUI, + /// `arboard` in the GPU). Drained per-tick like `pending_crdt_ops`. + pending_clipboard: Option<(FrontendId, Vec)>, + /// Open context menu (Q#CM1), or `None` when closed. Shared + /// `Arc` so the TUI [`crate::menu::MenuView`] overlay renders + /// from the same state the dispatch path mutates — the menu twin of + /// `search_store`. + pub menu: crate::menu::SharedMenu, } impl EditorCore { @@ -207,6 +223,9 @@ impl EditorCore { jump_ring: Vec::new(), search_store: crate::search::make_shared_store(), search: None, + clipboard_slot: Vec::new(), + pending_clipboard: None, + menu: crate::menu::make_shared_menu(), } } @@ -1569,6 +1588,216 @@ impl EditorCore { Ok(new_len) } + // ---- clipboard (Q#CM6) ------------------------------------------------- + + /// The identifier under (or immediately left of) the cursor, or + /// `None` when the cursor isn't on a word (Q#CM3, the `symbol` + /// context). A word is a run of ASCII alphanumerics / `_`; since + /// those are all single-byte, the slice always lands on UTF-8 + /// boundaries. + #[must_use] + pub fn word_at_cursor(&self) -> Option { + let bytes = self.buffer_bytes(self.active_buffer_id()); + let is_word = |b: u8| b.is_ascii_alphanumeric() || b == b'_'; + let cursor = (self.cursor() as usize).min(bytes.len()); + let mut start = cursor; + while start > 0 && is_word(bytes[start - 1]) { + start -= 1; + } + let mut end = cursor; + while end < bytes.len() && is_word(bytes[end]) { + end += 1; + } + if start == end { + return None; + } + String::from_utf8(bytes[start..end].to_vec()).ok() + } + + /// Bytes of the active region, or `None` when nothing is selected. + #[must_use] + pub fn region_bytes(&self) -> Option> { + let (lo, hi) = self.active_region()?; + let reg = self.registry.borrow(); + let buf = reg.get(self.active_buffer_id()).ok()?; + let mut out = vec![0u8; (hi - lo) as usize]; + buf.snapshot_rope().slice(lo, hi, &mut out); + Some(out) + } + + /// Copy the active region into the clipboard slot and queue an + /// outbound OS-clipboard publish to the originating frontend. + /// Returns `false` (a no-op) when there is no region. + pub fn clipboard_copy(&mut self) -> bool { + let Some(bytes) = self.region_bytes() else { + return false; + }; + self.clipboard_slot.clone_from(&bytes); + self.pending_clipboard = Some((self.active_frontend, bytes)); + true + } + + /// Cut: copy the region, then delete it. Returns `false` (a no-op) + /// when there is no region. + /// + /// # Errors + /// + /// Propagates [`Self::delete_region`]'s error. + pub fn clipboard_cut(&mut self) -> Result { + if !self.clipboard_copy() { + return Ok(false); + } + self.delete_region()?; + Ok(true) + } + + /// Paste the clipboard slot at the cursor, replacing the active + /// region if one exists (one undo step, like CUA type-over). + /// Returns `false` when the slot is empty. + /// + /// # Errors + /// + /// Propagates the underlying edit error. + pub fn clipboard_paste(&mut self) -> Result { + if self.clipboard_slot.is_empty() { + return Ok(false); + } + let bytes = self.clipboard_slot.clone(); + self.insert_bytes_over_region(&bytes)?; + Ok(true) + } + + /// Insert externally-pasted bytes at the cursor (inbound OS paste: + /// terminal bracketed paste, or GPU Ctrl-V via `arboard`), + /// refreshing the slot so a later in-app paste repeats them. + /// Replaces the active region if one exists. + /// + /// # Errors + /// + /// Propagates the underlying edit error. + pub fn paste_inbound(&mut self, data: &[u8]) -> Result<(), String> { + self.clipboard_slot = data.to_vec(); + self.insert_bytes_over_region(data) + } + + /// Shared insert/replace for paste: `Replace` over the active + /// region, else `Insert` at the cursor. The cursor lands just past + /// the inserted bytes and any selection is cleared. No-op insert for + /// empty `bytes`. + fn insert_bytes_over_region(&mut self, bytes: &[u8]) -> Result<(), String> { + self.active_window_mut().goal_col = None; + let start = if let Some((lo, hi)) = self.active_region() { + self.apply_active_edit(EditOp::Replace { + range: Range { start: lo, end: hi }, + bytes, + })?; + lo + } else { + let pos = self.active_window().cursor; + self.apply_active_edit(EditOp::Insert { pos, bytes })?; + pos + }; + let aw = self.active_window_mut(); + aw.cursor = start + bytes.len() as u64; + aw.selection = None; + Ok(()) + } + + /// Select the whole active buffer (anchor at 0, cursor at the end). + pub fn select_all(&mut self) { + let len = self.active_buffer_len(); + self.begin_selection(0); + let aw = self.active_window_mut(); + aw.cursor = len; + aw.goal_col = None; + } + + /// Drain the one-shot outbound clipboard publish, if any. Called by + /// the dispatcher each tick (alongside `pending_crdt_ops`). + pub fn take_pending_clipboard(&mut self) -> Option<(FrontendId, Vec)> { + self.pending_clipboard.take() + } + + /// The current clipboard slot bytes (testing / introspection). + #[must_use] + pub fn clipboard_slot(&self) -> &[u8] { + &self.clipboard_slot + } + + // ---- context menu (Q#CM1) ---------------------------------------------- + + /// True while a context menu is open. + #[must_use] + pub fn menu_is_open(&self) -> bool { + self.menu.lock().expect("menu mutex poisoned").is_some() + } + + /// Open a menu of resolved `rows` anchored at the absolute `anchor` + /// cell. A no-op (stays closed) when no row is selectable. Attaches + /// the TUI overlay on first open (deduped by kind). + pub fn menu_open(&mut self, rows: Vec, anchor: (u32, u32)) { + let state = crate::menu::MenuState::new(rows, anchor); + let opened = state.is_some(); + *self.menu.lock().expect("menu mutex poisoned") = state; + if opened { + self.ensure_menu_overlay(); + } + } + + /// Close the menu (the overlay then self-suppresses). + pub fn menu_close(&mut self) { + *self.menu.lock().expect("menu mutex poisoned") = None; + } + + /// Move the highlight by `delta` items (wrapping, skipping separators). + pub fn menu_step(&mut self, delta: isize) { + if let Some(m) = self.menu.lock().expect("menu mutex poisoned").as_mut() { + m.step(delta); + } + } + + /// Set the highlight to `row` if it names a selectable item (mouse + /// hover / click). + pub fn menu_set_active_row(&mut self, row: usize) { + if let Some(m) = self.menu.lock().expect("menu mutex poisoned").as_mut() + && matches!(m.rows.get(row), Some(crate::menu::MenuRow::Item { .. })) + { + m.active = row; + } + } + + /// The active item's command name, if a menu is open. + #[must_use] + pub fn menu_active_command(&self) -> Option { + self.menu + .lock() + .expect("menu mutex poisoned") + .as_ref() + .and_then(|m| m.active_command().map(str::to_owned)) + } + + /// Hit-test an absolute cell against the open popup, returning the + /// selectable row it covers (or `None`). + #[must_use] + pub fn menu_hit(&self, row: u32, col: u32) -> Option { + self.menu + .lock() + .expect("menu mutex poisoned") + .as_ref() + .and_then(|m| m.hit(row, col)) + } + + /// Ensure the active window carries a [`crate::menu::MenuView`] + /// overlay (deduped by kind). The view reads the shared `menu`, so + /// one instance suffices; it renders nothing while the menu is closed. + fn ensure_menu_overlay(&mut self) { + let menu = self.menu.clone(); + let win = self.active_window_mut(); + if !win.overlay_kinds().contains(&"context-menu") { + win.push_overlay(Box::new(crate::menu::MenuView::new(menu))); + } + } + /// Safely remove `buffer_id` from the registry. Any window that /// was displaying it is redirected to a fallback buffer (`*scratch*`, /// created on demand) so window state never refers to a missing id. @@ -2030,6 +2259,118 @@ mod tests { assert_eq!(s.active_buffer_len(), 2); } + #[test] + fn copy_captures_region_and_queues_publish() { + let mut s = from_bytes(b"hello world"); + s.begin_selection(0); + s.active_window_mut().cursor = 5; // region [0,5) = "hello" + assert!(s.clipboard_copy()); + assert_eq!(s.clipboard_slot(), b"hello"); + let (fid, bytes) = s.take_pending_clipboard().expect("publish queued"); + assert_eq!(fid, s.active_frontend); + assert_eq!(bytes, b"hello"); + // Drained: second take is None. + assert!(s.take_pending_clipboard().is_none()); + // Copy does not mutate the buffer. + assert_eq!(s.active_buffer_len(), 11); + } + + #[test] + fn copy_without_region_is_a_noop() { + let mut s = from_bytes(b"abc"); + assert!(!s.clipboard_copy()); + assert!(s.clipboard_slot().is_empty()); + assert!(s.take_pending_clipboard().is_none()); + } + + #[test] + fn cut_copies_then_deletes_region() { + let mut s = from_bytes(b"hello world"); + s.begin_selection(6); + s.active_window_mut().cursor = 11; // region [6,11) = "world" + assert!(s.clipboard_cut().unwrap()); + assert_eq!(s.clipboard_slot(), b"world"); + assert_eq!(s.buffer_bytes(s.active_buffer_id()), b"hello "); + assert_eq!(s.cursor(), 6); + } + + #[test] + fn paste_inserts_slot_at_cursor() { + let mut s = from_bytes(b"ac"); + // Seed the slot via a copy. + s.begin_selection(0); + s.active_window_mut().cursor = 1; // "a" + s.clipboard_copy(); + // Paste "a" between a and c. + s.clear_selection(); + s.active_window_mut().cursor = 1; + assert!(s.clipboard_paste().unwrap()); + assert_eq!(s.buffer_bytes(s.active_buffer_id()), b"aac"); + assert_eq!(s.cursor(), 2); + } + + #[test] + fn paste_replaces_active_region_as_one_step() { + let mut s = from_bytes(b"hello world"); + // Copy "hello". + s.begin_selection(0); + s.active_window_mut().cursor = 5; + s.clipboard_copy(); + // Select "world" and paste over it. + s.begin_selection(6); + s.active_window_mut().cursor = 11; + assert!(s.clipboard_paste().unwrap()); + assert_eq!(s.buffer_bytes(s.active_buffer_id()), b"hello hello"); + assert!(s.active_region().is_none()); // selection cleared + } + + #[test] + fn paste_with_empty_slot_is_a_noop() { + let mut s = from_bytes(b"abc"); + assert!(!s.clipboard_paste().unwrap()); + assert_eq!(s.active_buffer_len(), 3); + } + + #[test] + fn paste_inbound_inserts_and_refreshes_slot() { + let mut s = from_bytes(b"ab"); + s.active_window_mut().cursor = 1; + s.paste_inbound(b"XYZ").unwrap(); + assert_eq!(s.buffer_bytes(s.active_buffer_id()), b"aXYZb"); + assert_eq!(s.cursor(), 4); + // Slot refreshed, so an in-app paste repeats the external text. + assert_eq!(s.clipboard_slot(), b"XYZ"); + // Inbound paste does NOT queue an outbound publish (no echo loop). + assert!(s.take_pending_clipboard().is_none()); + } + + #[test] + fn select_all_spans_the_buffer() { + let mut s = from_bytes(b"hello"); + s.active_window_mut().cursor = 2; + s.select_all(); + assert_eq!(s.active_region(), Some((0, 5))); + assert_eq!(s.region_bytes().unwrap(), b"hello"); + } + + #[test] + fn word_at_cursor_reads_the_identifier() { + let mut s = from_bytes(b"foo bar_baz qux"); + s.active_window_mut().cursor = 6; // inside "bar_baz" + assert_eq!(s.word_at_cursor().as_deref(), Some("bar_baz")); + s.active_window_mut().cursor = 0; // start of "foo" + assert_eq!(s.word_at_cursor().as_deref(), Some("foo")); + s.active_window_mut().cursor = 3; // just past "foo" → scans left + assert_eq!(s.word_at_cursor().as_deref(), Some("foo")); + } + + #[test] + fn word_at_cursor_is_none_in_whitespace() { + let mut s = from_bytes(b"a b"); + s.active_window_mut().cursor = 2; // a run of spaces, none adjacent left + assert_eq!(s.word_at_cursor(), None); + } + #[test] fn cursor_navigation_left_right() { let mut s = from_bytes(b"abc"); diff --git a/src/lua.rs b/src/lua.rs index 11026bf..91221fc 100644 --- a/src/lua.rs +++ b/src/lua.rs @@ -35,8 +35,9 @@ use crate::keymap_stack::KeymapStack; use crate::lua_bindings::{ self, CurrentAttachmentSlot, InitCompleteFlag, LocalInstanceInfo, PackageInstallOverride, RequestedAttach, SharedCommandRegistry, SharedCore, SharedHookRegistry, SharedKeymapStack, - SharedRegistry, + SharedMenuRegistry, SharedRegistry, }; +use crate::menu::MenuRegistry; use crate::protocol::{AttachTarget, AttachmentHandle, InstanceIdentity}; // `Rc` and `RefCell` are pulled in for the registry-owning fields. @@ -76,6 +77,9 @@ pub struct LuaHost { /// once T M2.5 lands; M2.4 builds the system without yet routing /// the live event stream through it. keymaps: SharedKeymapStack, + /// Context-menu registry (Q#CM2). Lua bindings (`pmacs.menu.*`) and + /// the menu builder in the core share this through `Rc`. + menus: SharedMenuRegistry, /// Hook registry. Stub for T M2.11 introspection; T M2.6 will wire /// execution at the relevant call sites. hooks: SharedHookRegistry, @@ -142,13 +146,15 @@ impl LuaHost { ); let commands: SharedCommandRegistry = Rc::new(RefCell::new(CommandRegistry::new())); let keymaps: SharedKeymapStack = Rc::new(RefCell::new(KeymapStack::new())); + let menus: SharedMenuRegistry = Rc::new(RefCell::new(MenuRegistry::new())); let hooks: SharedHookRegistry = Rc::new(RefCell::new(HookRegistry::new())); - lua_bindings::install(&lua, ®istry, &commands, &keymaps, &hooks)?; + lua_bindings::install(&lua, ®istry, &commands, &keymaps, &menus, &hooks)?; Ok(Self { lua, registry, commands, keymaps, + menus, hooks, core: None, errors: Vec::new(), @@ -204,6 +210,13 @@ impl LuaHost { &self.keymaps } + /// Shared handle to the context-menu registry. The menu builder + /// resolves visible items against this when a right-click opens the + /// menu (Q#CM2). + pub fn menus(&self) -> &SharedMenuRegistry { + &self.menus + } + /// Shared handle to the hook registry. T M2.11 surfaces it via /// `pmacs.describe.hook`; T M2.6 will wire actual hook execution /// into the appropriate editor lifecycle points. @@ -240,6 +253,12 @@ impl LuaHost { "@pmacs/builtin/keymaps/default.lua", include_str!("../builtin/keymaps/default.lua"), )?; + // Menus last: items reference commands (and conceptually keys), + // so both registries must be populated first. + self.load_builtin( + "@pmacs/builtin/menus/default.lua", + include_str!("../builtin/menus/default.lua"), + )?; Ok(()) } diff --git a/src/lua_bindings.rs b/src/lua_bindings.rs index 7cee70e..d9289e9 100644 --- a/src/lua_bindings.rs +++ b/src/lua_bindings.rs @@ -59,6 +59,7 @@ use crate::highlight::{SyntaxHighlightView, Theme}; use crate::hook::{Hook, HookRegistry}; use crate::key::{display_sequence, parse_sequence}; use crate::keymap_stack::KeymapStack; +use crate::menu::{MenuItem, MenuRegistry}; use crate::packages::{ Address, Fetcher, InstallError, InstallPin, InstallScope, InstallSpec, InstalledPackage, Installer, LookupOutcome, ResolvedKind, lookup_in_roster, @@ -88,6 +89,11 @@ pub type SharedCommandRegistry = Rc>; /// Shared, single-threaded handle to the keymap stack. pub type SharedKeymapStack = Rc>; +/// Shared handle to the context-menu registry. Cloned into the Lua +/// `pmacs.menu.*` closures and stored as app data alongside the command +/// and keymap registries. +pub type SharedMenuRegistry = Rc>; + /// Shared, single-threaded handle to the editor core --- the world /// state mutated by `pmacs.editor.*` primitives invoked from inside /// command bodies. @@ -1882,11 +1888,13 @@ pub fn install( registry: &SharedRegistry, commands: &SharedCommandRegistry, keymaps: &SharedKeymapStack, + menus: &SharedMenuRegistry, hooks: &SharedHookRegistry, ) -> mlua::Result<()> { lua.set_app_data(registry.clone()); lua.set_app_data(commands.clone()); lua.set_app_data(keymaps.clone()); + lua.set_app_data(menus.clone()); lua.set_app_data(hooks.clone()); lua.set_app_data(InitCompleteFlag::new()); lua.set_app_data(RequestedAttach::new()); @@ -1901,6 +1909,7 @@ pub fn install( pmacs.set("buffer", install_buffer_module(lua, registry)?)?; pmacs.set("command", install_command_module(lua, commands)?)?; pmacs.set("keymap", install_keymap_module(lua, keymaps)?)?; + pmacs.set("menu", install_menu_module(lua, menus)?)?; pmacs.set("hook", install_hook_module(lua, hooks)?)?; // Wall-clock millis (since UNIX epoch). Used by builtin runtime // chunks for timeout loops; `os.clock()` only counts CPU time and @@ -4417,6 +4426,113 @@ fn install_command_module(lua: &Lua, commands: &SharedCommandRegistry) -> mlua:: Ok(command) } +/// Install `pmacs.menu.*` --- the context-menu item registry (Q#CM2). +/// +/// Mirrors [`install_command_module`]: each closure clones the shared +/// `Rc` and borrows on demand. `item` registers, `list` introspects, +/// `remove`/`clear` tear down. Menu items reference commands by name +/// (resolved at invoke time), so this module has no dependency on the +/// command registry. +fn install_menu_module(lua: &Lua, menus: &SharedMenuRegistry) -> mlua::Result { + let menu = lua.create_table()?; + + { + let ms = menus.clone(); + menu.set( + "item", + lua.create_function(move |lua, spec: Table| -> mlua::Result<()> { + let item = build_menu_item_from_spec(lua, &spec)?; + ms.borrow_mut().add(item).map_err(mlua::Error::external)?; + Ok(()) + })?, + )?; + } + + { + let ms = menus.clone(); + menu.set( + "list", + lua.create_function(move |lua, ()| { + let r = ms.borrow(); + let out = lua.create_table()?; + for (i, item) in r.items().iter().enumerate() { + let t = lua.create_table()?; + if let Some(id) = &item.id { + t.set("id", id.clone())?; + } + t.set("label", item.label.clone())?; + t.set("command", item.command.clone())?; + if let Some(context) = &item.context { + t.set("context", context.clone())?; + } + t.set("group", item.group.clone())?; + t.set("order", item.order)?; + t.set("has_predicate", item.predicate.is_some())?; + out.set(i + 1, t)?; + } + Ok(out) + })?, + )?; + } + + { + // `pmacs.menu.remove(id)` drops the item(s) carrying `id`. + // Returns `true` if anything was removed --- the symmetric + // inverse of `item`, mirroring `pmacs.command.unregister`. Lets + // a user config hide a builtin item idempotently. + let ms = menus.clone(); + menu.set( + "remove", + lua.create_function(move |_, id: String| Ok(ms.borrow_mut().remove(&id)))?, + )?; + } + + { + // `pmacs.menu.clear()` empties the registry --- the reset used + // when a config wants to rebuild the menu from scratch. + let ms = menus.clone(); + menu.set( + "clear", + lua.create_function(move |_, ()| { + ms.borrow_mut().clear(); + Ok(()) + })?, + )?; + } + + { + // `pmacs.menu._raw()` --- internal accessor returning items + // *with* their predicate functions (which `list` omits), so the + // Lua menu builder (`pmacs.menu.build`) can evaluate visibility. + // Underscore-prefixed: not part of the user-facing surface. + let ms = menus.clone(); + menu.set( + "_raw", + lua.create_function(move |lua, ()| { + let r = ms.borrow(); + let out = lua.create_table()?; + for (i, item) in r.items().iter().enumerate() { + let t = lua.create_table()?; + t.set("label", item.label.clone())?; + t.set("command", item.command.clone())?; + if let Some(context) = &item.context { + t.set("context", context.clone())?; + } + t.set("group", item.group.clone())?; + t.set("order", item.order)?; + if let Some(predicate) = &item.predicate { + t.set("predicate", predicate.clone())?; + } + out.set(i + 1, t)?; + } + Ok(out) + })?, + )?; + } + + Ok(menu) +} + #[allow( clippy::too_many_lines, reason = "seven help bindings each follow the same pattern; splitting them adds ceremony without clarity" @@ -11558,6 +11674,16 @@ fn install_session(editor: &Table, lua: &Lua, core: &SharedCore) -> mlua::Result })?, )?; } + { + // The identifier under the cursor, or nil (Q#CM3 `symbol` + // context). The context menu uses it to decide whether to show + // symbol-oriented LSP items. + let cc = core.clone(); + editor.set( + "word_at_cursor", + lua.create_function(move |_, ()| Ok(cc.borrow().word_at_cursor()))?, + )?; + } { // Active buffer's backing file path, or `nil` if none. Used by // the LSP runtime to compute file:// URIs and locate the @@ -11632,6 +11758,50 @@ fn install_session(editor: &Table, lua: &Lua, core: &SharedCore) -> mlua::Result })?, )?; } + // Q#CM6: clipboard primitives. Copy/cut publish the region to the + // OS clipboard (the daemon drains the queued publish and sends + // `InstanceSignal::Clipboard` to the originating frontend); paste + // inserts the in-core slot. Each returns whether it acted, so the + // `edit.*` commands can report status / fall through. + { + let cc = core.clone(); + editor.set( + "clipboard_copy", + lua.create_function(move |_, ()| Ok(cc.borrow_mut().clipboard_copy()))?, + )?; + } + { + let cc = core.clone(); + editor.set( + "clipboard_cut", + lua.create_function(move |_, ()| -> mlua::Result { + cc.borrow_mut() + .clipboard_cut() + .map_err(mlua::Error::external) + })?, + )?; + } + { + let cc = core.clone(); + editor.set( + "clipboard_paste", + lua.create_function(move |_, ()| -> mlua::Result { + cc.borrow_mut() + .clipboard_paste() + .map_err(mlua::Error::external) + })?, + )?; + } + { + let cc = core.clone(); + editor.set( + "select_all", + lua.create_function(move |_, ()| { + cc.borrow_mut().select_all(); + Ok(()) + })?, + )?; + } Ok(()) } @@ -12089,6 +12259,64 @@ fn build_command_from_spec(lua: &Lua, spec: &Table) -> mlua::Result { }) } +/// Build a [`MenuItem`] from a `pmacs.menu.item` spec table. +/// +/// Mirrors [`build_command_from_spec`]: rejects unknown keys (R50 +/// typo-detection) before reading, then pulls the fields. `label` and +/// `command` are required strings; `id`, `context`, `predicate`, +/// `group`, and `order` are optional. The registry validates the +/// `context` vocabulary and non-empty invariants. +fn build_menu_item_from_spec(lua: &Lua, spec: &Table) -> mlua::Result { + for pair in spec.clone().pairs::() { + let (k, _) = pair?; + let key = match k { + Value::String(s) => s.to_str()?.to_string(), + other => { + return Err(mlua::Error::external(BindingError::NonStringSpecKey { + got: other.type_name().to_string(), + })); + } + }; + if !matches!( + key.as_str(), + "id" | "label" | "command" | "context" | "predicate" | "group" | "order" + ) { + return Err(mlua::Error::external( + crate::menu::MenuError::UnknownField { field: key }, + )); + } + } + + let label: String = spec.get("label").map_err(|_| { + mlua::Error::external(BindingError::SpecFieldType { + field: "label", + expected: "string", + }) + })?; + let command: String = spec.get("command").map_err(|_| { + mlua::Error::external(BindingError::SpecFieldType { + field: "command", + expected: "string", + }) + })?; + let id: Option = spec.get("id")?; + let context: Option = spec.get("context")?; + let predicate: Option = spec.get("predicate")?; + let group: String = spec.get::>("group")?.unwrap_or_default(); + let order: i64 = spec.get::>("order")?.unwrap_or(0); + + Ok(MenuItem { + id, + label, + command, + context, + predicate, + group, + order, + source: caller_source(lua, 2), + }) +} + /// Inspect the Lua call stack at `level` frames above the C boundary /// and return the caller's source location, or a default if debug info /// is unavailable. @@ -12188,8 +12416,12 @@ mod tests { let reg: SharedRegistry = Rc::new(RefCell::new(BufferRegistry::new())); let cmds: SharedCommandRegistry = Rc::new(RefCell::new(CommandRegistry::new())); let kms: SharedKeymapStack = Rc::new(RefCell::new(KeymapStack::new())); + // The menu registry isn't returned --- install clones it into + // app data, which keeps it alive for the VM's lifetime, so tests + // that don't exercise menus needn't carry the handle. + let mns: SharedMenuRegistry = Rc::new(RefCell::new(MenuRegistry::new())); let hks: SharedHookRegistry = Rc::new(RefCell::new(HookRegistry::new())); - install(&lua, ®, &cmds, &kms, &hks).expect("install"); + install(&lua, ®, &cmds, &kms, &mns, &hks).expect("install"); (lua, reg, cmds, kms, hks) } @@ -12224,6 +12456,97 @@ mod tests { assert_eq!(len, 5); } + #[test] + fn menu_item_registers_and_lists_with_defaults() { + let (lua, _reg, _cmds, _kms, _hks) = fresh(); + let (label, command, group, order, has_pred): (String, String, String, i64, bool) = lua + .load( + r#" + pmacs.menu.item { label = "Copy", command = "edit.copy" } + local items = pmacs.menu.list() + assert(#items == 1, "one item") + local it = items[1] + return it.label, it.command, it.group, it.order, it.has_predicate + "#, + ) + .eval() + .unwrap(); + assert_eq!(label, "Copy"); + assert_eq!(command, "edit.copy"); + assert_eq!(group, ""); // group defaults to empty + assert_eq!(order, 0); // order defaults to 0 + assert!(!has_pred); // no predicate given + } + + #[test] + fn menu_item_carries_context_and_predicate_through() { + let (lua, _reg, _cmds, _kms, _hks) = fresh(); + let (context, has_pred): (String, bool) = lua + .load( + r#" + pmacs.menu.item { + label = "Paste", command = "edit.paste", + context = "selection", + predicate = function(cx) return true end, + group = "edit", order = 30, + } + local it = pmacs.menu.list()[1] + return it.context, it.has_predicate + "#, + ) + .eval() + .unwrap(); + assert_eq!(context, "selection"); + assert!(has_pred); + } + + #[test] + fn menu_remove_and_clear_work() { + let (lua, _reg, _cmds, _kms, _hks) = fresh(); + let (removed, removed_again, after_clear): (bool, bool, i64) = lua + .load( + r#" + pmacs.menu.item { id = "a", label = "A", command = "cmd.a" } + pmacs.menu.item { label = "B", command = "cmd.b" } + local r1 = pmacs.menu.remove("a") + local r2 = pmacs.menu.remove("a") + pmacs.menu.clear() + return r1, r2, #pmacs.menu.list() + "#, + ) + .eval() + .unwrap(); + assert!(removed); + assert!(!removed_again); + assert_eq!(after_clear, 0); + } + + #[test] + fn menu_item_rejects_unknown_field() { + let (lua, _reg, _cmds, _kms, _hks) = fresh(); + let err = lua + .load(r#"pmacs.menu.item { label = "X", command = "x", colour = "red" }"#) + .exec() + .unwrap_err(); + assert!( + err.to_string().contains("unknown field `colour`"), + "got: {err}" + ); + } + + #[test] + fn menu_item_rejects_unknown_context() { + let (lua, _reg, _cmds, _kms, _hks) = fresh(); + let err = lua + .load(r#"pmacs.menu.item { label = "X", command = "x", context = "selecton" }"#) + .exec() + .unwrap_err(); + assert!( + err.to_string().contains("unknown context `selecton`"), + "got: {err}" + ); + } + #[test] fn from_file_loads_existing_file_as_clean_buffer() { let (lua, _reg, _cmds, _kms, _hks) = fresh(); From b934723dfd312972810807127eda0fc17406a6a0 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 27 Jun 2026 22:03:58 -0400 Subject: [PATCH 3/5] pmacs context menu: clipboard commands/keys + LSP context accessor (Q#CM5/Q#CM6) The Lua glue that gives the menu real items to surface. - `edit.copy/cut/paste/select-all` commands (Q#CM6) over the core clipboard, with the Emacs kill/yank bindings `M-w`/`C-w`/`C-y` and `C-x h` (the CUA trio's keys are already bound: `C-a` line-start, `C-v` page-down). - `pmacs.lsp.active_attachment()` (Q#CM5): a pure, side-effect-free attachment lookup for the menu's `symbol`/`diagnostic` visibility checks. Unlike `attached_for_active`, it never triggers an attach just because the menu opened. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U --- builtin/commands/default.lua | 25 +++++++++++++++++++++++++ builtin/keymaps/default.lua | 9 +++++++++ builtin/runtime/lsp.lua | 12 ++++++++++++ 3 files changed, 46 insertions(+) diff --git a/builtin/commands/default.lua b/builtin/commands/default.lua index af31814..b220a74 100644 --- a/builtin/commands/default.lua +++ b/builtin/commands/default.lua @@ -160,6 +160,31 @@ cmd { name = "region.cancel", description = "Drop any active selection without changing the cursor.", fn = function() ed.clear_selection() end } +-- Clipboard (Q#CM6) ---------------------------------------------------------- +-- Copy/cut publish the selection to the OS clipboard (OSC 52 in the TUI, +-- arboard in the GPU); paste inserts the in-app slot, which Ctrl-V / +-- bracketed paste also refreshes. The default bindings are the Emacs +-- kill/yank set (M-w / C-w / C-y, C-x h), which were all free. + +cmd { name = "edit.copy", + description = "Copy the active region to the clipboard.", + fn = function() + if not ed.clipboard_copy() then ed.set_status("no region") end + end } +cmd { name = "edit.cut", + description = "Cut the active region to the clipboard.", + fn = function() + if not ed.clipboard_cut() then ed.set_status("no region") end + end } +cmd { name = "edit.paste", + description = "Paste the clipboard at the cursor, replacing any region.", + fn = function() + if not ed.clipboard_paste() then ed.set_status("clipboard empty") end + end } +cmd { name = "edit.select-all", + description = "Select the whole buffer.", + fn = function() ed.select_all() end } + -- File I/O ------------------------------------------------------------------- cmd { name = "buffer.save", description = "Save the current buffer to its backing file.", diff --git a/builtin/keymaps/default.lua b/builtin/keymaps/default.lua index 18b9386..0c9f612 100644 --- a/builtin/keymaps/default.lua +++ b/builtin/keymaps/default.lua @@ -103,6 +103,15 @@ bind("C-S-", "cursor.select-word-right") bind("C-S-", "cursor.select-paragraph-up") bind("C-S-", "cursor.select-paragraph-down") +-- Clipboard (Q#CM6). The Emacs kill/yank set --- all of these were free +-- in the default map (C-a / C-v are taken for line-start / page-down, so +-- the CUA trio would have clobbered motion). C-w cuts, M-w copies, C-y +-- pastes; C-x h selects the whole buffer (Emacs mark-whole-buffer). +bind("M-w", "edit.copy") +bind("C-w", "edit.cut") +bind("C-y", "edit.paste") +bind("C-x h", "edit.select-all") + -- Undo / redo ---------------------------------------------------------------- -- -- Multiple undo bindings exist because terminals translate Ctrl+/ diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index 9699367..032f3db 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -532,6 +532,18 @@ local function attached_for_active() return attach_buffer(buf) end +-- Pure, side-effect-free attachment lookup for the active buffer: +-- returns the live record (with `.uri`) when a server is already +-- attached, else nil. Unlike `attached_for_active`, it never *triggers* +-- an attach --- the context menu (Q#CM3) calls it to decide whether to +-- show symbol/diagnostic items, and must not perturb LSP state just by +-- opening. +function pmacs.lsp.active_attachment() + local buf = pmacs.window.buffer() + if not buf then return nil end + return attachments[tostring(buf)] +end + -- Hooks -------------------------------------------------------------------- pmacs.hook.add("buffer.after-load", function() From 640b998d6b955c3648489f159274eca3a023c92d Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 27 Jun 2026 22:19:00 -0400 Subject: [PATCH 4/5] pmacs context menu: protocol v11 + dispatch + TUI/GPU surfaces (Q#CM1/Q#CM5) The wiring that makes the menu and OS clipboard work end-to-end. The protocol bump touches every exhaustive match on the wire enums, so the daemon / frontend / GPU consumers all land together. Protocol v11 (additive; SUPPORTED = [6..11]): - `PointerKind::Context` (right-click), `FrontendEvent::MenuPointer` (GPU->daemon navigation, index-only), `InstanceMessage::MenuPrompt` + `MenuPromptRow` (daemon->GPU rows + highlight, daemon-gated >= 11). Dispatch + producer: - `EditorState`: menu interception in `dispatch_key`/`dispatch_mouse`, `MenuKey`, `dispatch_menu_key`/`_mouse`, `open_context_menu` (TUI) / `open_menu_at_byte` + `dispatch_menu_pointer` (GPU), `build_menu_rows` (calls the Lua resolver), `dispatch_idle` now false while a menu is open. `dispatch_pointer` gains the `Context` arm. - daemon: routes `Context` -> open, `MenuPointer` -> navigate; gates `MenuPrompt` >= 11; drains the clipboard publish as `InstanceSignal::Clipboard`; honors the previously-dropped `FrontendEvent::Paste` (so paste works for the first time). - `semantic_render`: `MenuPrompt` producer with cached-compare. Frontends: - TUI (`frontend.rs`): OSC 52 clipboard write; ignores `MenuPrompt` (the cell overlay renders the menu). - GPU (`pmacs-gpu`): `arboard` dep; clipboard write/read + Ctrl-V inbound paste; right-click -> `Context`; `MenuLocal` + `MenuPrompt` handler; the popup (a second `TextRenderer` over bg quads) at the click pixel; hover/click -> `MenuPointer`; key intercept while open. Also folds a pre-existing clippy `unnested_or_patterns` nit in a search test (`Color::Indexed(11 | 3)`) that newer CI clippy surfaced. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U --- Cargo.lock | 139 +++++++++++- pmacs-gpu/Cargo.toml | 5 + pmacs-gpu/src/attach.rs | 27 +++ pmacs-gpu/src/main.rs | 410 +++++++++++++++++++++++++++++++++- pmacs-protocol/src/lib.rs | 2 +- pmacs-protocol/src/message.rs | 56 ++++- src/daemon.rs | 68 +++++- src/editor.rs | 399 ++++++++++++++++++++++++++++++++- src/frontend.rs | 59 ++++- src/protocol.rs | 21 +- src/semantic_render.rs | 70 +++++- 11 files changed, 1228 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 14013ba..7faecf7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -151,6 +151,24 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.59.0", + "wl-clipboard-rs", + "x11rb", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -387,6 +405,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "cobs" version = "0.3.0" @@ -836,6 +863,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "etagere" version = "0.3.0" @@ -877,6 +910,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "fnv" version = "1.0.7" @@ -1899,6 +1938,15 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nonmax" version = "0.5.5" @@ -2051,6 +2099,18 @@ dependencies = [ "objc2-quartz-core 0.2.2", ] +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-cloud-kit" version = "0.2.2" @@ -2098,6 +2158,19 @@ dependencies = [ "objc2 0.6.4", ] +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + [[package]] name = "objc2-core-image" version = "0.2.2" @@ -2152,6 +2225,17 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + [[package]] name = "objc2-link-presentation" version = "0.2.2" @@ -2160,7 +2244,7 @@ checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" dependencies = [ "block2 0.5.1", "objc2 0.5.2", - "objc2-app-kit", + "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", ] @@ -2300,6 +2384,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "owned_ttf_parser" version = "0.25.1" @@ -2381,6 +2475,17 @@ dependencies = [ "sha2", ] +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "pin-project" version = "1.1.13" @@ -2457,6 +2562,7 @@ dependencies = [ name = "pmacs-gpu" version = "0.0.1" dependencies = [ + "arboard", "env_logger", "glyphon", "loro", @@ -3665,6 +3771,17 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "ttf-parser" version = "0.25.1" @@ -4455,7 +4572,7 @@ dependencies = [ "memmap2", "ndk", "objc2 0.5.2", - "objc2-app-kit", + "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", "objc2-ui-kit", "orbclient", @@ -4610,6 +4727,24 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.4", + "thiserror 2.0.18", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "x11-dl" version = "2.21.0" diff --git a/pmacs-gpu/Cargo.toml b/pmacs-gpu/Cargo.toml index 47a47c6..adbe4c7 100644 --- a/pmacs-gpu/Cargo.toml +++ b/pmacs-gpu/Cargo.toml @@ -37,6 +37,11 @@ similar_names = "allow" multiple_crate_versions = "allow" [dependencies] +# OS clipboard for cut/copy/paste (Q#CM6). `wayland-data-control` adds +# the zwlr_data_control backend so the clipboard works under Wayland +# without a window handle; the default X11 backend covers X sessions. +# `image-data` is dropped --- pmacs only round-trips text. +arboard = { version = "3", default-features = false, features = ["wayland-data-control"] } env_logger = "0.11.10" # Text shaping + GPU rendering. `glyphon` re-exports the `cosmic-text` # types it pins (`Buffer`, `Attrs`, `Family`, `FontSystem`, diff --git a/pmacs-gpu/src/attach.rs b/pmacs-gpu/src/attach.rs index 97b1262..13869e1 100644 --- a/pmacs-gpu/src/attach.rs +++ b/pmacs-gpu/src/attach.rs @@ -279,6 +279,33 @@ impl AttachClient { }) } + /// Send a `FrontendEvent::Paste` (Q#CM6) carrying OS-clipboard + /// bytes read locally via `arboard` on Ctrl-V. The daemon inserts it + /// at the cursor (replacing any region) and refreshes its clipboard + /// slot, exactly as it handles the TUI's bracketed paste. + pub fn send_paste(&self, data: Vec) -> Result<(), TransportError> { + self.send_event(FrontendEvent::Paste { + frontend_id: self.frontend_id, + data, + }) + } + + /// Send a `FrontendEvent::MenuPointer` (Q#CM1) — open-menu + /// navigation hit-tested locally against the popup we drew. `index` + /// is the row the pointer is over (`None` = off the menu); `invoke` + /// marks a click (invoke the row, or dismiss when `index` is `None`). + pub fn send_menu_pointer( + &self, + index: Option, + invoke: bool, + ) -> Result<(), TransportError> { + self.send_event(FrontendEvent::MenuPointer { + frontend_id: self.frontend_id, + index, + invoke, + }) + } + /// The daemon's negotiated wire version from `Hello`. pub fn server_protocol_version(&self) -> u32 { self.server_protocol_version diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 41edc39..23bc7a1 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -35,8 +35,9 @@ use glyphon::{ use loro::{ContainerTrait, ExportMode}; use pmacs_protocol::{ AdornmentContent, AdornmentPlacement, BufferId, ByteRange, CrdtOp, Decoration, DecorationKind, - DecorationSegment, FrontendId, InlineAdornment, InstanceMessage, Key as ProtocolKey, Modifiers, - PointerKind, SelectionSnapshot, StyleSegment, StyleSpan, + DecorationSegment, FrontendId, InlineAdornment, InstanceMessage, InstanceSignal, + Key as ProtocolKey, MenuPromptRow, Modifiers, PointerKind, SelectionSnapshot, StyleSegment, + StyleSpan, cell::{Color as CellColor, Style as CellStyle}, }; use wgpu::MultisampleState; @@ -109,6 +110,20 @@ const STATUS_BAND_BG: [f32; 4] = [0.105, 0.105, 0.145, 1.0]; const STATUS_TEXT_PAD: f32 = 10.0; const STATUS_FONT_SIZE: f32 = 13.0; const STATUS_LINE_HEIGHT: f32 = 18.0; +// Context menu popup (Q#CM1). One row per item/separator; width tracks +// the widest label (estimated from a fixed per-char advance, which the +// code font's monospacing makes good enough for hit-testing + the bg +// quad to agree). +const MENU_ROW_HEIGHT: f32 = 22.0; +const MENU_FONT_SIZE: f32 = 14.0; +const MENU_LINE_HEIGHT: f32 = 22.0; +const MENU_PAD_X: f32 = 12.0; +const MENU_CHAR_W: f32 = 8.4; +const MENU_MIN_WIDTH: f32 = 140.0; +const MENU_MAX_WIDTH: f32 = 380.0; +const MENU_BG: [f32; 4] = [0.16, 0.16, 0.20, 0.98]; +const MENU_SELECTED_BG: [f32; 4] = [0.20, 0.40, 0.66, 1.0]; +const MENU_SEPARATOR_BG: [f32; 4] = [0.30, 0.30, 0.36, 1.0]; const QUAD_SHADER: &str = r" struct VertexOut { @builtin(position) pos: vec4, @@ -418,6 +433,11 @@ struct State { /// keys round-trip so minibuffer and prefix commands keep their /// daemon-owned semantics. dispatch_idle: bool, + /// OS clipboard handle (Q#CM6), created lazily on first cut / copy / + /// paste. `None` until first use or when the platform clipboard is + /// unavailable (headless / unsupported compositor) --- clipboard ops + /// then degrade to no-ops rather than crashing. + clipboard: Option, /// Whether `own_cursor` is still an authoritative position for /// local optimistic insertion. Round-tripped keys can move the /// daemon cursor in ways the GPU does not predict, so they mark @@ -535,6 +555,21 @@ struct State { /// the buffer name; the matches highlight via `SearchMatch` /// decorations. search_prompt: Option, + /// Q#CM1 — the live context menu (protocol v11), or `None` when + /// closed. The rows + highlight come from `MenuPrompt`; the popup + /// draws at the pixel of the right-click. + menu: Option, + /// Pixel of the most recent right-click, remembered so the + /// `MenuPrompt` that follows can anchor the popup there. + menu_anchor_px: (f64, f64), + /// Shaped label text for the open menu (Q#CM1), one line per row. + menu_buffer: Buffer, + /// Dedicated text renderer for the menu, so its glyphs draw in a + /// layer *over* the buffer text + caret (a popup), not interleaved + /// with them in the main text pass. + menu_text_renderer: TextRenderer, + /// Popup background / highlight / separator quads (Q#CM1). + menu_bg_vertex_buffer: ReusableVertexBuffer, /// Minimap vertex bytes cached by [`MinimapCacheKey`] — /// rebuilding rescanned every line shape per frame. minimap_cache: Option<(MinimapCacheKey, Vec)>, @@ -563,6 +598,17 @@ struct SearchPromptLocal { invalid: bool, } +/// The live context menu (Q#CM1, protocol v11), mirrored from a +/// `MenuPrompt` with non-empty rows. The popup draws at `anchor_px` +/// (the right-click pixel, remembered locally — the daemon never sees +/// pixels). +#[derive(Clone, Debug, PartialEq)] +struct MenuLocal { + rows: Vec, + active: Option, + anchor_px: (f64, f64), +} + /// pmacs-gpu's own cursor position, mirrored from `CursorByte`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct OwnCursor { @@ -612,10 +658,31 @@ impl App { } else { kind }; + // Context (right-click, Q#CM1) is a v11 variant; a pre-v11 + // instance can't open a menu, so drop the gesture rather than + // sending an undecodable variant. + if kind == PointerKind::Context && client.server_protocol_version() < 11 { + return; + } if let Err(e) = client.send_pointer(buffer_id, byte, kind, mods) { eprintln!("pmacs-gpu: send_pointer failed: {e}"); } } + + /// Ship a [`pmacs_protocol::FrontendEvent::MenuPointer`] if the + /// daemon speaks v11+ (Q#CM1). Navigates the open menu the daemon + /// owns; pixels stay local, only the resolved row index crosses. + fn send_menu_pointer(&self, index: Option, invoke: bool) { + let Some(client) = self.attach_client.as_ref() else { + return; + }; + if client.server_protocol_version() < 11 { + return; + } + if let Err(e) = client.send_menu_pointer(index, invoke) { + eprintln!("pmacs-gpu: send_menu_pointer failed: {e}"); + } + } } impl ApplicationHandler for App { @@ -690,6 +757,25 @@ impl ApplicationHandler for App { let Some((pkey, pmods)) = translate_key(&key.logical_key, self.modifiers) else { return; }; + + // Ctrl-V — OS paste (Q#CM6). Read the system clipboard + // locally via arboard and ship it as a `Paste` event; the + // daemon inserts it. Handled before binding `client` so + // the `&mut self` clipboard read doesn't conflict with the + // client borrow. Skipped while intercepting (the daemon's + // active handler owns the key then). The daemon keymap's + // C-y yanks the in-app slot instead. + if !intercept && pkey == ProtocolKey::Char('v') && pmods == Modifiers::CTRL { + let bytes = self.state.as_mut().and_then(State::read_os_clipboard); + if let Some(bytes) = bytes + && let Some(client) = self.attach_client.as_ref() + && let Err(e) = client.send_paste(bytes) + { + eprintln!("pmacs-gpu: send_paste failed: {e}"); + } + return; + } + let Some(client) = self.attach_client.as_ref() else { return; }; @@ -727,6 +813,21 @@ impl ApplicationHandler for App { return; } + // Clipboard command chords (Q#CM6): M-w copy, C-w cut, + // C-y yank. Like the search-entry chords, these drive + // daemon `edit.*` commands and are otherwise withheld, so + // forward them explicitly. (OS paste is Ctrl-V, handled + // locally above.) + if is_clipboard_chord(pkey, pmods) { + if let Some(state) = self.state.as_mut() { + state.mark_cursor_stale_after_round_trip(); + } + if let Err(e) = client.send_key(pkey, pmods) { + eprintln!("pmacs-gpu: send_key (clipboard) failed: {e}"); + } + return; + } + // Session B2 forwards cursor motion + plain text editing // (Char / Backspace / Enter / Delete / Tab). Ctrl/Alt/ // Meta chords are withheld — they drive commands and @@ -799,6 +900,19 @@ impl ApplicationHandler for App { return; }; state.pointer_pos = Some((position.x, position.y)); + // Q#CM1 — while the menu is open, motion only moves the + // highlight; send a hover when the item under the pointer + // changes from the daemon's current active row. + if state.menu.is_some() { + let hit = state.menu_hit(position.x, position.y); + let active = state.menu.as_ref().and_then(|m| m.active); + if let Some((row, true)) = hit + && active != Some(row) + { + self.send_menu_pointer(Some(row), false); + } + return; + } if state.minimap_scrub_active { // Scrubbing (Q#M6): the press began on the // minimap; motion keeps jumping, even if the @@ -847,6 +961,22 @@ impl ApplicationHandler for App { let Some((x, y)) = state.pointer_pos else { return; }; + // Q#CM1 — while the menu is open the left button drives + // it: a press invokes the row under the pointer (or + // dismisses on a click outside); a release is swallowed. + if state.menu.is_some() { + if button_state == ElementState::Pressed { + let action = match state.menu_hit(x, y) { + Some((row, true)) => Some((Some(row), true)), + Some((_, false)) => None, // separator — ignore + None => Some((None, true)), // outside — dismiss + }; + if let Some((index, invoke)) = action { + self.send_menu_pointer(index, invoke); + } + } + return; + } let mods = translate_mods(self.modifiers); match button_state { ElementState::Pressed => { @@ -900,6 +1030,34 @@ impl ApplicationHandler for App { } } } + // Q#CM1 — right-click opens the context menu at the hit byte + // (or dismisses an open one). The anchor pixel is remembered + // so the popup the daemon sends back draws at the click. + WindowEvent::MouseInput { + state: ElementState::Pressed, + button: winit::event::MouseButton::Right, + .. + } => { + let Some(state) = self.state.as_mut() else { + return; + }; + let Some((x, y)) = state.pointer_pos else { + return; + }; + if state.menu.is_some() { + self.send_menu_pointer(None, true); + return; + } + let Some(byte) = state.hit_test_source_byte(x, y) else { + return; + }; + state.menu_anchor_px = (x, y); + let buffer_id = state.current_buffer_id; + let mods = translate_mods(self.modifiers); + if let Some(buffer_id) = buffer_id { + self.send_pointer(buffer_id, byte, PointerKind::Context, mods); + } + } WindowEvent::MouseWheel { delta, .. } => { let Some(state) = self.state.as_mut() else { return; @@ -1394,6 +1552,9 @@ impl State { let mut atlas = TextAtlas::new(&device, &queue, &cache, surface_format); let text_renderer = TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None); + // Q#CM1 — a second renderer so the menu draws as a top layer. + let menu_text_renderer = + TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None); let quad_renderer = QuadRenderer::new(&device, surface_format); let squiggle_renderer = SquiggleRenderer::new(&device, surface_format); @@ -1425,6 +1586,15 @@ impl State { Some(config.width as f32), Some(STATUS_BAND_HEIGHT), ); + let mut menu_buffer = Buffer::new( + &mut font_system, + Metrics::new(MENU_FONT_SIZE, MENU_LINE_HEIGHT), + ); + menu_buffer.set_size( + &mut font_system, + Some(MENU_MAX_WIDTH), + Some(config.height as f32), + ); buffer.set_text( &mut font_system, initial_text, @@ -1468,6 +1638,7 @@ impl State { last_viewport_sent: None, local_frontend_id: None, dispatch_idle: false, + clipboard: None, cursor_fresh: false, optimistic_cursor_floor: None, deferred_round_trip_keys: Vec::new(), @@ -1496,6 +1667,11 @@ impl State { status_left_text: String::new(), status_facts: None, search_prompt: None, + menu: None, + menu_anchor_px: (0.0, 0.0), + menu_buffer, + menu_text_renderer, + menu_bg_vertex_buffer: ReusableVertexBuffer::new(), minimap_cache: None, } } @@ -1516,7 +1692,9 @@ impl State { /// every key to the daemon's handler instead of optimistically /// applying it to the buffer. fn daemon_intercepts_keys(&self) -> bool { - self.search_prompt.is_some() || !self.dispatch_idle + // Q#CM1 — an open menu shadows the keymap like search: every key + // round-trips so the daemon's `dispatch_menu_key` drives it. + self.search_prompt.is_some() || self.menu.is_some() || !self.dispatch_idle } /// Shared eligibility gates for the optimistic edit paths @@ -1841,6 +2019,45 @@ impl State { /// ignored — pmacs-gpu lays out locally and tracks the cursor via /// `PresenceUpdate` (session 9.3). Remaining semantic variants land /// in subsequent Phase A sessions. + /// Lazily-created OS clipboard handle (Q#CM6). Returns `None` if the + /// platform clipboard can't be opened, so callers degrade to no-ops. + fn os_clipboard(&mut self) -> Option<&mut arboard::Clipboard> { + if self.clipboard.is_none() { + match arboard::Clipboard::new() { + Ok(c) => self.clipboard = Some(c), + Err(e) => { + eprintln!("pmacs-gpu: OS clipboard unavailable: {e}"); + return None; + } + } + } + self.clipboard.as_mut() + } + + /// Read the OS clipboard as bytes (for Ctrl-V → `Paste`). `None` on + /// any failure (empty / non-text / unavailable). + fn read_os_clipboard(&mut self) -> Option> { + match self.os_clipboard()?.get_text() { + Ok(s) => Some(s.into_bytes()), + Err(e) => { + eprintln!("pmacs-gpu: clipboard read failed: {e}"); + None + } + } + } + + /// Write bytes to the OS clipboard (for an inbound + /// `Signal::Clipboard` after a daemon copy/cut). Lossy UTF-8; the + /// daemon only ever sends valid document text. + fn write_os_clipboard(&mut self, bytes: &[u8]) { + let text = String::from_utf8_lossy(bytes).into_owned(); + if let Some(c) = self.os_clipboard() + && let Err(e) = c.set_text(text) + { + eprintln!("pmacs-gpu: clipboard write failed: {e}"); + } + } + #[allow(clippy::too_many_lines)] // per-variant match dispatcher; one arm per InstanceMessage. fn apply_attach_message(&mut self, msg: InstanceMessage) -> Option { match msg { @@ -2235,6 +2452,28 @@ impl State { self.dispatch_idle = idle; None } + // Q#CM6 — a daemon copy/cut published the region; write it to + // the OS clipboard via arboard so other apps can paste it. + InstanceMessage::Signal(InstanceSignal::Clipboard(bytes)) => { + self.write_os_clipboard(&bytes); + None + } + // Q#CM1 — the context menu's rows + highlight. Empty rows + // close it; otherwise anchor the popup at the remembered + // right-click pixel. + InstanceMessage::MenuPrompt { rows, active, .. } => { + self.menu = if rows.is_empty() { + None + } else { + Some(MenuLocal { + rows, + active, + anchor_px: self.menu_anchor_px, + }) + }; + self.window.request_redraw(); + None + } _ => None, } } @@ -2367,6 +2606,35 @@ impl State { minimap_band_contains(x as f32, y as f32, self.config.width, self.config.height) } + /// Popup width in pixels (Q#CM1) — widest label estimated from a + /// fixed per-char advance, padded, clamped. Used by both hit-testing + /// and the bg quad so they line up. + fn menu_width_px(menu: &MenuLocal) -> f32 { + let max_chars = menu + .rows + .iter() + .map(|r| r.label.chars().count()) + .max() + .unwrap_or(0); + (max_chars as f32 * MENU_CHAR_W + 2.0 * MENU_PAD_X).clamp(MENU_MIN_WIDTH, MENU_MAX_WIDTH) + } + + /// Hit-test a pixel against the open popup (Q#CM1). Returns + /// `(row_index, is_item)` when inside the popup rectangle, or `None` + /// when outside (or no menu open). + fn menu_hit(&self, x: f64, y: f64) -> Option<(u32, bool)> { + let menu = self.menu.as_ref()?; + let (ax, ay) = menu.anchor_px; + let w = f64::from(Self::menu_width_px(menu)); + let h = menu.rows.len() as f64 * f64::from(MENU_ROW_HEIGHT); + if x < ax || x >= ax + w || y < ay || y >= ay + h { + return None; + } + let row = + (((y - ay) / f64::from(MENU_ROW_HEIGHT)).floor() as usize).min(menu.rows.len() - 1); + Some((row as u32, !menu.rows[row].separator)) + } + /// Center the viewport on the source line the minimap pixel `y` /// maps to — the inverse of the painter's linear line→y /// interpolation. Reuses [`Self::scroll_by_lines`] for the @@ -2723,6 +2991,67 @@ impl State { rects_to_vertex_bytes(&[rect], self.config.width, self.config.height) } + /// Re-shape the menu label text from `self.menu` (Q#CM1), one line + /// per row (separators are blank lines so rows stay aligned with the + /// bg quads). A no-op string when the menu is closed. + fn refresh_menu_buffer(&mut self) { + let text = self.menu.as_ref().map_or_else(String::new, |menu| { + menu.rows + .iter() + .map(|r| if r.separator { "" } else { r.label.as_str() }) + .collect::>() + .join("\n") + }); + self.menu_buffer.set_text( + &mut self.font_system, + &text, + &Attrs::new().family(Family::Name("JetBrains Mono")), + Shaping::Advanced, + None, + ); + self.menu_buffer + .shape_until_scroll(&mut self.font_system, false); + } + + /// Popup background, active-row highlight, and separator quads + /// (Q#CM1). Empty when the menu is closed. + fn menu_vertex_bytes(&self) -> Vec { + let Some(menu) = self.menu.as_ref() else { + return Vec::new(); + }; + let ax = menu.anchor_px.0 as f32; + let ay = menu.anchor_px.1 as f32; + let w = Self::menu_width_px(menu); + let mut rects = vec![MinimapRect { + x: ax, + y: ay, + w, + h: menu.rows.len() as f32 * MENU_ROW_HEIGHT, + color: MENU_BG, + }]; + for (i, row) in menu.rows.iter().enumerate() { + let ry = ay + i as f32 * MENU_ROW_HEIGHT; + if row.separator { + rects.push(MinimapRect { + x: ax + MENU_PAD_X, + y: ry + MENU_ROW_HEIGHT / 2.0 - 0.5, + w: w - 2.0 * MENU_PAD_X, + h: 1.0, + color: MENU_SEPARATOR_BG, + }); + } else if menu.active == Some(i as u32) { + rects.push(MinimapRect { + x: ax, + y: ry, + w, + h: MENU_ROW_HEIGHT, + color: MENU_SELECTED_BG, + }); + } + } + rects_to_vertex_bytes(&rects, self.config.width, self.config.height) + } + /// Bookkeeping for an outgoing Pointer event: it supersedes any /// unconfirmed optimistic-cursor prediction (the daemon's answer /// will be the click position, not the typing prediction), and @@ -3049,6 +3378,20 @@ impl State { .create_view(&wgpu::TextureViewDescriptor::default()); let frame_start = debug_frame().then(std::time::Instant::now); self.refresh_status_line(); + self.refresh_menu_buffer(); + // Q#CM1 — the context-menu popup quads (bg / highlight / + // separators), drawn as a top layer after everything else. + let menu_vertices = self.menu_vertex_bytes(); + let menu_vertex_count = (menu_vertices.len() / QUAD_VERTEX_STRIDE as usize) as u32; + let menu_bg_buffer = self + .menu_bg_vertex_buffer + .upload( + &self.device, + &self.queue, + "pmacs-gpu context menu", + &menu_vertices, + ) + .cloned(); // The band's strip rides the bg quad batch so it draws under // the band text (text renders after the first quad draw). let mut bg_vertices = self.decoration_background_vertex_bytes(); @@ -3190,6 +3533,43 @@ impl State { ) .expect("text_renderer prepare"); + // Q#CM1 — prepare the menu glyphs in their own layer (empty when + // closed, so the renderer draws nothing). + let menu_areas: Vec