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:
parent
b934723dfd
commit
640b998d6b
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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