Merge pull request #74 from levineuwirth/session-gpu-chord-forwarding
GPU: general command-chord forwarding (no protocol change)
This commit is contained in:
commit
e5d77816b5
|
|
@ -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).
|
||||
|
|
@ -832,61 +832,32 @@ impl ApplicationHandler<AppEvent> 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]
|
||||
|
|
|
|||
Loading…
Reference in New Issue