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