pmacs/docs/context-menu-framing.md

292 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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:
```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<u32>,
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<MenuPromptRow>,
active: Option<u32> }`**, `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 14 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<Mutex>`
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.