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:
parent
487c12cca9
commit
8929bf0d25
|
|
@ -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 }
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
23
src/lua.rs
23
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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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, ®, &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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue