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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
This commit is contained in:
Levi Neuwirth 2026-06-27 22:03:58 -04:00
parent 487c12cca9
commit 8929bf0d25
4 changed files with 828 additions and 3 deletions

142
builtin/menus/default.lua Normal file
View File

@ -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 }

View File

@ -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<SearchSession>,
/// 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<u8>,
/// 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<u8>)>,
/// Open context menu (Q#CM1), or `None` when closed. Shared
/// `Arc<Mutex>` 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<String> {
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<Vec<u8>> {
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<bool, String> {
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<bool, String> {
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<u8>)> {
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<crate::menu::MenuRow>, 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<String> {
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<usize> {
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");

View File

@ -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, &registry, &commands, &keymaps, &hooks)?;
lua_bindings::install(&lua, &registry, &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(())
}

View File

@ -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<RefCell<CommandRegistry>>;
/// Shared, single-threaded handle to the keymap stack.
pub type SharedKeymapStack = Rc<RefCell<KeymapStack>>;
/// 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<RefCell<MenuRegistry>>;
/// 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<Table> {
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<bool> {
cc.borrow_mut()
.clipboard_cut()
.map_err(mlua::Error::external)
})?,
)?;
}
{
let cc = core.clone();
editor.set(
"clipboard_paste",
lua.create_function(move |_, ()| -> mlua::Result<bool> {
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<Command> {
})
}
/// 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<MenuItem> {
for pair in spec.clone().pairs::<Value, Value>() {
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<String> = spec.get("id")?;
let context: Option<String> = spec.get("context")?;
let predicate: Option<Function> = spec.get("predicate")?;
let group: String = spec.get::<Option<String>>("group")?.unwrap_or_default();
let order: i64 = spec.get::<Option<i64>>("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, &reg, &cmds, &kms, &hks).expect("install");
install(&lua, &reg, &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();