pmacs context menu: protocol v11 + dispatch + TUI/GPU surfaces (Q#CM1/Q#CM5)

The wiring that makes the menu and OS clipboard work end-to-end. The
protocol bump touches every exhaustive match on the wire enums, so the
daemon / frontend / GPU consumers all land together.

Protocol v11 (additive; SUPPORTED = [6..11]):
- `PointerKind::Context` (right-click), `FrontendEvent::MenuPointer`
  (GPU->daemon navigation, index-only), `InstanceMessage::MenuPrompt` +
  `MenuPromptRow` (daemon->GPU rows + highlight, daemon-gated >= 11).

Dispatch + producer:
- `EditorState`: menu interception in `dispatch_key`/`dispatch_mouse`,
  `MenuKey`, `dispatch_menu_key`/`_mouse`, `open_context_menu` (TUI) /
  `open_menu_at_byte` + `dispatch_menu_pointer` (GPU), `build_menu_rows`
  (calls the Lua resolver), `dispatch_idle` now false while a menu is
  open. `dispatch_pointer` gains the `Context` arm.
- daemon: routes `Context` -> open, `MenuPointer` -> navigate; gates
  `MenuPrompt` >= 11; drains the clipboard publish as
  `InstanceSignal::Clipboard`; honors the previously-dropped
  `FrontendEvent::Paste` (so paste works for the first time).
- `semantic_render`: `MenuPrompt` producer with cached-compare.

Frontends:
- TUI (`frontend.rs`): OSC 52 clipboard write; ignores `MenuPrompt`
  (the cell overlay renders the menu).
- GPU (`pmacs-gpu`): `arboard` dep; clipboard write/read + Ctrl-V inbound
  paste; right-click -> `Context`; `MenuLocal` + `MenuPrompt` handler;
  the popup (a second `TextRenderer` over bg quads) at the click pixel;
  hover/click -> `MenuPointer`; key intercept while open.

Also folds a pre-existing clippy `unnested_or_patterns` nit in a search
test (`Color::Indexed(11 | 3)`) that newer CI clippy surfaced.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
This commit is contained in:
Levi Neuwirth 2026-06-27 22:19:00 -04:00
parent b934723dfd
commit 640b998d6b
11 changed files with 1228 additions and 28 deletions

139
Cargo.lock generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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