14 KiB
Right-click context menus — framing + as-built
pmacs's first interactive floating surface. Unlike search (a band + washes), a context menu is a thing you point at and click, which broke new ground in both frontends: pointer hit-testing against a popup, a highlighted active row, and dismissal rules. This doc records the design as shipped; where the build diverged from the framing stance, the "As-built" notes and the divergences section say what actually landed and why.
User-decided up front (AskUserQuestion):
- Context-sensitive — items adapt to what's under the pointer (a selection → Cut/Copy/Paste; a symbol → LSP go-to-def / rename; a diagnostic → quick fix).
- Both frontends, GPU-primary — a shared core menu mode rendered in the TUI and pmacs-gpu; the GPU surface was the bulk of the work.
- Lua registry — items defined in Lua like commands and keymaps, user-configurable, actions invoking named commands.
- OS clipboard — copy/cut publish to the system clipboard (the menu needed real Cut/Copy/Paste, which didn't exist). See Q#CM6.
Architecture
Q#CM1 — Menu state lives in the core, mirroring the search store
The open menu is a frontend-agnostic SharedMenu = Arc<Mutex<Option<MenuState>>> on EditorCore — the menu twin of
search_store. MenuState { rows, active, anchor, width }; a
MenuRow is Separator | Item { label, command }; active indexes
rows and always points at an Item (navigation skips separators).
The core drives it through menu_open / menu_close / menu_step /
menu_set_active_row / menu_active_command / menu_hit.
EditorState (which has both the core and the Lua host) is where the
menu is built and invoked: open_context_menu (TUI, cell anchor) /
open_menu_at_byte (GPU, byte anchor) resolve the rows and call
menu_open; dispatch_menu_key navigates; menu_invoke_active runs
the chosen command by name. This is the same dispatch path the daemon
runs for round-tripped GPU input, so the menu behaves identically in
both frontends — only the surface differs. The SearchSession bet held;
the new axis was mouse interaction.
Q#CM2 — Items defined in a Lua registry, mirroring commands/keymaps
A MenuRegistry (Rc<RefCell<…>>, like CommandRegistry /
KeymapStack), installed as pmacs.menu, loaded from
builtin/menus/default.lua after keymaps in lua.rs::attach_editor
(items reference commands, so they load last). The Lua surface:
pmacs.menu.item {
id = "edit.cut", -- optional; enables override / removal
label = "Cut",
command = "edit.cut", -- invoked by name via the command registry
context = "selection", -- sugar (Q#CM3); or predicate = fn(cx)
group = "edit", order = 10,
}
MenuItem { id, label, command, context, predicate, group, order }.
item registers (validating non-empty label/command and that context
is one of always|selection|symbol|diagnostic — the same R50
typo-paranoia as the command spec); a matching id replaces in place,
so re-running config and user overrides are idempotent. list /
remove / clear round out the surface; _raw (internal) exposes
items with their predicate functions for the Lua builder. The default
menu lives in Lua, so users re-order, hide, or add items without
recompiling — the pmacs way.
Q#CM3 — Visibility resolves in Lua; context is sugar over a predicate
The dormant Command.predicate field finally got a consumer. The whole
resolve happens in pmacs.menu.build() (Lua), called once from Rust
(EditorState::build_menu_rows) at open: it reads _raw(), evaluates
each item's visibility, groups and sorts, and returns the rows. Building
in Lua keeps the LSP/diagnostic queries where their APIs live and the
core frontend-agnostic.
Visibility = an explicit predicate(cx) (a pcall, so a throwing
predicate hides its item rather than aborting the menu), else the
context tag via pmacs.menu._context_eval(tag, cx), else visible. The
context table is built from core facts:
cx = { has_selection, -- ed.region() ~= nil
word, -- ed.word_at_cursor() (core helper), or nil
line, col, -- ed.cursor_line() / cursor_col() (0-based)
attachment } -- pmacs.lsp.active_attachment(), or nil
Tag semantics: always → true; selection → cx.has_selection;
symbol → cx.word ~= nil and cx.attachment ~= nil; diagnostic → a
published diagnostic in pmacs.diag.list(cx.attachment.uri) spans
(cx.line, cx.col). After filtering, items sort by (group first-appearance order, item order, insertion), and a separator falls
between distinct groups. Bet (held): synchronous local context picks
the right groups without an LSP round-trip at open.
ed.word_at_cursor() is a core helper (identifier run of ASCII
alphanumerics / _ around the cursor). pmacs.lsp.active_attachment()
is a new pure accessor added for this — the existing
attached_for_active triggers an attach as a side effect, which a
visibility check must not do.
Q#CM4 — Right-click anchoring
If a selection exists, right-click keeps it (so Copy/Cut act on it) and leaves the cursor; otherwise it moves the cursor to the click and clears any selection. The byte anchor means LSP items act where you clicked. (The framing's finer "keep iff click is inside the selection" was simplified to "keep iff a selection exists" — uniform across both frontends, and the TUI path never needs the click byte.)
Commands & the clipboard
Q#CM5 — The default menu, as shipped
builtin/menus/default.lua, grouped (separators between groups):
- edit — Cut, Copy (
selection), Paste, Select All (always). - symbol (
symbol: word + attached server) — Go to Definition, Find References, Rename, Hover. Each invokes the existing asynclsp.*command, which acts at the cursor the right-click anchored. - diagnostic (
diagnostic: a diagnostic spans the cursor) — Quick Fix →lsp.code-actions. Streaming the individual fix titles into the menu is deferred (Q#CM10); one "Quick Fix" entry that fires the async command was the Phase-1 stance. - history — Undo, Redo (
always).
Q#CM6 — Clipboard with OS interop
A right-click menu without working Cut/Copy/Paste isn't credible, and
none existed — pmacs had no clipboard at all, and had never honored a
paste (FrontendEvent::Paste was silently dropped). This arc added
edit.copy/cut/paste/select-all with copy/cut publishing to the real
system clipboard.
The wrinkle: commands run in the core (daemon), but the OS clipboard belongs to the frontend's environment (the winit display, or the terminal — possibly across SSH). So the model is an internal slot (core-owned, the synchronous paste source) plus outbound publish and inbound capture across the wire:
edit.copy/edit.cut: extract the region text → write the slot → queue an outbound publish the dispatcher drains asInstanceSignal::Clipboard(bytes)(a v6-floor variant, already in the protocol but never produced — no new message, no version bump) → the frontend writes the OS clipboard (pmacs-gpu viaarboard, TUI via OSC 52, write-reliable). Cut then deletes the region.edit.paste: insert the slot at the cursor, replacing any region.- Inbound OS → pmacs rides each frontend's native paste affordance,
landing as
FrontendEvent::Paste: the TUI's bracketed paste (already enabled), and a new pmacs-gpuCtrl-V→arboard.get()→Paste. The core inserts and refreshes the slot, so a later in-app paste repeats the external text.
Default keys are the Emacs kill/yank set — M-w copy, C-w cut,
C-y yank, C-x h select-all — because the CUA trio collides (C-a is
line-start, C-v is page-down). In pmacs-gpu these chords are forwarded
like the search-entry chords (otherwise withheld); Ctrl-V is handled
locally for OS paste. arboard (with wayland-data-control) is a new
pmacs-gpu dep.
Input & routing
Right-click opens; while open, the menu captures pointer and keyboard until an item fires or it's dismissed.
- Keyboard (both):
Down/C-nnext,Up/C-pprev,RETinvoke,Esc/C-gdismiss; any other key dismisses. Decoded byMenuKey::from_chord, the SearchKey pattern — the same path both frontends reach via theFrontendEvent::Keyround-trip.dispatch_key/ the GPU'sdispatch_idlegate both treat an open menu like an active search. - TUI: a self-suppressing
MenuViewoverlay (theSearchViewpattern — deduped by kind, renders nothing while closed) pushed on the active window at open, drawn to cells over the buffer text. Right-click arrives already asFrontendEvent::Mouse(Down(Right))(was dropped);dispatch_mousegains a Right arm to open and a while-open branch routingMove/Drag→highlight,Down(Left)→invoke (hit-testing the cell against the popup rect), click-outside→dismiss. - GPU: the core ships the resolved rows as a
MenuPromptsemantic message; the GPU renders the popup at the remembered right-click pixel and owns hit-testing (it drew the rect), translating hover/click intoFrontendEvent::MenuPointer { index, invoke }— never shipping pixels the core can't read. Right-click opens viaPointerKind::Context; Escape dismisses instead of quitting.
Wire (protocol v11)
Additive over v10; the ladder resumes (SUPPORTED = [6,7,8,9,10,11]):
PointerKind::Context— right-button-down as a semantic pointer (carries the hit byte). Frontend-gated likePointer/TripleDown(pmacs-gpu drops it against a< 11daemon).FrontendEvent::MenuPointer { frontend_id, index: Option<u32>, invoke: bool }— GPU→daemon navigation.index: None= pointer off the menu;invoke= click (invoke the row, or dismiss whenNone).InstanceMessage::MenuPrompt { buffer_id, rows: Vec<MenuPromptRow>, active: Option<u32> },MenuPromptRow { label, separator }. Emptyrowscloses the menu. Emitted by the semantic producer with cached-compare suppression (likeSearchPrompt); daemon-gated>= 11, so a v10 peer never opens a GPU menu rather than mis-decoding it.
Clipboard added no new wire (reused InstanceSignal::Clipboard +
FrontendEvent::Paste). The TUI needs no new wire at all (menu =
overlay cells + existing Mouse; clipboard = existing Signal/Paste);
the v11 bump is the GPU menu's alone.
Phasing (delivered; each commit binary-build-green)
- Registry —
MenuRegistry+pmacs.menu+ load wiring. Introspection only. - Clipboard + select-all — commands + internal slot + reused
InstanceSignal::Clipboard+ per-frontend OS write (arboard / OSC 52) + GPUCtrl-Vinbound + wired the previously-droppedFrontendEvent::Paste. Emacs kill/yank keys. Validated standalone. - Core mode + TUI surface —
MenuState/SharedMenu,MenuView,dispatch_menu_key/_mouse, right-click open, the Luabuildresolver. End-to-end in the terminal (user-validated). - Default menu content — the symbol/diagnostic context tags
(
word_at_cursor,active_attachment, diagnostic containment) and the LSP items. - Protocol v11 + GPU surface —
Context/MenuPointer/MenuPrompt, the daemon routing + gate, the producer, and the GPU popup (a secondTextRendererover bg quads, pixel hit-testing →MenuPointer). End-to-end in pmacs-gpu (user-validated). - Docs — this consolidation.
Phases 1–4 validated the whole feature in the TUI before the GPU surface (the expensive half) began. The v11 bump means the daemon and pmacs-gpu must both be rebuilt to negotiate the menu.
As-built divergences from the framing
- No
SetClipboardmessage. The framing proposed a new daemon→frontend clipboard message; the protocol already carriedInstanceSignal::Clipboard(outbound) andFrontendEvent::Paste(inbound) since the v6 floor — both unused. Reusing them meant clipboard cost zero new wire and no bump. - Resolve runs entirely in Lua, not Rust. The framing had the core
evaluate predicates against a Rust-built context table. As-built,
pmacs.menu.build()does the whole resolve (predicate eval, context tags, grouping, sorting, separators) in Lua; Rust calls it once and parses rows. Thesymbol/diagnostictags needpmacs.lsp/pmacs.diag, which live in Lua — so the policy belongs there. MenuPromptcarries rows, not "MenuItemWire". The framing sketchedMenuItemWire { label, enabled, separator_before }; the shippedMenuPromptRow { label, separator }is leaner — the GPU needs only labels + separator flags + the active index, never the command names (the core invokes by index).- Menu state is a
SharedMenu, noorigin. The framing'sMenuSession { …, origin }implied a cursor-restore on dismiss; right-click already anchors the cursor intentionally, so there's nothing to restore. The state is the search store'sArc<Mutex>shape, read by the TUI overlay. - Anchoring simplified to keep-iff-a-selection-exists (Q#CM4).
- Emacs clipboard keys (
M-w/C-w/C-y/C-x h), because the CUA trio's keys were already bound (Q#CM6). - GPU select-all has no keyboard binding —
C-xis a prefix the GPU doesn't forward, soC-x his TUI-only; in pmacs-gpu select-all is menu-only.
Categorical bets (all held)
- Core-mode generalized from search to a pointed-at popup.
SharedMenu+MenuPointerwas the right seam; the core stayed frontend-agnostic. - Predicates were enough for context — sync local facts, no LSP round-trip at open.
- The GPU popup was just quads + glyph rows — no new pipeline; the
status-band
MinimapRectquad path plus a second glyphonTextRenderer(so the popup layers over the buffer text) composed into it. The only real risk was draw order, handled by drawing the menu last. - The internal clipboard was a clean prerequisite, not scope creep.
Deferred (named, not silently dropped)
- Q#CM10 Async quick-fix titles streamed into the menu (vs the one
"Quick Fix" item firing
lsp.code-actions). - menu-Paste reading the OS clipboard directly in pmacs-gpu (vs the slot), and clipboard history / kill-ring.
- Paste routed into an open minibuffer prompt (today inbound paste always targets the buffer).
- A keyboard menu key (Shift-F10 / Menu) to open at the caret, and
GPU select-all-by-keyboard (needs
C-xprefix forwarding). - Submenus / nested groups; first-letter mnemonic jump within a menu.
- CUA clipboard chords (
Ctrl-C/Ctrl-X) as an alternative binding set.