feat(gpu): 1-pre --- the keyboard family, and a second structural exception

The 194-line arm moves to `App::apply_keyboard`, verified byte-identical
against `HEAD~1` modulo exactly two named conversions: the press guard
becomes the router's decision, and `event_loop.exit()` becomes a
returned `EventOutcome::Exit`.

THE OUTCOME RETURN IS THE LOAD-BEARING PART. The keyboard arm was the
second caller of `event_loop.exit()` --- an idle Escape is a local quit
--- so a body that owned the exit would have needed an
`&ActiveEventLoop`, and `ActiveEventLoop` is exactly what cannot exist
in a test. Returning the decision keeps `event_loop.exit()` in one
place, `window_event`, and leaves every body reachable in principle.
Both call sites are now inside `window_event` and nowhere else, which
is checkable by grep. Stage 1a's A4 deletes the Escape branch, at which
point `EventOutcome` has one variant and should go; the branch carries
a comment saying so.

A SECOND ACCEPTED STRUCTURAL EXCEPTION, ALONGSIDE P3, and it is winit's
rather than this seam's: `KeyEvent` carries a `pub(crate)
platform_specific` field, so NO `WindowEvent::KeyboardInput` CAN BE
CONSTRUCTED OUTSIDE WINIT and no headless test can feed one. Checked in
winit-0.30.13/src/event.rs, not assumed.

The response is to shrink what the exception covers rather than to
accept it whole. The family's only real decision --- press acted on,
release claimed and discarded --- is factored into
`route_key_action(ElementState) -> KeyAction`, which takes a
constructible argument and is tested directly. What stays unwitnessed
is one pattern arm containing a match and a call, with no logic in it.

The exception does NOT extend to the pointer families: winit provides
`DeviceId::dummy()` for exactly this purpose ("useful for unit
testing") and `CursorMoved`/`MouseInput`/`MouseWheel` are constructible.
Checked before writing the exception down, so its scope is measured.

`Release` is a route and not a `None`. The family CLAIMS a key-up and
drops it, which is a different fact from no family claiming the event;
collapsing them would hide the drop the moment a slice wants key-up
semantics. `window_event` merges the two arms because both are today
nothing to do, and says so.

Evidence --- 8 rows, 3 further mutations, each failing exactly one row:

  M9  a release treated as a press -> the key-action row
  M10 a press treated as a release -> the key-action row
  M11 harness records outbound only -> the transcript row

`route_one` deliberately calls `route_event` and not the harness, so
the transcript row stays P2's sole owner and M11 stays surgical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
Levi Neuwirth 2026-08-11 23:36:30 +02:00
parent 7f0f9db98c
commit 0705564ae4
No known key found for this signature in database
1 changed files with 341 additions and 230 deletions

View File

@ -55,7 +55,7 @@ use pmacs_protocol::{
use unicode_width::UnicodeWidthChar;
use wgpu::MultisampleState;
use winit::application::ApplicationHandler;
use winit::event::{ElementState, WindowEvent};
use winit::event::{ElementState, KeyEvent, WindowEvent};
use winit::event_loop::{ActiveEventLoop, EventLoop};
use winit::keyboard::{Key, NamedKey};
use winit::window::{Window, WindowId};
@ -2711,6 +2711,202 @@ impl App {
state.render();
}
}
/// Perform [`KeyboardRoute::Press`]. The router has already
/// discarded key-ups, so `key` is always a press.
#[allow(clippy::too_many_lines)] // one linear key pipeline; splitting hides the order.
fn apply_keyboard(&mut self, key: &KeyEvent) -> EventOutcome {
// While the daemon is intercepting keystrokes — an active
// incremental search (Q#SR5), or a minibuffer / pending
// prefix — every key belongs to its handler, not the
// buffer. The GUI round-trips them all and never
// optimistic-applies (that would edit the document
// mid-search).
let intercept = self
.state
.as_ref()
.is_some_and(State::daemon_intercepts_keys);
// Arc 1a Q#C6 — the completion popup is NON-modal, so it
// never flips the intercept gate (typing stays
// optimistic; the daemon's after-edit refresh re-ships
// the popup). Only the keys whose *default GPU handling
// is wrong under a popup* need this flag: Esc (below,
// else it's the local quit) and RET/TAB (the optimistic
// gate further down, else they'd insert instead of
// accept). C-n/C-p/C-g already round-trip as command
// chords, Up/Down as forwarded motion keys — the daemon's
// completion shadow handles all of them.
let completion_open = self
.state
.as_ref()
.is_some_and(State::completion_open_for_current_buffer);
// Escape cancels an active intercept (e.g. a running
// search) or dismisses the completion popup; otherwise it
// stays the local quit.
if matches!(key.logical_key, Key::Named(NamedKey::Escape)) {
if intercept || completion_open {
if let Some(client) = self.attach_client.as_ref()
&& let Err(e) = client.send_key(ProtocolKey::Escape, Modifiers::NONE)
{
eprintln!("pmacs-gpu: send Escape (cancel) failed: {e}");
}
} else {
// Q#S1-1 / A4 — the local quit, unchanged here and
// deleted by Stage 1a: an idle Escape must reach the
// daemon. `window_event` performs the exit; this is
// the only reason a body needs an outcome at all.
return EventOutcome::Exit;
}
return EventOutcome::Continue;
}
let Some((pkey, mut pmods)) = translate_key(&key.logical_key, self.modifiers) else {
return EventOutcome::Continue;
};
// AltGr / international text (audit F-004). winit reports
// the text a keypress produces; when a keypress yields
// printable text *while both Ctrl and Alt* are held — the
// AltGr signature on Windows (LCtrl+RAlt) — it's text
// input, not a command chord. Strip those modifiers (keep
// Shift) so it inserts (through the plain-text path, or the
// daemon's SelfInsert while a prompt is open) instead of
// being routed to the keymap. Alt alone is left intact so
// macOS Option-as-Meta still reaches the keymap; on layouts
// where AltGr isn't Ctrl+Alt this is a no-op.
if matches!(pkey, ProtocolKey::Char(_)) && is_layout_text(key.text.as_deref(), pmods) {
pmods = if pmods.contains(Modifiers::SHIFT) {
Modifiers::SHIFT
} else {
Modifiers::NONE
};
}
// 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 EventOutcome::Continue;
}
let Some(client) = self.attach_client.as_ref() else {
return EventOutcome::Continue;
};
// Intercept path: round-trip every key into the daemon's
// active handler (search query / step / accept / cancel).
if intercept {
if let Some(state) = self.state.as_mut() {
state.mark_cursor_stale_after_round_trip();
}
if debug_input() {
eprintln!("pmacs-gpu send_key (intercepted): {pkey:?} mods={pmods:?}");
}
if let Err(e) = client.send_key(pkey, pmods) {
eprintln!("pmacs-gpu: send_key (intercepted) failed: {e}");
}
return EventOutcome::Continue;
}
// 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 (command chord) failed: {e}");
}
return EventOutcome::Continue;
}
// Session B2 forwards cursor motion + plain text editing
// (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 EventOutcome::Continue;
}
// Arc 1a Q#C6 — with the popup open, RET and TAB mean
// "accept", not "insert \n / \t": skip the optimistic
// path so they round-trip into the daemon's
// dispatch_completion_key. Everything else stays
// optimistic.
let completion_takes_key =
completion_open && matches!(pkey, ProtocolKey::Enter | ProtocolKey::Tab);
if !completion_takes_key
&& let Some(op) = self.state.as_mut().and_then(|state| {
state
.optimistic_crdt_insert(pkey, pmods)
.or_else(|| state.optimistic_crdt_delete(pkey, pmods))
})
{
if debug_input() {
eprintln!(
"pmacs-gpu send_crdt: key={pkey:?} buf={:?} bytes={}B",
op.buffer_id,
op.op.bytes.len()
);
}
if let Err(e) = client.send_crdt_op(op.buffer_id, op.op) {
eprintln!("pmacs-gpu: send_crdt_op failed: {e}");
}
// An optimistic edit near the viewport edge can
// scroll (a wrap-inducing insert, a Backspace
// above the top); re-declare the scoped viewport
// so the producer styles the newly visible lines.
if let Some(vp) = op.viewport
&& let Err(e) = client.send_viewport(vp.buffer_id, vp.visible, vp.generation)
{
eprintln!("pmacs-gpu: send Viewport failed: {e}");
}
return EventOutcome::Continue;
}
if let Some(state) = self.state.as_mut() {
if state.defer_round_trip_key_if_needed(pkey, pmods) {
if debug_input() {
eprintln!(
"pmacs-gpu defer_key: {pkey:?} mods={pmods:?} \
pending optimistic cursor"
);
}
return EventOutcome::Continue;
}
state.mark_cursor_stale_after_round_trip();
}
if debug_input() {
eprintln!("pmacs-gpu send_key: {pkey:?} mods={pmods:?}");
}
if let Err(e) = client.send_key(pkey, pmods) {
eprintln!("pmacs-gpu: send_key failed: {e}");
}
EventOutcome::Continue
}
}
/// GUI Stage 1-pre — the input seam.
@ -2733,10 +2929,15 @@ impl App {
/// all**, so a harness recording only protocol traffic would leave them
/// invisible; that is why a route names its local effect and the harness
/// records routes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Route {
#[derive(Debug, Clone, Copy, PartialEq)]
enum Route<'a> {
/// The lifecycle family — see [`route_lifecycle`].
Lifecycle(LifecycleRoute),
/// The keyboard family — see [`route_keyboard`].
Keyboard {
action: KeyAction,
key: &'a KeyEvent,
},
/// No extracted family claims this event. During 1-pre that covers
/// both the families still inline in `window_event` and the events
/// pmacs genuinely ignores; when the last family moves here it means
@ -2744,6 +2945,21 @@ enum Route {
Unrouted,
}
/// What the event loop must do once a family's body has run. Only the
/// keyboard family produces anything but `Continue` today: an idle
/// Escape is a local quit. Returning the decision rather than taking an
/// `&ActiveEventLoop` is what keeps every body reachable from a test —
/// `event_loop.exit()` is called in exactly one place, `window_event`.
///
/// Stage 1a's A4 deletes that branch (an idle Escape must reach the
/// daemon and never exit), at which point this type has one variant and
/// should go.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EventOutcome {
Continue,
Exit,
}
/// The lifecycle family: events about the **window itself** — closing,
/// resizing, repainting — rather than about a gesture aimed into the
/// document. `ModifiersChanged` is grouped here as the one exception,
@ -2769,12 +2985,28 @@ enum LifecycleRoute {
Redraw,
}
/// The keyboard family's whole decision. `Release` is a route rather
/// than an absence: the family **claims** a key-up and drops it, which
/// is a different fact from no family claiming the event, and
/// conflating the two would hide the drop the moment a later slice
/// wants key-up semantics.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum KeyAction {
/// A key-down — the only keyboard state pmacs acts on.
Press,
/// A key-up. Claimed and deliberately discarded.
Release,
}
/// Decide what `window_event` should do with an event, from the event
/// alone. See [`Route`].
fn route_event(event: &WindowEvent) -> Route {
fn route_event(event: &WindowEvent) -> Route<'_> {
if let Some(lifecycle) = route_lifecycle(event) {
return Route::Lifecycle(lifecycle);
}
if let Some((action, key)) = route_keyboard(event) {
return Route::Keyboard { action, key };
}
Route::Unrouted
}
@ -2793,6 +3025,34 @@ fn route_lifecycle(event: &WindowEvent) -> Option<LifecycleRoute> {
}
}
/// The keyboard family's claim. `None` means some other family owns the
/// event.
///
/// **A SECOND ACCEPTED STRUCTURAL EXCEPTION, alongside P3.** This
/// function's own pattern arm is unwitnessable: `KeyEvent` carries a
/// `pub(crate) platform_specific` field, so **no `WindowEvent::KeyboardInput`
/// can be constructed outside winit** and no headless test can feed one.
/// The limitation is winit's and not this seam's — it is why the arm is
/// kept to a pattern and a call, with the family's only real decision
/// factored into [`route_key_action`], which takes an `ElementState` and
/// is tested directly. The pointer families have no such problem:
/// `DeviceId::dummy()` is provided by winit for exactly this purpose,
/// and their events are constructible.
fn route_keyboard(event: &WindowEvent) -> Option<(KeyAction, &KeyEvent)> {
match event {
WindowEvent::KeyboardInput { event: key, .. } => Some((route_key_action(key.state), key)),
_ => None,
}
}
/// Press or release — see [`KeyAction`].
fn route_key_action(state: ElementState) -> KeyAction {
match state {
ElementState::Pressed => KeyAction::Press,
ElementState::Released => KeyAction::Release,
}
}
/// GUI Stage 1-pre — the headless routing harness (P2).
///
/// It feeds `WindowEvent`s through the production [`route_event`] and
@ -2812,19 +3072,19 @@ fn route_lifecycle(event: &WindowEvent) -> Option<LifecycleRoute> {
/// delegation.
#[cfg(test)]
#[derive(Debug, Default)]
struct RoutingHarness {
transcript: Vec<Route>,
struct RoutingHarness<'a> {
transcript: Vec<Route<'a>>,
}
#[cfg(test)]
impl RoutingHarness {
fn feed(&mut self, event: &WindowEvent) -> Route {
impl<'a> RoutingHarness<'a> {
fn feed(&mut self, event: &'a WindowEvent) -> Route<'a> {
let route = route_event(event);
self.transcript.push(route);
route
}
fn transcript(&self) -> &[Route] {
fn transcript(&self) -> &[Route<'a>] {
&self.transcript
}
}
@ -2839,32 +3099,43 @@ mod input_routing_tests {
WindowEvent::ModifiersChanged(state.into())
}
/// The per-variant rows below drive [`route_event`] directly and the
/// transcript row drives the harness. That split is deliberate: it
/// leaves the transcript row as P2's sole owner, so a harness that
/// stopped recording an effect fails exactly one row instead of
/// every row.
///
/// Events are bound to locals rather than passed as temporaries
/// because a `Route` borrows the event it came from — the keyboard
/// variant carries a `&KeyEvent`.
fn route_one(event: &WindowEvent) -> Route<'_> {
route_event(event)
}
/// P1 — `CloseRequested`. It sends the daemon nothing and its whole
/// effect is local, so this row exists only because the route names
/// the effect.
#[test]
fn close_requested_routes_to_exit() {
let mut harness = RoutingHarness::default();
assert_eq!(
harness.feed(&WindowEvent::CloseRequested),
Route::Lifecycle(LifecycleRoute::Exit)
);
let event = WindowEvent::CloseRequested;
assert_eq!(route_one(&event), Route::Lifecycle(LifecycleRoute::Exit));
}
/// P1 — `ModifiersChanged` carries the unwrapped state, which is the
/// mutation `window_event` performs.
#[test]
fn modifiers_changed_routes_the_new_state() {
let mut harness = RoutingHarness::default();
let mods = ModifiersState::CONTROL | ModifiersState::ALT;
let held = modifiers_changed(mods);
assert_eq!(
harness.feed(&modifiers_changed(mods)),
route_one(&held),
Route::Lifecycle(LifecycleRoute::Modifiers(mods))
);
// An empty state is a real transition (every modifier released),
// not an absent one.
let released = modifiers_changed(ModifiersState::empty());
assert_eq!(
harness.feed(&modifiers_changed(ModifiersState::empty())),
route_one(&released),
Route::Lifecycle(LifecycleRoute::Modifiers(ModifiersState::empty()))
);
}
@ -2873,9 +3144,9 @@ mod input_routing_tests {
/// already non-zero.
#[test]
fn resized_routes_the_new_extent() {
let mut harness = RoutingHarness::default();
let event = WindowEvent::Resized(PhysicalSize::new(1280, 720));
assert_eq!(
harness.feed(&WindowEvent::Resized(PhysicalSize::new(1280, 720))),
route_one(&event),
Route::Lifecycle(LifecycleRoute::Resize {
width: 1280,
height: 720,
@ -2888,23 +3159,25 @@ mod input_routing_tests {
/// on one axis only still keeps the other.
#[test]
fn a_zero_extent_resize_clamps_per_axis() {
let mut harness = RoutingHarness::default();
let collapsed = WindowEvent::Resized(PhysicalSize::new(0, 0));
assert_eq!(
harness.feed(&WindowEvent::Resized(PhysicalSize::new(0, 0))),
route_one(&collapsed),
Route::Lifecycle(LifecycleRoute::Resize {
width: 1,
height: 1,
})
);
let no_width = WindowEvent::Resized(PhysicalSize::new(0, 720));
assert_eq!(
harness.feed(&WindowEvent::Resized(PhysicalSize::new(0, 720))),
route_one(&no_width),
Route::Lifecycle(LifecycleRoute::Resize {
width: 1,
height: 720,
})
);
let no_height = WindowEvent::Resized(PhysicalSize::new(1280, 0));
assert_eq!(
harness.feed(&WindowEvent::Resized(PhysicalSize::new(1280, 0))),
route_one(&no_height),
Route::Lifecycle(LifecycleRoute::Resize {
width: 1280,
height: 1,
@ -2917,11 +3190,22 @@ mod input_routing_tests {
/// outbound traffic.
#[test]
fn redraw_requested_routes_to_redraw() {
let mut harness = RoutingHarness::default();
assert_eq!(
harness.feed(&WindowEvent::RedrawRequested),
Route::Lifecycle(LifecycleRoute::Redraw)
);
let event = WindowEvent::RedrawRequested;
assert_eq!(route_one(&event), Route::Lifecycle(LifecycleRoute::Redraw));
}
/// P1, keyboard — the family's whole decision.
///
/// It is driven through [`route_key_action`] rather than the
/// harness, and that is the accepted exception documented on
/// [`route_keyboard`]: `KeyEvent` has a `pub(crate)` field, so no
/// `WindowEvent::KeyboardInput` exists that a test can build. What
/// remains unwitnessed is one pattern arm with no logic in it; the
/// decision itself is here.
#[test]
fn a_press_is_acted_on_and_a_release_is_discarded() {
assert_eq!(route_key_action(ElementState::Pressed), KeyAction::Press);
assert_eq!(route_key_action(ElementState::Released), KeyAction::Release);
}
/// An event no family claims. `Occluded` is chosen because pmacs has
@ -2929,8 +3213,8 @@ mod input_routing_tests {
/// would also read `Unrouted` today and stop doing so when it moves.
#[test]
fn an_unclaimed_event_is_unrouted() {
let mut harness = RoutingHarness::default();
assert_eq!(harness.feed(&WindowEvent::Occluded(true)), Route::Unrouted);
let event = WindowEvent::Occluded(true);
assert_eq!(route_one(&event), Route::Unrouted);
}
/// P2 — the harness records a transcript, and the transcript
@ -2940,12 +3224,17 @@ mod input_routing_tests {
/// harness built on protocol traffic alone.
#[test]
fn the_harness_records_each_local_effect_in_order() {
let events = [
WindowEvent::Resized(PhysicalSize::new(800, 600)),
modifiers_changed(ModifiersState::SHIFT),
WindowEvent::RedrawRequested,
WindowEvent::Occluded(false),
WindowEvent::CloseRequested,
];
let mut harness = RoutingHarness::default();
harness.feed(&WindowEvent::Resized(PhysicalSize::new(800, 600)));
harness.feed(&modifiers_changed(ModifiersState::SHIFT));
harness.feed(&WindowEvent::RedrawRequested);
harness.feed(&WindowEvent::Occluded(false));
harness.feed(&WindowEvent::CloseRequested);
for event in &events {
harness.feed(event);
}
assert_eq!(
harness.transcript(),
&[
@ -3014,200 +3303,6 @@ impl ApplicationHandler<AppEvent> for App {
#[allow(clippy::too_many_lines)] // linear per-event dispatch; splitting hides the input flow.
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
match event {
WindowEvent::KeyboardInput { event: key, .. } => {
if key.state != ElementState::Pressed {
return;
}
// While the daemon is intercepting keystrokes — an active
// incremental search (Q#SR5), or a minibuffer / pending
// prefix — every key belongs to its handler, not the
// buffer. The GUI round-trips them all and never
// optimistic-applies (that would edit the document
// mid-search).
let intercept = self
.state
.as_ref()
.is_some_and(State::daemon_intercepts_keys);
// Arc 1a Q#C6 — the completion popup is NON-modal, so it
// never flips the intercept gate (typing stays
// optimistic; the daemon's after-edit refresh re-ships
// the popup). Only the keys whose *default GPU handling
// is wrong under a popup* need this flag: Esc (below,
// else it's the local quit) and RET/TAB (the optimistic
// gate further down, else they'd insert instead of
// accept). C-n/C-p/C-g already round-trip as command
// chords, Up/Down as forwarded motion keys — the daemon's
// completion shadow handles all of them.
let completion_open = self
.state
.as_ref()
.is_some_and(State::completion_open_for_current_buffer);
// Escape cancels an active intercept (e.g. a running
// search) or dismisses the completion popup; otherwise it
// stays the local quit.
if matches!(key.logical_key, Key::Named(NamedKey::Escape)) {
if intercept || completion_open {
if let Some(client) = self.attach_client.as_ref()
&& let Err(e) = client.send_key(ProtocolKey::Escape, Modifiers::NONE)
{
eprintln!("pmacs-gpu: send Escape (cancel) failed: {e}");
}
} else {
event_loop.exit();
}
return;
}
let Some((pkey, mut pmods)) = translate_key(&key.logical_key, self.modifiers)
else {
return;
};
// AltGr / international text (audit F-004). winit reports
// the text a keypress produces; when a keypress yields
// printable text *while both Ctrl and Alt* are held — the
// AltGr signature on Windows (LCtrl+RAlt) — it's text
// input, not a command chord. Strip those modifiers (keep
// Shift) so it inserts (through the plain-text path, or the
// daemon's SelfInsert while a prompt is open) instead of
// being routed to the keymap. Alt alone is left intact so
// macOS Option-as-Meta still reaches the keymap; on layouts
// where AltGr isn't Ctrl+Alt this is a no-op.
if matches!(pkey, ProtocolKey::Char(_))
&& is_layout_text(key.text.as_deref(), pmods)
{
pmods = if pmods.contains(Modifiers::SHIFT) {
Modifiers::SHIFT
} else {
Modifiers::NONE
};
}
// 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;
};
// Intercept path: round-trip every key into the daemon's
// active handler (search query / step / accept / cancel).
if intercept {
if let Some(state) = self.state.as_mut() {
state.mark_cursor_stale_after_round_trip();
}
if debug_input() {
eprintln!("pmacs-gpu send_key (intercepted): {pkey:?} mods={pmods:?}");
}
if let Err(e) = client.send_key(pkey, pmods) {
eprintln!("pmacs-gpu: send_key (intercepted) failed: {e}");
}
return;
}
// 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 (command chord) failed: {e}");
}
return;
}
// Session B2 forwards cursor motion + plain text editing
// (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;
}
// Arc 1a Q#C6 — with the popup open, RET and TAB mean
// "accept", not "insert \n / \t": skip the optimistic
// path so they round-trip into the daemon's
// dispatch_completion_key. Everything else stays
// optimistic.
let completion_takes_key =
completion_open && matches!(pkey, ProtocolKey::Enter | ProtocolKey::Tab);
if !completion_takes_key
&& let Some(op) = self.state.as_mut().and_then(|state| {
state
.optimistic_crdt_insert(pkey, pmods)
.or_else(|| state.optimistic_crdt_delete(pkey, pmods))
})
{
if debug_input() {
eprintln!(
"pmacs-gpu send_crdt: key={pkey:?} buf={:?} bytes={}B",
op.buffer_id,
op.op.bytes.len()
);
}
if let Err(e) = client.send_crdt_op(op.buffer_id, op.op) {
eprintln!("pmacs-gpu: send_crdt_op failed: {e}");
}
// An optimistic edit near the viewport edge can
// scroll (a wrap-inducing insert, a Backspace
// above the top); re-declare the scoped viewport
// so the producer styles the newly visible lines.
if let Some(vp) = op.viewport
&& let Err(e) =
client.send_viewport(vp.buffer_id, vp.visible, vp.generation)
{
eprintln!("pmacs-gpu: send Viewport failed: {e}");
}
return;
}
if let Some(state) = self.state.as_mut() {
if state.defer_round_trip_key_if_needed(pkey, pmods) {
if debug_input() {
eprintln!(
"pmacs-gpu defer_key: {pkey:?} mods={pmods:?} \
pending optimistic cursor"
);
}
return;
}
state.mark_cursor_stale_after_round_trip();
}
if debug_input() {
eprintln!("pmacs-gpu send_key: {pkey:?} mods={pmods:?}");
}
if let Err(e) = client.send_key(pkey, pmods) {
eprintln!("pmacs-gpu: send_key failed: {e}");
}
}
// Session M-2 — pointer input (docs/pmacs-gpu-mouse-framing.md).
WindowEvent::CursorMoved { position, .. } => {
let Some(state) = self.state.as_mut() else {
@ -3647,7 +3742,23 @@ impl ApplicationHandler<AppEvent> for App {
self.apply_resize(width, height);
}
Route::Lifecycle(LifecycleRoute::Redraw) => self.apply_redraw(),
Route::Unrouted => {}
Route::Keyboard {
action: KeyAction::Press,
key,
} => {
if self.apply_keyboard(key) == EventOutcome::Exit {
event_loop.exit();
}
}
// A key-up is claimed by the keyboard family and
// discarded; an unrouted event was claimed by nobody.
// The route keeps those apart — they are merged here
// only because both are, today, nothing to do.
Route::Keyboard {
action: KeyAction::Release,
..
}
| Route::Unrouted => {}
},
}
}