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();