diff --git a/docs/gpu-chord-forwarding-framing.md b/docs/gpu-chord-forwarding-framing.md new file mode 100644 index 0000000..4a415ce --- /dev/null +++ b/docs/gpu-chord-forwarding-framing.md @@ -0,0 +1,90 @@ +# GPU general chord forwarding — framing + as-built + +pmacs-gpu withheld command chords (`Ctrl`/`Alt` + a character) from the +daemon by default, forwarding only a hand-maintained allowlist: +`is_search_entry_chord` (`C-s`/`C-r`/…), `is_clipboard_chord` +(`M-w`/`C-w`/`C-y`), `is_minibuffer_open_chord` (`M-x`/`C-x`). So most of +the Emacs keymap — `C-a`, `C-e`, `C-k`, `C-/`, `M-f`, `M-d`, … — was +unreachable in the GUI, and every arc that added a chorded command had to +bolt on another allowlist entry. + +**The blocker was gone.** The reason the GUI withheld command chords was +recorded in the code: *"they drive commands and minibuffer flows the GUI +can't render or interact with yet."* The minibuffer now renders (Q#MB1), +so command chords can forward and the three allowlists collapse into one +general rule. This arc was the payoff of the minibuffer arc — a net +simplification (three predicates + three near-identical handler blocks → +one; **−51 lines**), and **no protocol change** (pure GPU input routing; +the wire and daemon are untouched, so only pmacs-gpu rebuilds). + +## The rule (Q#GC1) + +**Forward any command chord** — `Char`/`Enter`/`Tab` with `Ctrl` or +`Alt` held — to the daemon, exactly as the allowlists did (mark the +optimistic cursor stale, `send_key`, no optimistic local flip: the +search-entry precedent, so a forwarded chord that changes no daemon +state can't wedge the intercept gate). The daemon's keymap resolves it — +the same path the TUI already drives, which forwards *everything*. Once a +forwarded chord opens a prompt / enters a prefix, `dispatch_idle` flips +false and the intercept gate round-trips the rest. + +`is_command_chord` (`matches!(key, Char(_) | Enter | Tab) && +(mods.contains(CTRL) || mods.contains(ALT))`) replaces +`is_search_entry_chord` / `is_clipboard_chord` / +`is_minibuffer_open_chord` — all three were just `Char + Ctrl/Alt`, +subsumed. + +## What stays local (Q#GC2) + +- **`Ctrl-V`** — OS paste via `arboard` (Q#CM6), intercepted *before* + the command-chord block. It never reaches the daemon, so in the GUI + `C-v` pastes rather than running the keymap's `cursor.page-down` — + a deliberate, pre-existing GUI divergence (the modern paste + convention); Emacs page-down is `M-v` / the `PageDown` key. +- **`Escape`** — cancels an active intercept, else quits the window (a + GUI affordance). Not a command chord, so the rule doesn't touch it. +- **`Meta`/`Super`-only chords** (`Cmd-C`, `Super-…`) — *not* command + chords (no `Ctrl`/`Alt`), so they fall through to `should_forward_key`, + which withholds them, leaving OS/WM shortcuts (`Cmd-Q`, `Cmd-C` on + macOS) to the platform rather than consuming them. + +## What didn't change (Q#GC3) + +- **`should_forward_key` is untouched.** Command chords are caught by + the new block *before* it, so it still withholds `Ctrl`/`Alt` chords + (they just never reach it) — its test still holds unchanged. +- **Motion / `Backspace` / `Delete`** keep their existing path (forwarded + with any modifiers, through the optimistic/defer logic) — the new + block matches only `Char`/`Enter`/`Tab`, so `C-Left` (word motion) + still gets its defer-aware handling, not immediate send. +- **The optimistic text path** is unchanged: a command chord's + `optimistic_crdt_insert` returns `None` (it requires plain modifiers), + so command chords never optimistic-apply — they round-trip, as before. + +## As-built + +Landed as framed — a `is_command_chord` predicate + one handler block +replacing the three allowlist blocks, the three helpers removed, the +"withheld" comment refreshed, and the `search_entry_chord` test swapped +for a `command_chord` test (which also asserts the old allowlist chords +plus `C-a` / `M-f` / `C-Enter` are command chords, and that plain text / +`Meta`-only / motion are not). No divergences from the framing; the one +judgment call baked in from the start — leaving `Meta`/`Super` to the OS +and `Ctrl-V` to local paste — is recorded in Q#GC2. + +## Categorical bets (held) + +- **The daemon already handles arbitrary chords.** The TUI forwards every + key; the daemon's keymap + `Action::Unbound` path handle bound and + unbound chords gracefully. Forwarding command chords just made the GPU + behave like the TUI for them — no daemon-side change. +- **`Char + Ctrl/Alt` was the right cut.** It captures exactly the + withheld chords, leaves motion's defer path alone, and leaves + `Meta`/`Super` to the OS. + +## Deferred (named, not silently dropped) + +- Rebindable / configurable local exceptions (today `Ctrl-V` and + `Escape` are hard-coded). +- Forwarding `Meta`/`Super` chords for users who bind them in the daemon + (currently withheld to protect OS shortcuts). diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 44dbf2f..5d5fe31 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -832,61 +832,32 @@ impl ApplicationHandler for App { return; } - // Idle: C-s / C-r begin an incremental search. They are - // otherwise withheld as command chords; forward them so - // the search can start. The daemon then flips the - // intercept gate (DispatchIdle / SearchPrompt) one - // round-trip later, after which every key routes into the - // search — no optimistic local flip, so a C-s that (via - // rebinding) doesn't start a search can never wedge the - // gate against the daemon's authoritative state. - if is_search_entry_chord(pkey, pmods) { + // Idle: forward any command chord (Char/Enter/Tab with + // Ctrl or Alt) to the daemon (Q#GC1). These drive the + // keymap — `C-a`, `M-f`, `C-x C-s`, isearch/clipboard/M-x, + // … — the same path the TUI forwards everything through. + // The GUI no longer withholds them (the minibuffer / prompt + // flows they open now render, Q#MB1). Once a forwarded + // chord opens a prompt or enters a prefix, `dispatch_idle` + // flips false and the intercept gate round-trips the rest — + // 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() { state.mark_cursor_stale_after_round_trip(); } if let Err(e) = client.send_key(pkey, pmods) { - eprintln!("pmacs-gpu: send_key (search entry) 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}"); + eprintln!("pmacs-gpu: send_key (command chord) 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 - // minibuffer flows the GUI can't render or interact with - // yet (a later session adds GUI minibuffer + chords). + // (Char / Backspace / Enter / Delete / Tab). Command chords + // are handled above; Meta/Super-only chords fall through + // here and are withheld, leaving OS/WM shortcuts (Cmd-Q, + // Cmd-C) to the platform. if !should_forward_key(pkey, pmods) { 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) -/// and `C-M-s` / `C-M-r` (regex, Q#RX5). Forwarded even when idle (they -/// are otherwise withheld as command chords by [`should_forward_key`]) -/// so a search can start; once it is running every key round-trips via -/// the intercept path (including `M-r`, the regex toggle). -fn is_search_entry_chord(key: ProtocolKey, mods: Modifiers) -> bool { - matches!(key, ProtocolKey::Char('s' | 'r')) - && (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 { +/// A command chord (Q#GC1): a `Char` / `Enter` / `Tab` with `Ctrl` or +/// `Alt` held. These drive the daemon keymap (motion like `C-a`, +/// commands like `M-f` / `C-x C-s`, isearch `C-s`, clipboard `M-w`, +/// `M-x`, …) and are forwarded to it, subsuming the old per-feature +/// allowlists. `Char + Ctrl/Alt` is the exact set `should_forward_key` +/// withholds; motion / `Backspace` / `Delete` keep their own path, and +/// `Meta`/`Super`-only chords (no `Ctrl`/`Alt`) are left to the OS. +/// `Ctrl-V` is intercepted for OS paste before this is reached. +fn is_command_chord(key: ProtocolKey, mods: Modifiers) -> bool { matches!( - (key, mods), - (ProtocolKey::Char('w'), Modifiers::ALT | Modifiers::CTRL) - | (ProtocolKey::Char('y'), Modifiers::CTRL) - ) -} - -/// 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) - ) + key, + ProtocolKey::Char(_) | ProtocolKey::Enter | ProtocolKey::Tab + ) && (mods.contains(Modifiers::CTRL) || mods.contains(Modifiers::ALT)) } fn is_plain_text_modifiers(mods: Modifiers) -> bool { @@ -5869,38 +5820,36 @@ mod tests { } #[test] - fn search_entry_chord_is_ctrl_or_ctrl_alt_s_r() { - // C-s / C-r (literal) and C-M-s / C-M-r (regex, Q#RX5) start a - // search — forwarded even though `should_forward_key` withholds - // them as command chords. + fn command_chord_forwards_char_chords_with_ctrl_or_alt() { + // Q#GC1 — any Char/Enter/Tab with Ctrl or Alt is a command chord, + // forwarded to the daemon keymap. This subsumes the old allowlists + // (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; - assert!(is_search_entry_chord( - ProtocolKey::Char('s'), - Modifiers::CTRL - )); - assert!(is_search_entry_chord( - ProtocolKey::Char('r'), - Modifiers::CTRL - )); - assert!(is_search_entry_chord(ProtocolKey::Char('s'), ctrl_alt)); - assert!(is_search_entry_chord(ProtocolKey::Char('r'), ctrl_alt)); - assert!( - !should_forward_key(ProtocolKey::Char('s'), Modifiers::CTRL), - "C-s is otherwise a withheld chord; the search-entry path is what forwards it" - ); - // Other Ctrl chords, and s/r without Ctrl, are not entry chords. - assert!(!is_search_entry_chord( - ProtocolKey::Char('x'), - Modifiers::CTRL - )); - assert!(!is_search_entry_chord( - ProtocolKey::Char('s'), - Modifiers::NONE - )); - assert!(!is_search_entry_chord( - ProtocolKey::Char('s'), - Modifiers::ALT - )); + for (key, mods) in [ + (ProtocolKey::Char('s'), Modifiers::CTRL), // isearch + (ProtocolKey::Char('s'), ctrl_alt), // regex isearch + (ProtocolKey::Char('w'), Modifiers::ALT), // copy + (ProtocolKey::Char('y'), Modifiers::CTRL), // yank + (ProtocolKey::Char('x'), Modifiers::ALT), // M-x + (ProtocolKey::Char('x'), Modifiers::CTRL), // C-x prefix + (ProtocolKey::Char('a'), Modifiers::CTRL), // line-start (was withheld) + (ProtocolKey::Char('f'), Modifiers::ALT), // forward-word + (ProtocolKey::Enter, Modifiers::CTRL), + ] { + assert!( + is_command_chord(key, mods), + "{key:?}+{mods:?} is a command chord" + ); + // The general forwarding is *why* these move; `should_forward_key` + // still withholds them (they're caught before it). + assert!(!should_forward_key(key, mods)); + } + // Plain text, and Meta/Super-only chords, are not command chords. + assert!(!is_command_chord(ProtocolKey::Char('a'), Modifiers::NONE)); + assert!(!is_command_chord(ProtocolKey::Char('c'), Modifiers::META)); + // Motion isn't routed here (it keeps its own defer-aware path). + assert!(!is_command_chord(ProtocolKey::Left, Modifiers::CTRL)); } #[test]