pmacs GPU: forward all command chords, not an allowlist (Q#GC1)

The GUI now forwards any command chord (Char/Enter/Tab with Ctrl or Alt)
to the daemon, so the whole Emacs keymap is reachable in pmacs-gpu —
`C-a`/`C-e`, `M-f`/`M-b`, `M-d`, `C-/`, `C-k`, `C-x C-s`, … — not just a
hand-carved allowlist. Pure GPU input routing; the wire and daemon are
untouched (no protocol change).

`is_command_chord` replaces `is_search_entry_chord` / `is_clipboard_chord`
/ `is_minibuffer_open_chord` (all three were just `Char + Ctrl/Alt`,
subsumed), collapsing three near-identical handler blocks into one
(−51 lines). This is the payoff of the minibuffer arc: the reason the GUI
withheld command chords was the un-renderable minibuffer (Q#MB1), now
gone. Forwarded chords still mark the optimistic cursor stale and never
flip `dispatch_idle` locally (the search-entry precedent).

Unchanged: `should_forward_key` still withholds Ctrl/Alt chords (they're
caught before it — its test holds); motion / Backspace / Delete keep
their defer-aware path; `Ctrl-V` stays local OS paste; `Escape` stays
quit/cancel; `Meta`/`Super`-only chords are left to the OS.

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-07-03 10:05:55 -04:00
parent aae37d821d
commit bc32332a41
1 changed files with 58 additions and 109 deletions

View File

@ -832,61 +832,32 @@ impl ApplicationHandler<AppEvent> for App {
return; return;
} }
// Idle: C-s / C-r begin an incremental search. They are // Idle: forward any command chord (Char/Enter/Tab with
// otherwise withheld as command chords; forward them so // Ctrl or Alt) to the daemon (Q#GC1). These drive the
// the search can start. The daemon then flips the // keymap — `C-a`, `M-f`, `C-x C-s`, isearch/clipboard/M-x,
// intercept gate (DispatchIdle / SearchPrompt) one // … — the same path the TUI forwards everything through.
// round-trip later, after which every key routes into the // The GUI no longer withholds them (the minibuffer / prompt
// search — no optimistic local flip, so a C-s that (via // flows they open now render, Q#MB1). Once a forwarded
// rebinding) doesn't start a search can never wedge the // chord opens a prompt or enters a prefix, `dispatch_idle`
// gate against the daemon's authoritative state. // flips false and the intercept gate round-trips the rest —
if is_search_entry_chord(pkey, pmods) { // no optimistic local flip, so a chord that changes no
// daemon state can never wedge the gate. (Ctrl-V / OS paste
// is handled locally above and never reaches here.)
if is_command_chord(pkey, pmods) {
if let Some(state) = self.state.as_mut() { if let Some(state) = self.state.as_mut() {
state.mark_cursor_stale_after_round_trip(); state.mark_cursor_stale_after_round_trip();
} }
if let Err(e) = client.send_key(pkey, pmods) { if let Err(e) = client.send_key(pkey, pmods) {
eprintln!("pmacs-gpu: send_key (search entry) failed: {e}"); eprintln!("pmacs-gpu: send_key (command chord) failed: {e}");
}
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;
}
// Minibuffer-opening chords (Q#MB1): M-x (execute-command)
// and the C-x prefix. Forwarded though otherwise withheld
// so the GUI can open a prompt / enter a prefix; the
// daemon then flips `dispatch_idle` false (minibuffer
// active / pending prefix) and the intercept gate
// round-trips every following key. No optimistic local
// flip (the search-entry precedent).
if is_minibuffer_open_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 (minibuffer open) failed: {e}");
} }
return; return;
} }
// Session B2 forwards cursor motion + plain text editing // Session B2 forwards cursor motion + plain text editing
// (Char / Backspace / Enter / Delete / Tab). Ctrl/Alt/ // (Char / Backspace / Enter / Delete / Tab). Command chords
// Meta chords are withheld — they drive commands and // are handled above; Meta/Super-only chords fall through
// minibuffer flows the GUI can't render or interact with // here and are withheld, leaving OS/WM shortcuts (Cmd-Q,
// yet (a later session adds GUI minibuffer + chords). // Cmd-C) to the platform.
if !should_forward_key(pkey, pmods) { if !should_forward_key(pkey, pmods) {
return; return;
} }
@ -4793,39 +4764,19 @@ fn should_forward_key(key: ProtocolKey, mods: Modifiers) -> bool {
) )
} }
/// The chords that begin an incremental search: `C-s` / `C-r` (literal) /// A command chord (Q#GC1): a `Char` / `Enter` / `Tab` with `Ctrl` or
/// and `C-M-s` / `C-M-r` (regex, Q#RX5). Forwarded even when idle (they /// `Alt` held. These drive the daemon keymap (motion like `C-a`,
/// are otherwise withheld as command chords by [`should_forward_key`]) /// commands like `M-f` / `C-x C-s`, isearch `C-s`, clipboard `M-w`,
/// so a search can start; once it is running every key round-trips via /// `M-x`, …) and are forwarded to it, subsuming the old per-feature
/// the intercept path (including `M-r`, the regex toggle). /// allowlists. `Char + Ctrl/Alt` is the exact set `should_forward_key`
fn is_search_entry_chord(key: ProtocolKey, mods: Modifiers) -> bool { /// withholds; motion / `Backspace` / `Delete` keep their own path, and
matches!(key, ProtocolKey::Char('s' | 'r')) /// `Meta`/`Super`-only chords (no `Ctrl`/`Alt`) are left to the OS.
&& (mods == Modifiers::CTRL || mods == Modifiers::CTRL | Modifiers::ALT) /// `Ctrl-V` is intercepted for OS paste before this is reached.
} fn is_command_chord(key: ProtocolKey, mods: Modifiers) -> bool {
/// 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!( matches!(
(key, mods), key,
(ProtocolKey::Char('w'), Modifiers::ALT | Modifiers::CTRL) ProtocolKey::Char(_) | ProtocolKey::Enter | ProtocolKey::Tab
| (ProtocolKey::Char('y'), Modifiers::CTRL) ) && (mods.contains(Modifiers::CTRL) || mods.contains(Modifiers::ALT))
)
}
/// The chords that open a minibuffer prompt or enter a prefix (Q#MB1):
/// `M-x` (execute-command) and the `C-x` prefix. Forwarded though
/// otherwise withheld; once the daemon enters the minibuffer / a pending
/// prefix, `dispatch_idle` goes false and the intercept gate round-trips
/// the rest. General Emacs-chord forwarding stays a separate thread.
fn is_minibuffer_open_chord(key: ProtocolKey, mods: Modifiers) -> bool {
matches!(
(key, mods),
(ProtocolKey::Char('x'), Modifiers::ALT | Modifiers::CTRL)
)
} }
fn is_plain_text_modifiers(mods: Modifiers) -> bool { fn is_plain_text_modifiers(mods: Modifiers) -> bool {
@ -5869,38 +5820,36 @@ mod tests {
} }
#[test] #[test]
fn search_entry_chord_is_ctrl_or_ctrl_alt_s_r() { fn command_chord_forwards_char_chords_with_ctrl_or_alt() {
// C-s / C-r (literal) and C-M-s / C-M-r (regex, Q#RX5) start a // Q#GC1 — any Char/Enter/Tab with Ctrl or Alt is a command chord,
// search — forwarded even though `should_forward_key` withholds // forwarded to the daemon keymap. This subsumes the old allowlists
// them as command chords. // (isearch C-s/C-r/C-M-s, clipboard M-w/C-w/C-y, M-x) plus the
// rest of the keymap (C-a, C-e, M-f, C-x, …).
let ctrl_alt = Modifiers::CTRL | Modifiers::ALT; let ctrl_alt = Modifiers::CTRL | Modifiers::ALT;
assert!(is_search_entry_chord( for (key, mods) in [
ProtocolKey::Char('s'), (ProtocolKey::Char('s'), Modifiers::CTRL), // isearch
Modifiers::CTRL (ProtocolKey::Char('s'), ctrl_alt), // regex isearch
)); (ProtocolKey::Char('w'), Modifiers::ALT), // copy
assert!(is_search_entry_chord( (ProtocolKey::Char('y'), Modifiers::CTRL), // yank
ProtocolKey::Char('r'), (ProtocolKey::Char('x'), Modifiers::ALT), // M-x
Modifiers::CTRL (ProtocolKey::Char('x'), Modifiers::CTRL), // C-x prefix
)); (ProtocolKey::Char('a'), Modifiers::CTRL), // line-start (was withheld)
assert!(is_search_entry_chord(ProtocolKey::Char('s'), ctrl_alt)); (ProtocolKey::Char('f'), Modifiers::ALT), // forward-word
assert!(is_search_entry_chord(ProtocolKey::Char('r'), ctrl_alt)); (ProtocolKey::Enter, Modifiers::CTRL),
] {
assert!( assert!(
!should_forward_key(ProtocolKey::Char('s'), Modifiers::CTRL), is_command_chord(key, mods),
"C-s is otherwise a withheld chord; the search-entry path is what forwards it" "{key:?}+{mods:?} is a command chord"
); );
// Other Ctrl chords, and s/r without Ctrl, are not entry chords. // The general forwarding is *why* these move; `should_forward_key`
assert!(!is_search_entry_chord( // still withholds them (they're caught before it).
ProtocolKey::Char('x'), assert!(!should_forward_key(key, mods));
Modifiers::CTRL }
)); // Plain text, and Meta/Super-only chords, are not command chords.
assert!(!is_search_entry_chord( assert!(!is_command_chord(ProtocolKey::Char('a'), Modifiers::NONE));
ProtocolKey::Char('s'), assert!(!is_command_chord(ProtocolKey::Char('c'), Modifiers::META));
Modifiers::NONE // Motion isn't routed here (it keeps its own defer-aware path).
)); assert!(!is_command_chord(ProtocolKey::Left, Modifiers::CTRL));
assert!(!is_search_entry_chord(
ProtocolKey::Char('s'),
Modifiers::ALT
));
} }
#[test] #[test]