Merge pull request #72 from levineuwirth/session-context-menu
Right-click context menus + OS clipboard (protocol v11)
This commit is contained in:
commit
a6070e201d
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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+/
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 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<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.
|
||||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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`].
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
399
src/editor.rs
399
src/editor.rs
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
23
src/lua.rs
23
src/lua.rs
|
|
@ -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, ®istry, &commands, &keymaps, &hooks)?;
|
||||
lua_bindings::install(&lua, ®istry, &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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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, ®, &cmds, &kms, &hks).expect("install");
|
||||
install(&lua, ®, &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();
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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 v1–v5 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue