Merge pull request #72 from levineuwirth/session-context-menu

Right-click context menus + OS clipboard (protocol v11)
This commit is contained in:
Levi Neuwirth 2026-06-30 16:55:38 -04:00 committed by GitHub
commit a6070e201d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 3018 additions and 31 deletions

139
Cargo.lock generated
View File

@ -151,6 +151,24 @@ dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arboard"
version = "3.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
dependencies = [
"clipboard-win",
"log",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
"windows-sys 0.59.0",
"wl-clipboard-rs",
"x11rb",
]
[[package]]
name = "arrayref"
version = "0.3.9"
@ -387,6 +405,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "clipboard-win"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
dependencies = [
"error-code",
]
[[package]]
name = "cobs"
version = "0.3.0"
@ -836,6 +863,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "error-code"
version = "3.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
[[package]]
name = "etagere"
version = "0.3.0"
@ -877,6 +910,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "fnv"
version = "1.0.7"
@ -1899,6 +1938,15 @@ dependencies = [
"memoffset",
]
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "nonmax"
version = "0.5.5"
@ -2051,6 +2099,18 @@ dependencies = [
"objc2-quartz-core 0.2.2",
]
[[package]]
name = "objc2-app-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.11.1",
"objc2 0.6.4",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
]
[[package]]
name = "objc2-cloud-kit"
version = "0.2.2"
@ -2098,6 +2158,19 @@ dependencies = [
"objc2 0.6.4",
]
[[package]]
name = "objc2-core-graphics"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags 2.11.1",
"dispatch2",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-io-surface",
]
[[package]]
name = "objc2-core-image"
version = "0.2.2"
@ -2152,6 +2225,17 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-io-surface"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags 2.11.1",
"objc2 0.6.4",
"objc2-core-foundation",
]
[[package]]
name = "objc2-link-presentation"
version = "0.2.2"
@ -2160,7 +2244,7 @@ checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
dependencies = [
"block2 0.5.1",
"objc2 0.5.2",
"objc2-app-kit",
"objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
]
@ -2300,6 +2384,16 @@ dependencies = [
"num-traits",
]
[[package]]
name = "os_pipe"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "owned_ttf_parser"
version = "0.25.1"
@ -2381,6 +2475,17 @@ dependencies = [
"sha2",
]
[[package]]
name = "petgraph"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset",
"hashbrown 0.15.5",
"indexmap",
]
[[package]]
name = "pin-project"
version = "1.1.13"
@ -2457,6 +2562,7 @@ dependencies = [
name = "pmacs-gpu"
version = "0.0.1"
dependencies = [
"arboard",
"env_logger",
"glyphon",
"loro",
@ -3665,6 +3771,17 @@ dependencies = [
"tree-sitter-language",
]
[[package]]
name = "tree_magic_mini"
version = "3.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
dependencies = [
"memchr",
"nom",
"petgraph",
]
[[package]]
name = "ttf-parser"
version = "0.25.1"
@ -4455,7 +4572,7 @@ dependencies = [
"memmap2",
"ndk",
"objc2 0.5.2",
"objc2-app-kit",
"objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
"objc2-ui-kit",
"orbclient",
@ -4610,6 +4727,24 @@ dependencies = [
"wasmparser",
]
[[package]]
name = "wl-clipboard-rs"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3"
dependencies = [
"libc",
"log",
"os_pipe",
"rustix 1.1.4",
"thiserror 2.0.18",
"tree_magic_mini",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-protocols-wlr",
]
[[package]]
name = "x11-dl"
version = "2.21.0"

View File

@ -160,6 +160,31 @@ cmd { name = "region.cancel",
description = "Drop any active selection without changing the cursor.",
fn = function() ed.clear_selection() end }
-- Clipboard (Q#CM6) ----------------------------------------------------------
-- Copy/cut publish the selection to the OS clipboard (OSC 52 in the TUI,
-- arboard in the GPU); paste inserts the in-app slot, which Ctrl-V /
-- bracketed paste also refreshes. The default bindings are the Emacs
-- kill/yank set (M-w / C-w / C-y, C-x h), which were all free.
cmd { name = "edit.copy",
description = "Copy the active region to the clipboard.",
fn = function()
if not ed.clipboard_copy() then ed.set_status("no region") end
end }
cmd { name = "edit.cut",
description = "Cut the active region to the clipboard.",
fn = function()
if not ed.clipboard_cut() then ed.set_status("no region") end
end }
cmd { name = "edit.paste",
description = "Paste the clipboard at the cursor, replacing any region.",
fn = function()
if not ed.clipboard_paste() then ed.set_status("clipboard empty") end
end }
cmd { name = "edit.select-all",
description = "Select the whole buffer.",
fn = function() ed.select_all() end }
-- File I/O -------------------------------------------------------------------
cmd { name = "buffer.save", description = "Save the current buffer to its backing file.",

View File

@ -103,6 +103,15 @@ bind("C-S-<right>", "cursor.select-word-right")
bind("C-S-<up>", "cursor.select-paragraph-up")
bind("C-S-<down>", "cursor.select-paragraph-down")
-- Clipboard (Q#CM6). The Emacs kill/yank set --- all of these were free
-- in the default map (C-a / C-v are taken for line-start / page-down, so
-- the CUA trio would have clobbered motion). C-w cuts, M-w copies, C-y
-- pastes; C-x h selects the whole buffer (Emacs mark-whole-buffer).
bind("M-w", "edit.copy")
bind("C-w", "edit.cut")
bind("C-y", "edit.paste")
bind("C-x h", "edit.select-all")
-- Undo / redo ----------------------------------------------------------------
--
-- Multiple undo bindings exist because terminals translate Ctrl+/

142
builtin/menus/default.lua Normal file
View File

@ -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 }

View File

@ -532,6 +532,18 @@ local function attached_for_active()
return attach_buffer(buf)
end
-- Pure, side-effect-free attachment lookup for the active buffer:
-- returns the live record (with `.uri`) when a server is already
-- attached, else nil. Unlike `attached_for_active`, it never *triggers*
-- an attach --- the context menu (Q#CM3) calls it to decide whether to
-- show symbol/diagnostic items, and must not perturb LSP state just by
-- opening.
function pmacs.lsp.active_attachment()
local buf = pmacs.window.buffer()
if not buf then return nil end
return attachments[tostring(buf)]
end
-- Hooks --------------------------------------------------------------------
pmacs.hook.add("buffer.after-load", function()

View File

@ -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<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.

View File

@ -37,6 +37,11 @@ similar_names = "allow"
multiple_crate_versions = "allow"
[dependencies]
# OS clipboard for cut/copy/paste (Q#CM6). `wayland-data-control` adds
# the zwlr_data_control backend so the clipboard works under Wayland
# without a window handle; the default X11 backend covers X sessions.
# `image-data` is dropped --- pmacs only round-trips text.
arboard = { version = "3", default-features = false, features = ["wayland-data-control"] }
env_logger = "0.11.10"
# Text shaping + GPU rendering. `glyphon` re-exports the `cosmic-text`
# types it pins (`Buffer`, `Attrs`, `Family`, `FontSystem`,

View File

@ -279,6 +279,33 @@ impl AttachClient {
})
}
/// Send a `FrontendEvent::Paste` (Q#CM6) carrying OS-clipboard
/// bytes read locally via `arboard` on Ctrl-V. The daemon inserts it
/// at the cursor (replacing any region) and refreshes its clipboard
/// slot, exactly as it handles the TUI's bracketed paste.
pub fn send_paste(&self, data: Vec<u8>) -> Result<(), TransportError> {
self.send_event(FrontendEvent::Paste {
frontend_id: self.frontend_id,
data,
})
}
/// Send a `FrontendEvent::MenuPointer` (Q#CM1) — open-menu
/// navigation hit-tested locally against the popup we drew. `index`
/// is the row the pointer is over (`None` = off the menu); `invoke`
/// marks a click (invoke the row, or dismiss when `index` is `None`).
pub fn send_menu_pointer(
&self,
index: Option<u32>,
invoke: bool,
) -> Result<(), TransportError> {
self.send_event(FrontendEvent::MenuPointer {
frontend_id: self.frontend_id,
index,
invoke,
})
}
/// The daemon's negotiated wire version from `Hello`.
pub fn server_protocol_version(&self) -> u32 {
self.server_protocol_version

View File

@ -35,8 +35,9 @@ use glyphon::{
use loro::{ContainerTrait, ExportMode};
use pmacs_protocol::{
AdornmentContent, AdornmentPlacement, BufferId, ByteRange, CrdtOp, Decoration, DecorationKind,
DecorationSegment, FrontendId, InlineAdornment, InstanceMessage, Key as ProtocolKey, Modifiers,
PointerKind, SelectionSnapshot, StyleSegment, StyleSpan,
DecorationSegment, FrontendId, InlineAdornment, InstanceMessage, InstanceSignal,
Key as ProtocolKey, MenuPromptRow, Modifiers, PointerKind, SelectionSnapshot, StyleSegment,
StyleSpan,
cell::{Color as CellColor, Style as CellStyle},
};
use wgpu::MultisampleState;
@ -109,6 +110,20 @@ const STATUS_BAND_BG: [f32; 4] = [0.105, 0.105, 0.145, 1.0];
const STATUS_TEXT_PAD: f32 = 10.0;
const STATUS_FONT_SIZE: f32 = 13.0;
const STATUS_LINE_HEIGHT: f32 = 18.0;
// Context menu popup (Q#CM1). One row per item/separator; width tracks
// the widest label (estimated from a fixed per-char advance, which the
// code font's monospacing makes good enough for hit-testing + the bg
// quad to agree).
const MENU_ROW_HEIGHT: f32 = 22.0;
const MENU_FONT_SIZE: f32 = 14.0;
const MENU_LINE_HEIGHT: f32 = 22.0;
const MENU_PAD_X: f32 = 12.0;
const MENU_CHAR_W: f32 = 8.4;
const MENU_MIN_WIDTH: f32 = 140.0;
const MENU_MAX_WIDTH: f32 = 380.0;
const MENU_BG: [f32; 4] = [0.16, 0.16, 0.20, 0.98];
const MENU_SELECTED_BG: [f32; 4] = [0.20, 0.40, 0.66, 1.0];
const MENU_SEPARATOR_BG: [f32; 4] = [0.30, 0.30, 0.36, 1.0];
const QUAD_SHADER: &str = r"
struct VertexOut {
@builtin(position) pos: vec4<f32>,
@ -418,6 +433,11 @@ struct State {
/// keys round-trip so minibuffer and prefix commands keep their
/// daemon-owned semantics.
dispatch_idle: bool,
/// OS clipboard handle (Q#CM6), created lazily on first cut / copy /
/// paste. `None` until first use or when the platform clipboard is
/// unavailable (headless / unsupported compositor) --- clipboard ops
/// then degrade to no-ops rather than crashing.
clipboard: Option<arboard::Clipboard>,
/// Whether `own_cursor` is still an authoritative position for
/// local optimistic insertion. Round-tripped keys can move the
/// daemon cursor in ways the GPU does not predict, so they mark
@ -535,6 +555,21 @@ struct State {
/// the buffer name; the matches highlight via `SearchMatch`
/// decorations.
search_prompt: Option<SearchPromptLocal>,
/// Q#CM1 — the live context menu (protocol v11), or `None` when
/// closed. The rows + highlight come from `MenuPrompt`; the popup
/// draws at the pixel of the right-click.
menu: Option<MenuLocal>,
/// Pixel of the most recent right-click, remembered so the
/// `MenuPrompt` that follows can anchor the popup there.
menu_anchor_px: (f64, f64),
/// Shaped label text for the open menu (Q#CM1), one line per row.
menu_buffer: Buffer,
/// Dedicated text renderer for the menu, so its glyphs draw in a
/// layer *over* the buffer text + caret (a popup), not interleaved
/// with them in the main text pass.
menu_text_renderer: TextRenderer,
/// Popup background / highlight / separator quads (Q#CM1).
menu_bg_vertex_buffer: ReusableVertexBuffer,
/// Minimap vertex bytes cached by [`MinimapCacheKey`] —
/// rebuilding rescanned every line shape per frame.
minimap_cache: Option<(MinimapCacheKey, Vec<u8>)>,
@ -563,6 +598,17 @@ struct SearchPromptLocal {
invalid: bool,
}
/// The live context menu (Q#CM1, protocol v11), mirrored from a
/// `MenuPrompt` with non-empty rows. The popup draws at `anchor_px`
/// (the right-click pixel, remembered locally — the daemon never sees
/// pixels).
#[derive(Clone, Debug, PartialEq)]
struct MenuLocal {
rows: Vec<MenuPromptRow>,
active: Option<u32>,
anchor_px: (f64, f64),
}
/// pmacs-gpu's own cursor position, mirrored from `CursorByte`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct OwnCursor {
@ -612,10 +658,31 @@ impl App {
} else {
kind
};
// Context (right-click, Q#CM1) is a v11 variant; a pre-v11
// instance can't open a menu, so drop the gesture rather than
// sending an undecodable variant.
if kind == PointerKind::Context && client.server_protocol_version() < 11 {
return;
}
if let Err(e) = client.send_pointer(buffer_id, byte, kind, mods) {
eprintln!("pmacs-gpu: send_pointer failed: {e}");
}
}
/// Ship a [`pmacs_protocol::FrontendEvent::MenuPointer`] if the
/// daemon speaks v11+ (Q#CM1). Navigates the open menu the daemon
/// owns; pixels stay local, only the resolved row index crosses.
fn send_menu_pointer(&self, index: Option<u32>, invoke: bool) {
let Some(client) = self.attach_client.as_ref() else {
return;
};
if client.server_protocol_version() < 11 {
return;
}
if let Err(e) = client.send_menu_pointer(index, invoke) {
eprintln!("pmacs-gpu: send_menu_pointer failed: {e}");
}
}
}
impl ApplicationHandler<AppEvent> for App {
@ -690,6 +757,25 @@ impl ApplicationHandler<AppEvent> for App {
let Some((pkey, pmods)) = translate_key(&key.logical_key, self.modifiers) else {
return;
};
// Ctrl-V — OS paste (Q#CM6). Read the system clipboard
// locally via arboard and ship it as a `Paste` event; the
// daemon inserts it. Handled before binding `client` so
// the `&mut self` clipboard read doesn't conflict with the
// client borrow. Skipped while intercepting (the daemon's
// active handler owns the key then). The daemon keymap's
// C-y yanks the in-app slot instead.
if !intercept && pkey == ProtocolKey::Char('v') && pmods == Modifiers::CTRL {
let bytes = self.state.as_mut().and_then(State::read_os_clipboard);
if let Some(bytes) = bytes
&& let Some(client) = self.attach_client.as_ref()
&& let Err(e) = client.send_paste(bytes)
{
eprintln!("pmacs-gpu: send_paste failed: {e}");
}
return;
}
let Some(client) = self.attach_client.as_ref() else {
return;
};
@ -727,6 +813,21 @@ impl ApplicationHandler<AppEvent> for App {
return;
}
// Clipboard command chords (Q#CM6): M-w copy, C-w cut,
// C-y yank. Like the search-entry chords, these drive
// daemon `edit.*` commands and are otherwise withheld, so
// forward them explicitly. (OS paste is Ctrl-V, handled
// locally above.)
if is_clipboard_chord(pkey, pmods) {
if let Some(state) = self.state.as_mut() {
state.mark_cursor_stale_after_round_trip();
}
if let Err(e) = client.send_key(pkey, pmods) {
eprintln!("pmacs-gpu: send_key (clipboard) failed: {e}");
}
return;
}
// Session B2 forwards cursor motion + plain text editing
// (Char / Backspace / Enter / Delete / Tab). Ctrl/Alt/
// Meta chords are withheld — they drive commands and
@ -799,6 +900,19 @@ impl ApplicationHandler<AppEvent> for App {
return;
};
state.pointer_pos = Some((position.x, position.y));
// Q#CM1 — while the menu is open, motion only moves the
// highlight; send a hover when the item under the pointer
// changes from the daemon's current active row.
if state.menu.is_some() {
let hit = state.menu_hit(position.x, position.y);
let active = state.menu.as_ref().and_then(|m| m.active);
if let Some((row, true)) = hit
&& active != Some(row)
{
self.send_menu_pointer(Some(row), false);
}
return;
}
if state.minimap_scrub_active {
// Scrubbing (Q#M6): the press began on the
// minimap; motion keeps jumping, even if the
@ -847,6 +961,22 @@ impl ApplicationHandler<AppEvent> for App {
let Some((x, y)) = state.pointer_pos else {
return;
};
// Q#CM1 — while the menu is open the left button drives
// it: a press invokes the row under the pointer (or
// dismisses on a click outside); a release is swallowed.
if state.menu.is_some() {
if button_state == ElementState::Pressed {
let action = match state.menu_hit(x, y) {
Some((row, true)) => Some((Some(row), true)),
Some((_, false)) => None, // separator — ignore
None => Some((None, true)), // outside — dismiss
};
if let Some((index, invoke)) = action {
self.send_menu_pointer(index, invoke);
}
}
return;
}
let mods = translate_mods(self.modifiers);
match button_state {
ElementState::Pressed => {
@ -900,6 +1030,34 @@ impl ApplicationHandler<AppEvent> for App {
}
}
}
// Q#CM1 — right-click opens the context menu at the hit byte
// (or dismisses an open one). The anchor pixel is remembered
// so the popup the daemon sends back draws at the click.
WindowEvent::MouseInput {
state: ElementState::Pressed,
button: winit::event::MouseButton::Right,
..
} => {
let Some(state) = self.state.as_mut() else {
return;
};
let Some((x, y)) = state.pointer_pos else {
return;
};
if state.menu.is_some() {
self.send_menu_pointer(None, true);
return;
}
let Some(byte) = state.hit_test_source_byte(x, y) else {
return;
};
state.menu_anchor_px = (x, y);
let buffer_id = state.current_buffer_id;
let mods = translate_mods(self.modifiers);
if let Some(buffer_id) = buffer_id {
self.send_pointer(buffer_id, byte, PointerKind::Context, mods);
}
}
WindowEvent::MouseWheel { delta, .. } => {
let Some(state) = self.state.as_mut() else {
return;
@ -1394,6 +1552,9 @@ impl State {
let mut atlas = TextAtlas::new(&device, &queue, &cache, surface_format);
let text_renderer =
TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None);
// Q#CM1 — a second renderer so the menu draws as a top layer.
let menu_text_renderer =
TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None);
let quad_renderer = QuadRenderer::new(&device, surface_format);
let squiggle_renderer = SquiggleRenderer::new(&device, surface_format);
@ -1425,6 +1586,15 @@ impl State {
Some(config.width as f32),
Some(STATUS_BAND_HEIGHT),
);
let mut menu_buffer = Buffer::new(
&mut font_system,
Metrics::new(MENU_FONT_SIZE, MENU_LINE_HEIGHT),
);
menu_buffer.set_size(
&mut font_system,
Some(MENU_MAX_WIDTH),
Some(config.height as f32),
);
buffer.set_text(
&mut font_system,
initial_text,
@ -1468,6 +1638,7 @@ impl State {
last_viewport_sent: None,
local_frontend_id: None,
dispatch_idle: false,
clipboard: None,
cursor_fresh: false,
optimistic_cursor_floor: None,
deferred_round_trip_keys: Vec::new(),
@ -1496,6 +1667,11 @@ impl State {
status_left_text: String::new(),
status_facts: None,
search_prompt: None,
menu: None,
menu_anchor_px: (0.0, 0.0),
menu_buffer,
menu_text_renderer,
menu_bg_vertex_buffer: ReusableVertexBuffer::new(),
minimap_cache: None,
}
}
@ -1516,7 +1692,9 @@ impl State {
/// every key to the daemon's handler instead of optimistically
/// applying it to the buffer.
fn daemon_intercepts_keys(&self) -> bool {
self.search_prompt.is_some() || !self.dispatch_idle
// Q#CM1 — an open menu shadows the keymap like search: every key
// round-trips so the daemon's `dispatch_menu_key` drives it.
self.search_prompt.is_some() || self.menu.is_some() || !self.dispatch_idle
}
/// Shared eligibility gates for the optimistic edit paths
@ -1841,6 +2019,45 @@ impl State {
/// ignored — pmacs-gpu lays out locally and tracks the cursor via
/// `PresenceUpdate` (session 9.3). Remaining semantic variants land
/// in subsequent Phase A sessions.
/// Lazily-created OS clipboard handle (Q#CM6). Returns `None` if the
/// platform clipboard can't be opened, so callers degrade to no-ops.
fn os_clipboard(&mut self) -> Option<&mut arboard::Clipboard> {
if self.clipboard.is_none() {
match arboard::Clipboard::new() {
Ok(c) => self.clipboard = Some(c),
Err(e) => {
eprintln!("pmacs-gpu: OS clipboard unavailable: {e}");
return None;
}
}
}
self.clipboard.as_mut()
}
/// Read the OS clipboard as bytes (for Ctrl-V → `Paste`). `None` on
/// any failure (empty / non-text / unavailable).
fn read_os_clipboard(&mut self) -> Option<Vec<u8>> {
match self.os_clipboard()?.get_text() {
Ok(s) => Some(s.into_bytes()),
Err(e) => {
eprintln!("pmacs-gpu: clipboard read failed: {e}");
None
}
}
}
/// Write bytes to the OS clipboard (for an inbound
/// `Signal::Clipboard` after a daemon copy/cut). Lossy UTF-8; the
/// daemon only ever sends valid document text.
fn write_os_clipboard(&mut self, bytes: &[u8]) {
let text = String::from_utf8_lossy(bytes).into_owned();
if let Some(c) = self.os_clipboard()
&& let Err(e) = c.set_text(text)
{
eprintln!("pmacs-gpu: clipboard write failed: {e}");
}
}
#[allow(clippy::too_many_lines)] // per-variant match dispatcher; one arm per InstanceMessage.
fn apply_attach_message(&mut self, msg: InstanceMessage) -> Option<ViewportSend> {
match msg {
@ -2235,6 +2452,28 @@ impl State {
self.dispatch_idle = idle;
None
}
// Q#CM6 — a daemon copy/cut published the region; write it to
// the OS clipboard via arboard so other apps can paste it.
InstanceMessage::Signal(InstanceSignal::Clipboard(bytes)) => {
self.write_os_clipboard(&bytes);
None
}
// Q#CM1 — the context menu's rows + highlight. Empty rows
// close it; otherwise anchor the popup at the remembered
// right-click pixel.
InstanceMessage::MenuPrompt { rows, active, .. } => {
self.menu = if rows.is_empty() {
None
} else {
Some(MenuLocal {
rows,
active,
anchor_px: self.menu_anchor_px,
})
};
self.window.request_redraw();
None
}
_ => None,
}
}
@ -2367,6 +2606,35 @@ impl State {
minimap_band_contains(x as f32, y as f32, self.config.width, self.config.height)
}
/// Popup width in pixels (Q#CM1) — widest label estimated from a
/// fixed per-char advance, padded, clamped. Used by both hit-testing
/// and the bg quad so they line up.
fn menu_width_px(menu: &MenuLocal) -> f32 {
let max_chars = menu
.rows
.iter()
.map(|r| r.label.chars().count())
.max()
.unwrap_or(0);
(max_chars as f32 * MENU_CHAR_W + 2.0 * MENU_PAD_X).clamp(MENU_MIN_WIDTH, MENU_MAX_WIDTH)
}
/// Hit-test a pixel against the open popup (Q#CM1). Returns
/// `(row_index, is_item)` when inside the popup rectangle, or `None`
/// when outside (or no menu open).
fn menu_hit(&self, x: f64, y: f64) -> Option<(u32, bool)> {
let menu = self.menu.as_ref()?;
let (ax, ay) = menu.anchor_px;
let w = f64::from(Self::menu_width_px(menu));
let h = menu.rows.len() as f64 * f64::from(MENU_ROW_HEIGHT);
if x < ax || x >= ax + w || y < ay || y >= ay + h {
return None;
}
let row =
(((y - ay) / f64::from(MENU_ROW_HEIGHT)).floor() as usize).min(menu.rows.len() - 1);
Some((row as u32, !menu.rows[row].separator))
}
/// Center the viewport on the source line the minimap pixel `y`
/// maps to — the inverse of the painter's linear line→y
/// interpolation. Reuses [`Self::scroll_by_lines`] for the
@ -2723,6 +2991,67 @@ impl State {
rects_to_vertex_bytes(&[rect], self.config.width, self.config.height)
}
/// Re-shape the menu label text from `self.menu` (Q#CM1), one line
/// per row (separators are blank lines so rows stay aligned with the
/// bg quads). A no-op string when the menu is closed.
fn refresh_menu_buffer(&mut self) {
let text = self.menu.as_ref().map_or_else(String::new, |menu| {
menu.rows
.iter()
.map(|r| if r.separator { "" } else { r.label.as_str() })
.collect::<Vec<_>>()
.join("\n")
});
self.menu_buffer.set_text(
&mut self.font_system,
&text,
&Attrs::new().family(Family::Name("JetBrains Mono")),
Shaping::Advanced,
None,
);
self.menu_buffer
.shape_until_scroll(&mut self.font_system, false);
}
/// Popup background, active-row highlight, and separator quads
/// (Q#CM1). Empty when the menu is closed.
fn menu_vertex_bytes(&self) -> Vec<u8> {
let Some(menu) = self.menu.as_ref() else {
return Vec::new();
};
let ax = menu.anchor_px.0 as f32;
let ay = menu.anchor_px.1 as f32;
let w = Self::menu_width_px(menu);
let mut rects = vec![MinimapRect {
x: ax,
y: ay,
w,
h: menu.rows.len() as f32 * MENU_ROW_HEIGHT,
color: MENU_BG,
}];
for (i, row) in menu.rows.iter().enumerate() {
let ry = ay + i as f32 * MENU_ROW_HEIGHT;
if row.separator {
rects.push(MinimapRect {
x: ax + MENU_PAD_X,
y: ry + MENU_ROW_HEIGHT / 2.0 - 0.5,
w: w - 2.0 * MENU_PAD_X,
h: 1.0,
color: MENU_SEPARATOR_BG,
});
} else if menu.active == Some(i as u32) {
rects.push(MinimapRect {
x: ax,
y: ry,
w,
h: MENU_ROW_HEIGHT,
color: MENU_SELECTED_BG,
});
}
}
rects_to_vertex_bytes(&rects, self.config.width, self.config.height)
}
/// Bookkeeping for an outgoing Pointer event: it supersedes any
/// unconfirmed optimistic-cursor prediction (the daemon's answer
/// will be the click position, not the typing prediction), and
@ -3049,6 +3378,20 @@ impl State {
.create_view(&wgpu::TextureViewDescriptor::default());
let frame_start = debug_frame().then(std::time::Instant::now);
self.refresh_status_line();
self.refresh_menu_buffer();
// Q#CM1 — the context-menu popup quads (bg / highlight /
// separators), drawn as a top layer after everything else.
let menu_vertices = self.menu_vertex_bytes();
let menu_vertex_count = (menu_vertices.len() / QUAD_VERTEX_STRIDE as usize) as u32;
let menu_bg_buffer = self
.menu_bg_vertex_buffer
.upload(
&self.device,
&self.queue,
"pmacs-gpu context menu",
&menu_vertices,
)
.cloned();
// The band's strip rides the bg quad batch so it draws under
// the band text (text renders after the first quad draw).
let mut bg_vertices = self.decoration_background_vertex_bytes();
@ -3190,6 +3533,43 @@ impl State {
)
.expect("text_renderer prepare");
// Q#CM1 — prepare the menu glyphs in their own layer (empty when
// closed, so the renderer draws nothing).
let menu_areas: Vec<TextArea> = self
.menu
.as_ref()
.map(|menu| {
let ax = menu.anchor_px.0 as f32;
let ay = menu.anchor_px.1 as f32;
TextArea {
buffer: &self.menu_buffer,
left: ax + MENU_PAD_X,
top: ay + 2.0,
scale: 1.0,
bounds: TextBounds {
left: ax as i32,
top: ay as i32,
right: (ax + Self::menu_width_px(menu)).round() as i32,
bottom: (ay + menu.rows.len() as f32 * MENU_ROW_HEIGHT).round() as i32,
},
default_color: Color::rgb(232, 232, 238),
custom_glyphs: &[],
}
})
.into_iter()
.collect();
self.menu_text_renderer
.prepare(
&self.device,
&self.queue,
&mut self.font_system,
&mut self.atlas,
&self.viewport,
menu_areas,
&mut self.swash_cache,
)
.expect("menu text_renderer prepare");
let mut encoder = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
@ -3239,6 +3619,15 @@ impl State {
self.quad_renderer
.render(&mut pass, vertex_buffer, minimap_vertex_count);
}
// Q#CM1 — the context menu draws last: its bg/highlight quads
// occlude everything beneath, then its glyphs on top.
if let Some(vertex_buffer) = menu_bg_buffer.as_ref() {
self.quad_renderer
.render(&mut pass, vertex_buffer, menu_vertex_count);
}
self.menu_text_renderer
.render(&self.atlas, &self.viewport, &mut pass)
.expect("menu text_renderer render");
}
self.queue.submit(std::iter::once(encoder.finish()));
frame.present();
@ -4023,6 +4412,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str {
InstanceMessage::FileStyleSummary { .. } => "FileStyleSummary",
InstanceMessage::StatusFacts { .. } => "StatusFacts",
InstanceMessage::SearchPrompt { .. } => "SearchPrompt",
InstanceMessage::MenuPrompt { .. } => "MenuPrompt",
InstanceMessage::BlockAdornments { .. } => "BlockAdornments",
InstanceMessage::FoldState { .. } => "FoldState",
InstanceMessage::ResourceOffer { .. } => "ResourceOffer",
@ -4149,6 +4539,20 @@ fn is_search_entry_chord(key: ProtocolKey, mods: Modifiers) -> bool {
&& (mods == Modifiers::CTRL || mods == Modifiers::CTRL | Modifiers::ALT)
}
/// The clipboard command chords (Q#CM6): `M-w` (copy), `C-w` (cut),
/// `C-y` (yank the in-app slot). Forwarded to the daemon though they are
/// otherwise withheld as command chords, mirroring
/// [`is_search_entry_chord`]. OS paste (`C-V`) is handled locally via
/// `arboard`, not here.
fn is_clipboard_chord(key: ProtocolKey, mods: Modifiers) -> bool {
matches!(
(key, mods),
(ProtocolKey::Char('w'), Modifiers::ALT)
| (ProtocolKey::Char('w'), Modifiers::CTRL)
| (ProtocolKey::Char('y'), Modifiers::CTRL)
)
}
fn is_plain_text_modifiers(mods: Modifiers) -> bool {
!mods.contains(Modifiers::CTRL)
&& !mods.contains(Modifiers::ALT)

View File

@ -49,7 +49,7 @@ pub use message::{
AdornmentContent, AdornmentPlacement, AttachRequest, BlockAdornment, CursorState, Decoration,
DecorationKind, DecorationSegment, FrontendCapabilities, FrontendEvent, GoodbyeReason, Hello,
InlineAdornment, InstanceCapabilities, InstanceIdentity, InstanceMessage, InstanceSignal, Key,
KeyEvent, Modifiers, MouseButton, MouseEvent, MouseKind, NegotiatedCapabilities,
KeyEvent, MenuPromptRow, Modifiers, MouseButton, MouseEvent, MouseKind, NegotiatedCapabilities,
PROTOCOL_VERSION, PointerKind, ResourceBody, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot,
StyleSegment, StyleSpan, is_supported_protocol_version, negotiate_capabilities,
};

View File

@ -335,6 +335,22 @@ pub enum FrontendEvent {
/// Shift-click extension; the v5 instance ignores them.
mods: Modifiers,
},
/// Navigate an open context menu (Q#CM1, protocol v11). A semantic
/// frontend hit-tests the popup it drew locally and reports the row
/// the pointer is over, so the daemon never needs the frontend's
/// pixels — symmetric with how the GPU owns its viewport. Sent only
/// while the daemon's menu is open.
MenuPointer {
/// Which frontend produced the gesture.
frontend_id: FrontendId,
/// Row index the pointer is over, or `None` when it left the
/// popup (a hover off the menu, or a click outside).
index: Option<u32>,
/// `true` on a click/release: invoke `index`'s item, or dismiss
/// the menu when `index` is `None`. `false` is a hover that only
/// moves the highlight.
invoke: bool,
},
}
/// Gesture step for [`FrontendEvent::Pointer`]. Double-click
@ -358,6 +374,10 @@ pub enum PointerKind {
/// sends this only to a `>= 7` instance; against an older one
/// the third click restarts the chain as a plain `Down`.
TripleDown,
/// Secondary (right) button pressed at `byte` — opens the context
/// menu there (Q#CM1, protocol v11). The frontend sends this only to
/// a `>= 11` instance.
Context,
}
impl FrontendEvent {
@ -374,7 +394,8 @@ impl FrontendEvent {
| Self::Detach(frontend_id)
| Self::CrdtOp { frontend_id, .. }
| Self::Viewport { frontend_id, .. }
| Self::Pointer { frontend_id, .. } => *frontend_id,
| Self::Pointer { frontend_id, .. }
| Self::MenuPointer { frontend_id, .. } => *frontend_id,
}
}
}
@ -847,6 +868,30 @@ pub enum InstanceMessage {
/// in literal mode.
invalid: bool,
},
/// Open-menu contents for a semantic frontend (Q#CM1, protocol v11).
/// The daemon resolves the visible items (predicates / context tags)
/// and ships the rendered rows + highlight; the frontend draws the
/// popup at the pixel it remembered from the right-click and reports
/// navigation via [`FrontendEvent::MenuPointer`]. An empty `rows`
/// closes the menu. Cached-compare suppressed like `SearchPrompt`.
MenuPrompt {
/// Buffer the menu is anchored in (the active buffer).
buffer_id: crate::BufferId,
/// Rows top-to-bottom (items + separators); empty = closed.
rows: Vec<MenuPromptRow>,
/// Index into `rows` of the highlighted item, or `None` when
/// closed.
active: Option<u32>,
},
}
/// One row of an open menu on the wire ([`InstanceMessage::MenuPrompt`]).
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct MenuPromptRow {
/// Display label (empty and ignored when `separator`).
pub label: String,
/// `true` for a non-selectable group divider.
pub separator: bool,
}
/// Flat selection state for the wire.
@ -1113,7 +1158,7 @@ pub enum ResourceBody {
/// encoding. Still daemon-gated per session (now at `< 10`); a v9 peer
/// negotiates v9 and simply receives no `SearchPrompt` (the decorations
/// still highlight), rather than mis-decoding the wider shape.
pub const PROTOCOL_VERSION: u32 = 10;
pub const PROTOCOL_VERSION: u32 = 11;
/// T M10.5: the set of protocol versions a v1.0 binary accepts on
/// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept
@ -1161,7 +1206,12 @@ pub const PROTOCOL_VERSION: u32 = 10;
/// `regex` / `invalid` (encoding change to that variant); v9 and v10
/// interoperate because the variant is daemon-gated per session, so a
/// v9 peer is simply never sent the wider shape.
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8, 9, 10];
///
/// Q#CM1: extended to `[6, 7, 8, 9, 10, 11]`. The context menu adds
/// `PointerKind::Context` (frontend-gated like `Pointer`/`TripleDown`),
/// `FrontendEvent::MenuPointer`, and `InstanceMessage::MenuPrompt`
/// (daemon-gated per session) — all additive, so the ladder resumes.
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8, 9, 10, 11];
/// T M10.5: predicate for the handshake check. Returns `true` if
/// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`].

View File

@ -65,7 +65,8 @@ use crate::presence::{PresenceSnapshot, SessionRegistry};
use crate::protocol::crossterm_translate::{key_to_crossterm, mouse_to_crossterm};
use crate::protocol::{
AttachRequest, FrontendEvent, FrontendId, GoodbyeReason, Hello, InstanceCapabilities,
InstanceIdentity, InstanceMessage, PROTOCOL_VERSION, SelectionSnapshot,
InstanceIdentity, InstanceMessage, InstanceSignal, PROTOCOL_VERSION, PointerKind,
SelectionSnapshot,
};
use crate::socket_path::{SocketPathError, ensure_runtime_subdir};
use crate::transport::{read_message, write_message};
@ -920,6 +921,21 @@ fn dispatcher_loop(
let _ = std::mem::take(&mut editor.core.borrow_mut().pending_crdt_ops);
}
// Q#CM6 — outbound clipboard publish. A copy/cut queued the
// region bytes for the originating frontend; deliver them as an
// `InstanceSignal::Clipboard` (a v6-floor variant every peer
// understands, so no version gate) and let the frontend write
// the OS clipboard (OSC 52 / arboard). One-shot, like the CRDT
// drain above.
if let Some((fid, bytes)) = editor.core.borrow_mut().take_pending_clipboard()
&& let Some(stream) = streams.get_mut(&fid)
{
let _ = write_message(
stream,
&InstanceMessage::Signal(InstanceSignal::Clipboard(bytes)),
);
}
for fid in &attached_fids {
editor.core.borrow_mut().active_frontend = *fid;
@ -1028,6 +1044,9 @@ fn dispatcher_loop(
let peer_knows_search_prompt = session_registry
.session_state(*fid)
.is_some_and(|s| s.negotiated_protocol_version >= 10);
let peer_knows_menu_prompt = session_registry
.session_state(*fid)
.is_some_and(|s| s.negotiated_protocol_version >= 11);
for msg in &messages {
if !peer_knows_status_facts
&& matches!(msg, InstanceMessage::StatusFacts { .. })
@ -1039,6 +1058,13 @@ fn dispatcher_loop(
{
continue;
}
// Q#CM1 — MenuPrompt gated at v11; a v10 peer keeps
// its decoration-only highlights and never opens a
// GPU menu, rather than mis-decoding the new variant.
if !peer_knows_menu_prompt && matches!(msg, InstanceMessage::MenuPrompt { .. })
{
continue;
}
// T M10.10 Day 4 / M10.11 F2 — the criterion-1
// jitter site: render-write latency.
//
@ -1398,7 +1424,21 @@ fn handle_dispatcher_event(
// displaying: a click can race a buffer switch.
if semantic_states.contains_key(&source) {
align_semantic_window_to_buffer(editor, source, buffer_id);
editor.dispatch_pointer(source, buffer_id, byte, kind, mods);
if kind == PointerKind::Context {
// Q#CM1 — right-click opens the context menu
// at the hit byte (needs the Lua builder, so
// it routes here rather than dispatch_pointer).
editor.open_menu_at_byte(source, buffer_id, byte);
} else {
editor.dispatch_pointer(source, buffer_id, byte, kind, mods);
}
}
}
FrontendEvent::MenuPointer { index, invoke, .. } => {
// Q#CM1 — semantic frontend menu navigation (hover /
// click), hit-tested against the popup it drew locally.
if semantic_states.contains_key(&source) {
editor.dispatch_menu_pointer(source, index, invoke);
}
}
_ => {
@ -2056,8 +2096,20 @@ fn apply_event(
render_state.resize(size);
*term_size = size;
}
FrontendEvent::Paste { .. }
| FrontendEvent::FocusGained(_)
FrontendEvent::Paste { frontend_id, data } => {
// Q#CM6 — inbound OS paste (terminal bracketed paste, or
// GPU Ctrl-V reading `arboard`). Insert at the cursor of the
// originating frontend's active window, replacing any region,
// and refresh the clipboard slot so a later in-app paste
// repeats the same text. (Previously dropped: pmacs had
// never honored a paste.)
let mut core = editor.core.borrow_mut();
core.active_frontend = frontend_id;
if let Err(e) = core.paste_inbound(&data) {
eprintln!("pmacs: inbound paste failed: {e}");
}
}
FrontendEvent::FocusGained(_)
| FrontendEvent::FocusLost(_)
// T M11.1: the semantic-frontend viewport declaration. Its
// consumer is the instance-side projection seam
@ -2105,6 +2157,14 @@ fn apply_event(
(semantic sessions route via apply_semantic_input_event)"
);
}
FrontendEvent::MenuPointer { .. } => {
// Q#CM1 — like Pointer, only semantic sessions emit
// MenuPointer, routed by the authenticated source in
// `handle_dispatcher_event`. Drop a grid session's.
eprintln!(
"pmacs daemon: FrontendEvent::MenuPointer from a grid session; dropping"
);
}
}
}

View File

@ -470,7 +470,10 @@ impl EditorState {
return false;
}
let core = self.core.borrow();
!core.minibuffer.is_active() && !core.search_active()
// A live context menu shadows the keymap too (Q#CM1): keys must
// round-trip so the daemon's `dispatch_menu_key` drives the menu
// rather than the frontend self-inserting.
!core.minibuffer.is_active() && !core.search_active() && !core.menu_is_open()
}
/// `frontend_id` records which frontend produced the event. v0.1
@ -490,6 +493,15 @@ impl EditorState {
core.active_frontend = frontend_id;
}
// Context-menu interception (Q#CM1): while a menu is open every
// key drives it (navigate / invoke / dismiss), shadowing the
// global keymap like search and the minibuffer. Same shared path
// both frontends reach via the `FrontendEvent::Key` round-trip.
if self.core.borrow().menu_is_open() {
self.dispatch_menu_key(chord);
return;
}
// Incremental-search interception: while an isearch is running,
// every key routes through the search handler (the global keymap
// is shadowed, like the minibuffer). Printable chars extend the
@ -661,6 +673,111 @@ impl EditorState {
}
}
/// Drive an open context menu from a keystroke (Q#CM1).
fn dispatch_menu_key(&mut self, chord: Chord) {
match MenuKey::from_chord(chord) {
MenuKey::Next => self.core.borrow_mut().menu_step(1),
MenuKey::Prev => self.core.borrow_mut().menu_step(-1),
MenuKey::Invoke => self.menu_invoke_active(),
MenuKey::Cancel | MenuKey::Dismiss => self.core.borrow_mut().menu_close(),
}
}
/// Close the menu, then invoke its highlighted item's command. The
/// menu closes *first* so the command runs against a clean state
/// (and a command that itself opens a menu isn't immediately torn
/// down).
fn menu_invoke_active(&mut self) {
let command = self.core.borrow().menu_active_command();
self.core.borrow_mut().menu_close();
if let Some(command) = command
&& let Err(e) = self
.lua_host
.invoke_command(&command, mlua::MultiValue::new())
{
self.core.borrow_mut().status =
format!("error in {command}: {}", first_line(&e.to_string()));
}
}
/// Build the resolved, grouped, visibility-filtered menu rows by
/// calling the Lua builder (`pmacs.menu.build`), which evaluates each
/// item's predicate / context tag against the live editor state.
/// Returns an empty list on any Lua error (the menu then won't open).
fn build_menu_rows(&mut self) -> Vec<crate::menu::MenuRow> {
let value = match self
.lua_host
.eval(Some("@pmacs/menu/build"), "return pmacs.menu.build()")
{
Ok(v) => v,
Err(e) => {
self.core.borrow_mut().status =
format!("menu build failed: {}", first_line(&e.to_string()));
return Vec::new();
}
};
let mlua::Value::Table(table) = value else {
return Vec::new();
};
let mut rows = Vec::new();
for entry in table.sequence_values::<mlua::Table>() {
let Ok(t) = entry else { continue };
if t.get::<Option<bool>>("separator").ok().flatten() == Some(true) {
rows.push(crate::menu::MenuRow::Separator);
} else if let (Ok(label), Ok(command)) =
(t.get::<String>("label"), t.get::<String>("command"))
{
rows.push(crate::menu::MenuRow::Item { label, command });
}
}
rows
}
/// Open the context menu at the click cell (Q#CM1). Anchors the
/// cursor: an existing selection is kept (so Copy/Cut act on it);
/// otherwise the cursor moves to the click and any selection clears.
fn open_context_menu(
&mut self,
win_id: WindowId,
local_row: u32,
local_col: u32,
anchor: (u32, u32),
) {
if !self.core.borrow().menu_is_open() {
let has_selection = self.core.borrow().active_region().is_some();
if !has_selection {
self.activate_and_position(win_id, local_row, local_col);
}
}
let rows = self.build_menu_rows();
self.core.borrow_mut().menu_open(rows, anchor);
}
/// Drive an open menu from a mouse event (Q#CM1): hover highlights,
/// left-click invokes, a click outside (or right-click) dismisses.
fn dispatch_menu_mouse(&mut self, ev: MouseEvent, cell_row: u32, cell_col: u32) {
use crossterm::event::{MouseButton, MouseEventKind};
let hit = self.core.borrow().menu_hit(cell_row, cell_col);
match ev.kind {
MouseEventKind::Moved | MouseEventKind::Drag(MouseButton::Left) => {
if let Some(row) = hit {
self.core.borrow_mut().menu_set_active_row(row);
}
}
MouseEventKind::Down(MouseButton::Left) => match hit {
Some(row) => {
self.core.borrow_mut().menu_set_active_row(row);
self.menu_invoke_active();
}
None => self.core.borrow_mut().menu_close(),
},
MouseEventKind::Down(MouseButton::Right | MouseButton::Middle) => {
self.core.borrow_mut().menu_close();
}
_ => {}
}
}
fn with_minibuffer<F: FnOnce(&mut Minibuffer)>(&mut self, f: F) {
f(&mut self.core.borrow_mut().minibuffer);
}
@ -749,6 +866,15 @@ impl EditorState {
let cell_row = u32::from(ev.row);
let cell_col = u32::from(ev.column);
// Context-menu interception (Q#CM1): while a menu is open the
// mouse drives it (hover highlights, left-click invokes, a click
// outside dismisses) — handled before window hit-testing so an
// outside click anywhere closes it.
if self.core.borrow().menu_is_open() {
self.dispatch_menu_mouse(ev, cell_row, cell_col);
return;
}
let Some((win_id, rect)) =
window_at_cell(&self.core.borrow(), term_size, cell_row, cell_col)
else {
@ -796,6 +922,14 @@ impl EditorState {
core.clear_selection();
}
}
MouseEventKind::Down(MouseButton::Right) => {
if local_row >= inner_rows {
self.mouse_click = None;
return; // Mode-line right-click: reserved.
}
self.mouse_click = None;
self.open_context_menu(win_id, local_row, local_col, (cell_row, cell_col));
}
MouseEventKind::ScrollUp => {
self.mouse_click = None;
self.scroll_window(win_id, -SCROLL_LINES);
@ -916,6 +1050,75 @@ impl EditorState {
aw.goal_col = None;
core.select_line_at_cursor();
}
// Right-click (Q#CM1) opens the menu, which needs the Lua
// builder — handled by `open_menu_at_byte`, which the daemon
// routes to *instead* of here. Unreachable in practice; the
// arm exists for match exhaustiveness.
PointerKind::Context => {}
}
}
/// Open the context menu at `byte` for a semantic frontend (Q#CM1) —
/// the byte-space twin of the TUI right-click. Keeps an existing
/// selection (so Copy/Cut act on it), else moves the cursor to the
/// click. The anchor cell is irrelevant for the GPU (it positions
/// the popup in pixels locally), so it stays at the origin.
pub fn open_menu_at_byte(
&mut self,
frontend_id: FrontendId,
buffer_id: crate::buffer::BufferId,
byte: u64,
) {
{
let mut core = self.core.borrow_mut();
core.active_frontend = frontend_id;
let Some(win_id) = core.views.get(&frontend_id).map(|v| v.active) else {
return;
};
if core.windows.get(&win_id).map(|w| w.buffer_id) != Some(buffer_id) {
return;
}
core.set_active_window_id(win_id);
if core.active_region().is_none() {
let snapped = {
let registry = core.registry.clone();
let reg = registry.borrow();
let Ok(buf) = reg.get(buffer_id) else {
return;
};
snap_to_char_boundary(buf, byte)
};
let aw = core.active_window_mut();
aw.cursor = snapped;
aw.goal_col = None;
}
}
let rows = self.build_menu_rows();
self.core.borrow_mut().menu_open(rows, (0, 0));
}
/// Apply a semantic frontend's menu navigation (Q#CM1). Hover
/// (`invoke = false`) moves the highlight; a click (`invoke = true`)
/// invokes the row, or dismisses the menu when `index` is `None`
/// (click outside the popup).
pub fn dispatch_menu_pointer(
&mut self,
frontend_id: FrontendId,
index: Option<u32>,
invoke: bool,
) {
self.core.borrow_mut().active_frontend = frontend_id;
if !self.core.borrow().menu_is_open() {
return;
}
match (index, invoke) {
(Some(i), false) => self.core.borrow_mut().menu_set_active_row(i as usize),
(Some(i), true) => {
self.core.borrow_mut().menu_set_active_row(i as usize);
self.menu_invoke_active();
}
(None, true) => self.core.borrow_mut().menu_close(),
(None, false) => {}
}
}
@ -1276,6 +1479,54 @@ impl SearchKey {
}
}
/// Keys handled while a context menu is open (Q#CM1). Like
/// [`SearchKey`], this shadows the global keymap; the same decode runs
/// in both frontends via the daemon's `FrontendEvent::Key` round-trip.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
enum MenuKey {
/// Highlight the next item (Down / C-n).
Next,
/// Highlight the previous item (Up / C-p).
Prev,
/// Invoke the highlighted item (RET).
Invoke,
/// Dismiss the menu (Esc / C-g).
Cancel,
/// Any other key — dismisses the menu (a click-away analogue).
Dismiss,
}
impl MenuKey {
/// Decode `chord` into a menu action. Unrecognized keys dismiss the
/// menu (standard popup behavior); a future mnemonic-jump refinement
/// would intercept printable chars here.
fn from_chord(chord: Chord) -> Self {
let ctrl = chord.modifiers.contains(KeyModifiers::CONTROL);
let alt = chord.modifiers.contains(KeyModifiers::ALT);
if !ctrl && !alt {
match chord.code {
KeyCode::Down => return Self::Next,
KeyCode::Up => return Self::Prev,
KeyCode::Enter => return Self::Invoke,
KeyCode::Esc => return Self::Cancel,
_ => return Self::Dismiss,
}
}
if ctrl
&& !alt
&& let KeyCode::Char(c) = chord.code
{
return match c {
'n' => Self::Next,
'p' => Self::Prev,
'g' => Self::Cancel,
_ => Self::Dismiss,
};
}
Self::Dismiss
}
}
/// Paint one full frame into `grid` and return the desired terminal
/// cursor position.
///
@ -2213,7 +2464,7 @@ mod tests {
// Indexed(11); lazy matches would be Indexed(3)).
let bg0 = grid.get(CellCoord::new(0, 0)).style.bg;
assert!(
matches!(bg0, Color::Indexed(11) | Color::Indexed(3)),
matches!(bg0, Color::Indexed(11 | 3)),
"first match cell should carry the search wash, got {bg0:?}"
);
// The bottom row shows the full live query, not just "f".
@ -2650,6 +2901,150 @@ mod tests {
}
}
fn right_click(row: u16, column: u16) -> crossterm::event::MouseEvent {
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Right),
row,
column,
modifiers: KeyModifiers::NONE,
}
}
fn menu_item_labels(s: &EditorState) -> Vec<String> {
let core = s.core.borrow();
let guard = core.menu.lock().unwrap();
guard
.as_ref()
.map(|m| {
m.rows
.iter()
.filter_map(|r| match r {
crate::menu::MenuRow::Item { label, .. } => Some(label.clone()),
crate::menu::MenuRow::Separator => None,
})
.collect()
})
.unwrap_or_default()
}
#[test]
fn right_click_opens_context_menu_with_default_items() {
let mut s = fresh_with(b"hello world");
let term = crate::cell::CellSize::new(24, 80);
s.dispatch_mouse(FrontendId(1), right_click(1, 3), term);
assert!(s.core.borrow().menu_is_open());
// No selection: the selection-only Cut/Copy are filtered out.
assert_eq!(
menu_item_labels(&s),
vec!["Paste", "Select All", "Undo", "Redo"]
);
}
#[test]
fn right_click_with_selection_includes_cut_and_copy() {
let mut s = fresh_with(b"hello world");
{
let mut c = s.core.borrow_mut();
c.begin_selection(0);
c.active_window_mut().cursor = 5; // select "hello"
}
s.dispatch_mouse(
FrontendId(1),
right_click(1, 3),
crate::cell::CellSize::new(24, 80),
);
assert_eq!(
menu_item_labels(&s),
vec!["Cut", "Copy", "Paste", "Select All", "Undo", "Redo"]
);
// Right-clicking with a selection preserves it (so Copy/Cut act on it).
assert!(s.core.borrow().active_region().is_some());
}
#[test]
fn menu_arrows_navigate_and_escape_dismisses() {
let mut s = fresh_with(b"abc");
s.dispatch_mouse(
FrontendId(1),
right_click(1, 1),
crate::cell::CellSize::new(24, 80),
);
assert_eq!(
s.core.borrow().menu_active_command().as_deref(),
Some("edit.paste")
);
s.dispatch_key(FrontendId(1), key(KeyCode::Down, KeyModifiers::NONE));
assert_eq!(
s.core.borrow().menu_active_command().as_deref(),
Some("edit.select-all")
);
s.dispatch_key(FrontendId(1), key(KeyCode::Esc, KeyModifiers::NONE));
assert!(!s.core.borrow().menu_is_open());
}
#[test]
fn menu_context_eval_gates_symbol_and_diagnostic() {
let mut s = fresh_with(b"");
let eval_bool = |s: &mut EditorState, expr: &str| -> bool {
matches!(
s.lua_host.eval(None, expr).unwrap(),
mlua::Value::Boolean(true)
)
};
// always / selection — pure context-table reads.
assert!(eval_bool(
&mut s,
"return pmacs.menu._context_eval('always', {})"
));
assert!(eval_bool(
&mut s,
"return pmacs.menu._context_eval('selection', {has_selection=true})"
));
assert!(!eval_bool(
&mut s,
"return pmacs.menu._context_eval('selection', {has_selection=false})"
));
// symbol needs BOTH a word and an attached server.
assert!(eval_bool(
&mut s,
"return pmacs.menu._context_eval('symbol', {word='x', attachment={uri='u'}})"
));
assert!(!eval_bool(
&mut s,
"return pmacs.menu._context_eval('symbol', {word='x'})"
));
assert!(!eval_bool(
&mut s,
"return pmacs.menu._context_eval('symbol', {attachment={uri='u'}})"
));
// diagnostic with no published diagnostics at the point → false
// (exercises the diag-store lookup without erroring).
assert!(!eval_bool(
&mut s,
"return pmacs.menu._context_eval('diagnostic', {attachment={uri='file:///none'}, line=0, col=0})"
));
}
#[test]
fn menu_enter_invokes_command_and_closes() {
let mut s = fresh_with(b"hello");
s.dispatch_mouse(
FrontendId(1),
right_click(1, 1),
crate::cell::CellSize::new(24, 80),
);
// Paste → Select All.
s.dispatch_key(FrontendId(1), key(KeyCode::Down, KeyModifiers::NONE));
assert_eq!(
s.core.borrow().menu_active_command().as_deref(),
Some("edit.select-all")
);
s.dispatch_key(FrontendId(1), key(KeyCode::Enter, KeyModifiers::NONE));
assert!(!s.core.borrow().menu_is_open());
// edit.select-all ran: the whole buffer is now the region.
assert_eq!(s.core.borrow().active_region(), Some((0, 5)));
}
/// The status line carries a Neovim/Doom-style scroll indicator
/// after `L:C`: `All` when the buffer fits, `Top` at the start,
/// `Bot` at the end, otherwise `NN%` cursor-row percent.

View File

@ -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");

View File

@ -47,7 +47,7 @@ use std::io::{self, BufWriter, Stdout, Write};
use std::time::Duration;
#[cfg(feature = "crdt")]
use crossterm::{cursor::MoveLeft, style::Print};
use crossterm::cursor::MoveLeft;
use crossterm::{
cursor::{self, MoveTo},
event::{
@ -56,7 +56,7 @@ use crossterm::{
},
queue,
style::{
Attribute, Color as CtColor, ResetColor, SetAttribute, SetBackgroundColor,
Attribute, Color as CtColor, Print, ResetColor, SetAttribute, SetBackgroundColor,
SetForegroundColor,
},
terminal::{
@ -66,7 +66,7 @@ use crossterm::{
};
use crate::cell::{CellSize, Color, DiffSpan, Glyph, Style};
use crate::protocol::InstanceMessage;
use crate::protocol::{InstanceMessage, InstanceSignal};
// Re-export the input event types so callers don't depend on crossterm
// directly. M2's keymap will translate these into normalized commands.
@ -340,7 +340,16 @@ impl Frontend {
queue!(self.out, cursor::Hide)?;
}
},
// Q#CM6 — publish to the OS clipboard via OSC 52. Writes are
// reliable in modern terminals; OSC 52 *reads* are widely
// disabled for security, which is why inbound paste rides
// bracketed paste rather than querying the clipboard here.
InstanceMessage::Signal(InstanceSignal::Clipboard(data)) => {
let payload = format!("\x1b]52;c;{}\x07", osc52_base64(data));
queue!(self.out, Print(payload))?;
}
InstanceMessage::ModeLine(_)
// Bell / window-title Signals stay reserved for v0.3.
| InstanceMessage::Signal(_)
| InstanceMessage::Goodbye(_)
// T M10.5: CrdtOp's wire shape exists; the v1.0 TUI doesn't
@ -388,6 +397,10 @@ impl Frontend {
// family member; the cell-grid TUI never negotiates it and
// drops it silently if one arrives.
| InstanceMessage::SearchPrompt { .. }
// Q#CM1 — MenuPrompt is the semantic-frontend menu surface;
// the TUI renders the menu via its cell overlay instead, so
// it drops this silently like the other semantic families.
| InstanceMessage::MenuPrompt { .. }
| InstanceMessage::ResourceOffer { .. }
// T M11.6 — DispatchIdle is consumed by `attach.rs`'s
// optimistic-apply gate; if any reaches this render path
@ -678,6 +691,34 @@ fn to_ct_color(c: Color) -> CtColor {
}
}
/// Standard-alphabet base64 (RFC 4648, with padding) for the OSC 52
/// clipboard payload (Q#CM6). Inlined rather than pulling a crate: the
/// only consumer is the clipboard escape, and the encoder is a dozen
/// lines with no edge cases beyond the 1-/2-byte tail.
fn osc52_base64(data: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for chunk in data.chunks(3) {
let b0 = chunk[0];
let b1 = chunk.get(1).copied().unwrap_or(0);
let b2 = chunk.get(2).copied().unwrap_or(0);
let n = (u32::from(b0) << 16) | (u32::from(b1) << 8) | u32::from(b2);
out.push(ALPHABET[(n >> 18) as usize & 0x3f] as char);
out.push(ALPHABET[(n >> 12) as usize & 0x3f] as char);
out.push(if chunk.len() > 1 {
ALPHABET[(n >> 6) as usize & 0x3f] as char
} else {
'='
});
out.push(if chunk.len() > 2 {
ALPHABET[n as usize & 0x3f] as char
} else {
'='
});
}
out
}
// ---------------------------------------------------------------------------
// Tests (pure parts only --- the lifecycle machinery requires a TTY)
// ---------------------------------------------------------------------------
@ -687,6 +728,18 @@ mod tests {
use super::*;
use crate::cell::{Cell, CellCoord, Glyph, Style};
#[test]
fn osc52_base64_matches_known_vectors() {
// RFC 4648 §10 test vectors exercise both tail lengths.
assert_eq!(osc52_base64(b""), "");
assert_eq!(osc52_base64(b"f"), "Zg==");
assert_eq!(osc52_base64(b"fo"), "Zm8=");
assert_eq!(osc52_base64(b"foo"), "Zm9v");
assert_eq!(osc52_base64(b"foob"), "Zm9vYg==");
assert_eq!(osc52_base64(b"fooba"), "Zm9vYmE=");
assert_eq!(osc52_base64(b"foobar"), "Zm9vYmFy");
}
fn ch(c: char) -> Cell {
Cell {
glyph: Glyph::Char(c),

View File

@ -77,6 +77,7 @@ pub mod lua;
pub mod lua_bindings;
pub mod lua_isolation;
pub mod mcp;
pub mod menu;
pub mod message_bus;
pub mod minibuffer;
// T M10.10: frontend-side optimistic-apply infrastructure (predicate

View File

@ -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, &registry, &commands, &keymaps, &hooks)?;
lua_bindings::install(&lua, &registry, &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(())
}

View File

@ -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, &reg, &cmds, &kms, &hks).expect("install");
install(&lua, &reg, &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();

624
src/menu.rs Normal file
View File

@ -0,0 +1,624 @@
// menu.rs --- Menu registry: the items that populate the right-click context menu.
//! Context-menu items.
//!
//! The right-click context menu (Q#CM2) is populated from a Lua
//! registry that mirrors [`crate::command::CommandRegistry`]. Each
//! [`MenuItem`] names a [`crate::command::Command`] to invoke and
//! carries optional visibility controls: a coarse `context` tag
//! (sugar) and/or a full `predicate` (the escape hatch), evaluated
//! against a context table when the menu opens (Q#CM3). Items are
//! grouped and ordered for layout; separators fall between groups.
//!
//! # Storage and lookup
//!
//! [`MenuRegistry`] owns the live items in insertion order. Unlike
//! commands, items are not uniquely *named* --- two contexts may each
//! contribute a "Copy". An optional `id` allows targeted removal or
//! in-place replacement, so a user config can hide or override a
//! builtin item (`pmacs.menu.remove("edit.copy")`, or re-`item` with
//! the same `id`) without disturbing the rest of the menu.
//!
//! # Threading
//!
//! Single-threaded, behind `Rc<RefCell<...>>` next to the command and
//! keymap registries.
use mlua::Function;
use thiserror::Error;
use crate::command::SourceLocation;
// ---------------------------------------------------------------------------
// Contexts
// ---------------------------------------------------------------------------
/// Coarse context tags that are sugar for a visibility predicate
/// (Q#CM3). Validated at registration so a typo (`"selecton"`) is a
/// hard error rather than a silently invisible item --- the same
/// typo-paranoia as the command spec's unknown-field check (R50).
///
/// The tag → predicate mapping is owned by the menu builder in the
/// core (it has the live context table); the registry only validates
/// the vocabulary.
pub const KNOWN_CONTEXTS: &[&str] = &["always", "selection", "symbol", "diagnostic"];
// ---------------------------------------------------------------------------
// MenuItem + errors
// ---------------------------------------------------------------------------
/// A single context-menu entry.
///
/// Cloning is cheap: `String`s clone trivially and `mlua::Function` is
/// reference-counted internally.
#[derive(Clone)]
pub struct MenuItem {
/// Optional stable identifier. Enables targeted [`MenuRegistry::remove`]
/// and in-place override (re-adding with the same id replaces).
pub id: Option<String>,
/// Human-readable label shown in the menu row.
pub label: String,
/// Name of the [`crate::command::Command`] this item invokes.
/// Resolved at invoke time (like a keymap binding), not at
/// registration --- the command may be defined later.
pub command: String,
/// Coarse visibility tag (one of [`KNOWN_CONTEXTS`]). Sugar for a
/// predicate; ignored when `predicate` is set.
pub context: Option<String>,
/// Full visibility predicate: `fn(context_table) -> bool`. The
/// escape hatch for items whose availability the coarse tags can't
/// express. Takes precedence over `context`.
pub predicate: Option<Function>,
/// Layout group. Items sharing a group render together; a separator
/// falls between distinct groups (group first-appearance order).
pub group: String,
/// Sort key within a group (ascending). Ties break on insertion order.
pub order: i64,
/// Where the item was defined (Lua `file:line`).
pub source: SourceLocation,
}
/// Errors raised by the menu registry.
#[derive(Debug, Error)]
pub enum MenuError {
/// `item` was called with no label or an all-whitespace one.
#[error("menu item label must be non-empty")]
EmptyLabel,
/// `item` was called without a `command` to invoke.
#[error("menu item \"{label}\" requires a non-empty `command`")]
EmptyCommand {
/// The offending item's label.
label: String,
},
/// The `context` tag is not one of [`KNOWN_CONTEXTS`].
#[error(
"menu item \"{label}\" has unknown context `{context}`; supported: always, selection, symbol, diagnostic"
)]
UnknownContext {
/// The offending item's label.
label: String,
/// The offending context tag.
context: String,
},
/// The spec table contained a key the registry doesn't know about.
#[error(
"unknown field `{field}` in menu item spec; supported: id, label, command, context, predicate, group, order"
)]
UnknownField {
/// The offending key.
field: String,
},
}
// ---------------------------------------------------------------------------
// MenuRegistry
// ---------------------------------------------------------------------------
/// Ordered registry of context-menu items.
///
/// Insert via [`Self::add`] (validates, replacing in place on a
/// matching `id`). Read the live set via [`Self::items`]; the menu
/// builder sorts and groups at open time. Remove by id via
/// [`Self::remove`]; reset via [`Self::clear`].
#[derive(Default)]
pub struct MenuRegistry {
items: Vec<MenuItem>,
}
impl MenuRegistry {
/// An empty registry.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Insert `item`, validating its metadata. If the item carries an
/// `id` already present, the existing entry is replaced in place
/// (preserving its slot) so re-running config and user overrides are
/// idempotent; otherwise the item is appended.
pub fn add(&mut self, item: MenuItem) -> Result<(), MenuError> {
if item.label.trim().is_empty() {
return Err(MenuError::EmptyLabel);
}
if item.command.trim().is_empty() {
return Err(MenuError::EmptyCommand { label: item.label });
}
if let Some(context) = &item.context
&& !KNOWN_CONTEXTS.contains(&context.as_str())
{
return Err(MenuError::UnknownContext {
label: item.label,
context: context.clone(),
});
}
if let Some(id) = item.id.clone()
&& let Some(slot) = self
.items
.iter_mut()
.find(|it| it.id.as_deref() == Some(&id))
{
*slot = item;
return Ok(());
}
self.items.push(item);
Ok(())
}
/// The live items, in insertion order.
#[must_use]
pub fn items(&self) -> &[MenuItem] {
&self.items
}
/// Remove every item whose `id` equals `id`. Returns `true` if any
/// item was removed.
pub fn remove(&mut self, id: &str) -> bool {
let before = self.items.len();
self.items.retain(|it| it.id.as_deref() != Some(id));
self.items.len() != before
}
/// Drop every item.
pub fn clear(&mut self) {
self.items.clear();
}
/// Number of registered items.
#[must_use]
pub fn len(&self) -> usize {
self.items.len()
}
/// True iff no items are registered.
#[must_use]
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
}
// ---------------------------------------------------------------------------
// Open-menu runtime state (Q#CM1) + TUI surface
// ---------------------------------------------------------------------------
use std::sync::{Arc, Mutex};
use crate::buffer::Buffer;
use crate::cell::{CellCoord, CellGrid, Color, Glyph, Style};
use crate::view::{View, Viewport};
/// One rendered row of an open menu: either a selectable command or a
/// non-selectable group divider. The resolved list is built in Lua
/// (`pmacs.menu.build`, which evaluates predicates / context tags and
/// groups items) and stored here; navigation skips `Separator` rows.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MenuRow {
/// A divider between groups.
Separator,
/// A selectable entry that invokes `command` when chosen.
Item {
/// Display label.
label: String,
/// Name of the command invoked on selection.
command: String,
},
}
impl MenuRow {
const fn is_item(&self) -> bool {
matches!(self, MenuRow::Item { .. })
}
}
/// The live state of an open context menu (Q#CM1). Frontend-agnostic:
/// the TUI [`MenuView`] overlay and (later) the GPU producer both render
/// from this. `active` is an index into `rows` that always points at an
/// [`MenuRow::Item`]. `anchor` is the absolute cell the menu opens at
/// (the click point) — the TUI render origin; the GPU positions in
/// pixels locally and ignores it.
pub struct MenuState {
/// Rows top-to-bottom (items + separators).
pub rows: Vec<MenuRow>,
/// Highlighted row index (always an `Item`).
pub active: usize,
/// Absolute `(row, col)` cell the popup renders from.
pub anchor: (u32, u32),
/// Popup width in cells (max label + padding, clamped).
pub width: u32,
}
/// Min / max popup width in cells.
const MENU_MIN_WIDTH: u32 = 8;
const MENU_MAX_WIDTH: u32 = 48;
impl MenuState {
/// Build from a resolved row list. Returns `None` when no row is an
/// `Item` (an empty menu never opens). Width is the widest label
/// plus padding, clamped to [`MENU_MIN_WIDTH`]..[`MENU_MAX_WIDTH`].
#[must_use]
pub fn new(rows: Vec<MenuRow>, anchor: (u32, u32)) -> Option<Self> {
let active = rows.iter().position(MenuRow::is_item)?;
let widest = rows
.iter()
.filter_map(|r| match r {
MenuRow::Item { label, .. } => Some(label.chars().count() as u32),
MenuRow::Separator => None,
})
.max()
.unwrap_or(0);
let width = (widest + 2).clamp(MENU_MIN_WIDTH, MENU_MAX_WIDTH);
Some(Self {
rows,
active,
anchor,
width,
})
}
/// Move the highlight one `Item` row forward (`delta >= 0`) or back
/// (`delta < 0`), wrapping and skipping separators. Only the sign of
/// `delta` matters — callers step one item at a time.
pub fn step(&mut self, delta: isize) {
let n = self.rows.len();
if n == 0 {
return;
}
let forward = delta >= 0;
for _ in 0..n {
self.active = if forward {
(self.active + 1) % n
} else {
(self.active + n - 1) % n
};
if self.rows[self.active].is_item() {
return;
}
}
}
/// The active item's command name.
#[must_use]
pub fn active_command(&self) -> Option<&str> {
match self.rows.get(self.active)? {
MenuRow::Item { command, .. } => Some(command),
MenuRow::Separator => None,
}
}
/// Map an absolute cell to the row index it covers, but only when
/// that row is a selectable `Item` (separators and cells outside the
/// popup rectangle return `None`).
#[must_use]
pub fn hit(&self, row: u32, col: u32) -> Option<usize> {
let (arow, acol) = self.anchor;
if row < arow || col < acol {
return None;
}
let ri = (row - arow) as usize;
let ci = col - acol;
if ri >= self.rows.len() || ci >= self.width {
return None;
}
self.rows[ri].is_item().then_some(ri)
}
}
/// Shared handle to the open menu (`None` when closed). Held by
/// [`crate::editor_core::EditorCore`] and read by [`MenuView`], mirroring
/// the search store's `Arc<Mutex>` bridge between core state and the
/// overlay that renders it.
pub type SharedMenu = Arc<Mutex<Option<MenuState>>>;
/// A fresh, closed shared menu.
#[must_use]
pub fn make_shared_menu() -> SharedMenu {
Arc::new(Mutex::new(None))
}
/// Popup background (non-selected rows) — a dim fill so the menu reads
/// as a floating surface over the buffer text it occludes.
fn menu_style() -> Style {
Style {
fg: Color::Indexed(252),
bg: Color::Indexed(236),
..Style::default()
}
}
/// Highlighted-row style (the active item).
fn menu_selected_style() -> Style {
Style {
fg: Color::Indexed(231),
bg: Color::Indexed(24),
..Style::default()
}
}
/// TUI overlay that paints the open menu (Q#CM1). Persistent on the
/// active window once attached (deduped by [`View::kind`]); renders
/// nothing while the menu is closed, mirroring `SearchView`'s
/// self-suppressing model. Owns every cell inside the popup rectangle,
/// occluding the buffer text beneath.
pub struct MenuView {
menu: SharedMenu,
}
impl MenuView {
/// Build a view reading `menu`.
#[must_use]
pub fn new(menu: SharedMenu) -> Self {
Self { menu }
}
}
impl View for MenuView {
fn kind(&self) -> &'static str {
"context-menu"
}
fn render(&mut self, _buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) {
let guard = self.menu.lock().expect("menu mutex poisoned");
let Some(menu) = guard.as_ref() else {
return;
};
let top = viewport.cell_origin.row;
let left = viewport.cell_origin.col;
let bottom = top + viewport.cell_size.rows;
let right = left + viewport.cell_size.cols;
let (arow, acol) = menu.anchor;
for (i, row) in menu.rows.iter().enumerate() {
let r = arow + i as u32;
if r < top || r >= bottom {
continue; // clip rows that fall outside the window
}
let selected = i == menu.active;
let row_style = if selected {
menu_selected_style()
} else {
menu_style()
};
// Paint the full-width row background first.
for c in 0..menu.width {
let col = acol + c;
if col < left || col >= right {
continue;
}
let cell = cells.at(CellCoord::new(r, col));
cell.glyph = Glyph::Char(' ');
cell.style = row_style;
cell.attachment = None;
}
match row {
MenuRow::Separator => {
for c in 0..menu.width {
let col = acol + c;
if col < left || col >= right {
continue;
}
cells.at(CellCoord::new(r, col)).glyph = Glyph::Char('─');
}
}
MenuRow::Item { label, .. } => {
// One cell of left padding; stop at the popup's right edge.
let row_right = (acol + menu.width).min(right);
for (col, ch) in (acol + 1..).zip(label.chars()) {
if col >= row_right {
break;
}
let cell = cells.at(CellCoord::new(r, col));
cell.glyph = Glyph::Char(ch);
cell.style = row_style;
cell.attachment = None;
}
}
}
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use mlua::Lua;
fn item(id: Option<&str>, label: &str, command: &str) -> MenuItem {
MenuItem {
id: id.map(ToOwned::to_owned),
label: label.to_owned(),
command: command.to_owned(),
context: None,
predicate: None,
group: String::new(),
order: 0,
source: SourceLocation::default(),
}
}
#[test]
fn add_then_items_round_trips() {
let mut r = MenuRegistry::new();
r.add(item(None, "Copy", "edit.copy")).unwrap();
assert_eq!(r.len(), 1);
assert_eq!(r.items()[0].label, "Copy");
assert_eq!(r.items()[0].command, "edit.copy");
}
#[test]
fn empty_label_is_rejected() {
let mut r = MenuRegistry::new();
assert!(matches!(
r.add(item(None, " ", "edit.copy")),
Err(MenuError::EmptyLabel)
));
}
#[test]
fn empty_command_is_rejected() {
let mut r = MenuRegistry::new();
match r.add(item(None, "Copy", "")) {
Err(MenuError::EmptyCommand { label }) => assert_eq!(label, "Copy"),
other => panic!("expected EmptyCommand, got {other:?}"),
}
}
#[test]
fn unknown_context_is_rejected() {
let mut r = MenuRegistry::new();
let mut it = item(None, "Copy", "edit.copy");
it.context = Some("selecton".into());
match r.add(it) {
Err(MenuError::UnknownContext { label, context }) => {
assert_eq!(label, "Copy");
assert_eq!(context, "selecton");
}
other => panic!("expected UnknownContext, got {other:?}"),
}
}
#[test]
fn known_contexts_are_accepted() {
let mut r = MenuRegistry::new();
for cx in KNOWN_CONTEXTS {
let mut it = item(None, "X", "x.cmd");
it.context = Some((*cx).to_owned());
r.add(it).unwrap();
}
assert_eq!(r.len(), KNOWN_CONTEXTS.len());
}
#[test]
fn predicate_is_stored() {
let lua = Lua::new();
let mut r = MenuRegistry::new();
let mut it = item(None, "Paste", "edit.paste");
it.predicate = Some(lua.create_function(|_, ()| Ok(true)).unwrap());
r.add(it).unwrap();
assert!(r.items()[0].predicate.is_some());
}
#[test]
fn matching_id_replaces_in_place() {
let mut r = MenuRegistry::new();
r.add(item(Some("a"), "First", "cmd.a")).unwrap();
r.add(item(Some("b"), "Second", "cmd.b")).unwrap();
// Override `a` in place: stays at slot 0 with the new label.
r.add(item(Some("a"), "First!", "cmd.a")).unwrap();
assert_eq!(r.len(), 2);
assert_eq!(r.items()[0].label, "First!");
assert_eq!(r.items()[1].label, "Second");
}
#[test]
fn remove_by_id_drops_the_item() {
let mut r = MenuRegistry::new();
r.add(item(Some("a"), "A", "cmd.a")).unwrap();
r.add(item(None, "B", "cmd.b")).unwrap();
assert!(r.remove("a"));
assert!(!r.remove("a")); // already gone
assert_eq!(r.len(), 1);
assert_eq!(r.items()[0].label, "B");
}
#[test]
fn clear_empties_the_registry() {
let mut r = MenuRegistry::new();
r.add(item(None, "A", "cmd.a")).unwrap();
r.add(item(None, "B", "cmd.b")).unwrap();
r.clear();
assert!(r.is_empty());
}
#[test]
fn items_without_id_both_append() {
let mut r = MenuRegistry::new();
r.add(item(None, "A", "cmd.a")).unwrap();
r.add(item(None, "A", "cmd.a")).unwrap();
// No id → no dedup; both are kept.
assert_eq!(r.len(), 2);
}
// ---- MenuState (open-menu runtime) -------------------------------------
fn row_item(label: &str) -> MenuRow {
MenuRow::Item {
label: label.to_owned(),
command: format!("cmd.{label}"),
}
}
#[test]
fn menu_state_new_requires_a_selectable_item() {
assert!(MenuState::new(vec![], (0, 0)).is_none());
assert!(MenuState::new(vec![MenuRow::Separator], (0, 0)).is_none());
// active lands on the first item, skipping a leading separator.
let m = MenuState::new(vec![MenuRow::Separator, row_item("A")], (2, 3)).unwrap();
assert_eq!(m.active, 1);
assert_eq!(m.anchor, (2, 3));
}
#[test]
fn menu_state_step_skips_separators_and_wraps() {
let rows = vec![row_item("A"), MenuRow::Separator, row_item("B")];
let mut m = MenuState::new(rows, (0, 0)).unwrap();
assert_eq!(m.active, 0);
m.step(1);
assert_eq!(m.active, 2); // jumps over the separator at row 1
m.step(1);
assert_eq!(m.active, 0); // wraps to the top
m.step(-1);
assert_eq!(m.active, 2); // wraps back, still skipping the separator
}
#[test]
fn menu_state_hit_maps_cells_to_item_rows_only() {
let rows = vec![row_item("A"), MenuRow::Separator, row_item("B")];
let m = MenuState::new(rows, (5, 10)).unwrap();
// Row 5 = item A (anywhere within the popup width).
assert_eq!(m.hit(5, 10), Some(0));
assert_eq!(m.hit(5, 10 + m.width - 1), Some(0));
// Row 6 = separator → not selectable.
assert_eq!(m.hit(6, 10), None);
// Row 7 = item B.
assert_eq!(m.hit(7, 12), Some(2));
// Outside the popup rectangle in each direction.
assert_eq!(m.hit(4, 10), None); // above
assert_eq!(m.hit(8, 10), None); // below
assert_eq!(m.hit(5, 9), None); // left
assert_eq!(m.hit(5, 10 + m.width), None); // right
}
#[test]
fn menu_state_active_command_reads_the_highlight() {
let mut m = MenuState::new(vec![row_item("A"), row_item("B")], (0, 0)).unwrap();
assert_eq!(m.active_command(), Some("cmd.A"));
m.step(1);
assert_eq!(m.active_command(), Some("cmd.B"));
}
}

View File

@ -1683,7 +1683,7 @@ mod tests {
// --- M5.5a handshake & postcard round-trips ---
#[test]
fn protocol_version_is_ten_for_regex_search_prompt() {
fn protocol_version_is_eleven_for_context_menu() {
// Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp /
// PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the
// SemanticFrame family + FrontendEvent::Viewport). T M11.6
@ -1697,8 +1697,10 @@ mod tests {
// + daemon-gated per session). Q#SR5 bumped 8→9
// (`InstanceMessage::SearchPrompt`, additive + daemon-gated).
// Q#RX6 bumped 9→10 (`SearchPrompt` gained regex/invalid;
// encoding change to that variant, still daemon-gated).
assert_eq!(PROTOCOL_VERSION, 10);
// encoding change to that variant, still daemon-gated). Q#CM1
// bumped 10→11 (`PointerKind::Context` + `MenuPointer` +
// `MenuPrompt`, all additive; the message daemon-gated).
assert_eq!(PROTOCOL_VERSION, 11);
}
#[test]
@ -1707,19 +1709,20 @@ mod tests {
// every cell-carrying message, ending the v1v5 ladder —
// pre-v6 peers are refused at the handshake (a clean
// VersionMismatch) rather than garbling postcard mid-session.
// Q#M4 / Q#S1 / Q#SR5 / Q#RX6: the ladder resumes above that
// floor — v7 (`TripleDown`, frontend-gated), v8 (`StatusFacts`),
// v9 + v10 (`SearchPrompt` and its regex/invalid extension, both
// daemon-gated) interoperate, so v6 through v10 talk.
// Q#M4 / Q#S1 / Q#SR5 / Q#RX6 / Q#CM1: the ladder resumes above
// that floor — v7 (`TripleDown`), v8 (`StatusFacts`), v9 + v10
// (`SearchPrompt` + regex/invalid), v11 (the context menu) all
// interoperate, so v6 through v11 talk.
assert!(is_supported_protocol_version(6));
assert!(is_supported_protocol_version(7));
assert!(is_supported_protocol_version(8));
assert!(is_supported_protocol_version(9));
assert!(is_supported_protocol_version(10));
for rejected in [0, 1, 2, 3, 4, 5, 11, u32::MAX] {
assert!(is_supported_protocol_version(11));
for rejected in [0, 1, 2, 3, 4, 5, 12, u32::MAX] {
assert!(
!is_supported_protocol_version(rejected),
"v{rejected} must be rejected by a v10 binary"
"v{rejected} must be rejected by a v11 binary"
);
}
}

View File

@ -37,7 +37,7 @@ use crate::cell::Style;
use crate::editor::EditorState;
use crate::protocol::{
AdornmentContent, AdornmentPlacement, ByteRange, Decoration, DecorationKind, DecorationSegment,
FrontendId, InlineAdornment, InstanceMessage, StyleSegment, StyleSpan,
FrontendId, InlineAdornment, InstanceMessage, MenuPromptRow, StyleSegment, StyleSpan,
};
/// The viewport a `semantic_render` frontend last declared.
@ -81,6 +81,11 @@ struct LastFrame<T> {
/// query means the last emission cleared the band.
type SearchPromptFacts = (Option<String>, Option<u32>, u32, bool, bool);
/// Cached `MenuPrompt` payload for cached-compare suppression (Q#CM1):
/// `(rows, active)`. Empty `rows` means the last emission closed the
/// menu.
type MenuPromptFacts = (Vec<MenuPromptRow>, Option<u32>);
/// Owns one `semantic_render` session's projection state: the last
/// viewport the frontend declared, and the diff baseline per buffer
/// for the `StyleSpans` and `Decorations` families.
@ -129,6 +134,9 @@ pub struct SemanticRenderState {
/// Last emitted `SearchPrompt` payload per buffer, for
/// cached-compare suppression (see [`SearchPromptFacts`]).
last_search_prompt: HashMap<BufferId, SearchPromptFacts>,
/// Last emitted `MenuPrompt` payload per buffer (Q#CM1), for
/// cached-compare suppression (see [`MenuPromptFacts`]).
last_menu_prompt: HashMap<BufferId, MenuPromptFacts>,
/// `StyleSpans` recompute gate (perf). `scoped_style_spans` runs
/// the tree-sitter highlights query over the *whole declared
/// viewport* (which the GPU frontend sets to the entire buffer)
@ -198,6 +206,7 @@ impl SemanticRenderState {
last_decorations: HashMap::new(),
last_adornments: HashMap::new(),
last_search_prompt: HashMap::new(),
last_menu_prompt: HashMap::new(),
last_summary: HashMap::new(),
last_status: HashMap::new(),
last_style_gate: HashMap::new(),
@ -400,6 +409,8 @@ impl SemanticRenderState {
out.extend(self.status_facts_msg(state, vp.buffer_id));
// --- SearchPrompt (isearch band; Q#SR5, protocol v9) ---
out.extend(self.search_prompt_msg(state, vp.buffer_id));
// --- MenuPrompt (context menu; Q#CM1, protocol v11) ---
out.extend(self.menu_prompt_msg(state, vp.buffer_id));
out
}
@ -466,6 +477,63 @@ impl SemanticRenderState {
Some(msg)
}
/// The `MenuPrompt` message for this frame, or `None` when the menu
/// state for `buffer_id` is unchanged (Q#CM1). Only the active
/// buffer carries a live menu (it shadows dispatch). Closed = empty
/// `rows`; first sight of a buffer with no menu stays silent (like
/// `search_prompt_msg`). The daemon keeps the variant off wires
/// negotiated `< 11`.
fn menu_prompt_msg(
&mut self,
state: &EditorState,
buffer_id: BufferId,
) -> Option<InstanceMessage> {
let facts: MenuPromptFacts = {
let core = state.core.borrow();
if buffer_id != core.active_buffer_id() {
return None;
}
let guard = core.menu.lock().expect("menu mutex poisoned");
match guard.as_ref() {
Some(m) => {
let rows = m
.rows
.iter()
.map(|r| match r {
crate::menu::MenuRow::Item { label, .. } => MenuPromptRow {
label: label.clone(),
separator: false,
},
crate::menu::MenuRow::Separator => MenuPromptRow {
label: String::new(),
separator: true,
},
})
.collect();
(rows, u32::try_from(m.active).ok())
}
None => (Vec::new(), None),
}
};
let cached = self.last_menu_prompt.get(&buffer_id);
if cached == Some(&facts) {
return None;
}
// First sight, menu closed: nothing to clear, stay silent (record
// the baseline so a later open→close still diffs).
if cached.is_none() && facts.0.is_empty() {
self.last_menu_prompt.insert(buffer_id, facts);
return None;
}
let msg = InstanceMessage::MenuPrompt {
buffer_id,
rows: facts.0.clone(),
active: facts.1,
};
self.last_menu_prompt.insert(buffer_id, facts);
Some(msg)
}
/// The `StatusFacts` message for this frame, or `None` when
/// nothing changed. Carries the facts a semantic frontend cannot
/// derive locally: buffer name, modified flag, whole-file