diff --git a/docs/context-menu-framing.md b/docs/context-menu-framing.md new file mode 100644 index 0000000..17b2c4e --- /dev/null +++ b/docs/context-menu-framing.md @@ -0,0 +1,291 @@ +# 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>>`** 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>`, 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: + +```lua +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: + +```lua +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 async + `lsp.*` 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 as + **`InstanceSignal::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 via + `arboard`, 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-gpu `Ctrl-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-n` next, `Up`/`C-p` prev, `RET` + invoke, `Esc`/`C-g` dismiss; any other key dismisses. Decoded by + `MenuKey::from_chord`, the SearchKey pattern — the same path both + frontends reach via the `FrontendEvent::Key` round-trip. `dispatch_key` + / the GPU's `dispatch_idle` gate both treat an open menu like an + active search. +- **TUI:** a self-suppressing **`MenuView` overlay** (the `SearchView` + pattern — 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 as `FrontendEvent::Mouse(Down(Right))` (was dropped); + `dispatch_mouse` gains a Right arm to open and a while-open branch + routing `Move`/`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 **`MenuPrompt`** + semantic message; the GPU renders the popup at the remembered + right-click pixel and owns hit-testing (it drew the rect), translating + hover/click into **`FrontendEvent::MenuPointer { index, invoke }`** — + never shipping pixels the core can't read. Right-click opens via + `PointerKind::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 like `Pointer`/`TripleDown` + (pmacs-gpu drops it against a `< 11` daemon). +- **`FrontendEvent::MenuPointer { frontend_id, index: Option, + invoke: bool }`** — GPU→daemon navigation. `index: None` = pointer off + the menu; `invoke` = click (invoke the row, or dismiss when `None`). +- **`InstanceMessage::MenuPrompt { buffer_id, rows: Vec, + active: Option }`**, `MenuPromptRow { label, separator }`. Empty + `rows` closes the menu. Emitted by the semantic producer with + cached-compare suppression (like `SearchPrompt`); 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) + +1. **Registry** — `MenuRegistry` + `pmacs.menu` + load wiring. + Introspection only. +2. **Clipboard + select-all** — commands + internal slot + reused + `InstanceSignal::Clipboard` + per-frontend OS write (arboard / OSC + 52) + GPU `Ctrl-V` inbound + wired the previously-dropped + `FrontendEvent::Paste`. Emacs kill/yank keys. Validated standalone. +3. **Core mode + TUI surface** — `MenuState`/`SharedMenu`, `MenuView`, + `dispatch_menu_key`/`_mouse`, right-click open, the Lua `build` + resolver. End-to-end in the terminal (user-validated). +4. **Default menu content** — the symbol/diagnostic context tags + (`word_at_cursor`, `active_attachment`, diagnostic containment) and + the LSP items. +5. **Protocol v11 + GPU surface** — `Context` / `MenuPointer` / + `MenuPrompt`, the daemon routing + gate, the producer, and the GPU + popup (a second `TextRenderer` over bg quads, pixel hit-testing → + `MenuPointer`). End-to-end in pmacs-gpu (user-validated). +6. **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 + +1. **No `SetClipboard` message.** The framing proposed a new + daemon→frontend clipboard message; the protocol already carried + `InstanceSignal::Clipboard` (outbound) and `FrontendEvent::Paste` + (inbound) since the v6 floor — both unused. Reusing them meant + clipboard cost *zero* new wire and no bump. +2. **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. The `symbol`/`diagnostic` tags need `pmacs.lsp` / + `pmacs.diag`, which live in Lua — so the policy belongs there. +3. **`MenuPrompt` carries rows, not "MenuItemWire".** The framing + sketched `MenuItemWire { label, enabled, separator_before }`; the + shipped `MenuPromptRow { label, separator }` is leaner — the GPU + needs only labels + separator flags + the active index, never the + command names (the core invokes by index). +4. **Menu state is a `SharedMenu`, no `origin`.** The framing's + `MenuSession { …, 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's `Arc` + shape, read by the TUI overlay. +5. **Anchoring simplified** to keep-iff-a-selection-exists (Q#CM4). +6. **Emacs clipboard keys** (`M-w`/`C-w`/`C-y`/`C-x h`), because the CUA + trio's keys were already bound (Q#CM6). +7. **GPU select-all has no keyboard binding** — `C-x` is a prefix the + GPU doesn't forward, so `C-x h` is 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` + `MenuPointer` was 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 `MinimapRect` quad path plus a second glyphon + `TextRenderer` (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-x` prefix forwarding). +- Submenus / nested groups; first-letter mnemonic jump within a menu. +- CUA clipboard chords (`Ctrl-C`/`Ctrl-X`) as an alternative binding set.