B1 fix — route semantic-frontend Key events into the editor core

Visual validation found "nothing occurs" when typing in pmacs-gpu.
Root cause is daemon-side, not consumer-side: the dispatcher's
catch-all arm only called `apply_event` (→ `dispatch_key`) when the
source frontend had a `RenderState` — i.e. a grid frontend. A semantic
frontend like pmacs-gpu has only a `SemanticRenderState`, so its
`Key`/`Mouse`/etc. events hit the `else` branch and were silently
dropped (the long-standing "M11.5 scope" posture). So pmacs-gpu's keys
never reached the keymap; the cursor never moved.

This contradicts the Phase B framing's "consumer-only" claim: the
Explore fact-check verified `apply_event` → `dispatch_key` (true for
grid frontends) but not that the dispatcher gates that call on
`render_state`, so semantic-frontend keys never reach `apply_event`.
Exactly the gap visual validation exists to catch.

Fix: when the source has no `render_state` but is a registered
semantic session, route its input through a new
`apply_semantic_input_event` — `Key` → `dispatch_key`, `Mouse` →
`dispatch_mouse` — the same core path the TUI uses. No grid state is
needed (the editor core owns the cursor/buffer/commands); the
resulting motion/edit flows back to pmacs-gpu as `CursorByte` /
`CrdtOp`.

Regression test `semantic_frontend_key_event_reaches_the_core`: a
printable `Key` from a semantic frontend self-inserts and advances its
window cursor (0→1). Before the fix the dispatcher dropped it.

Gates green:
- cargo fmt --all -- --check
- cargo clippy --all-targets --workspace -- -D warnings (default + crdt)
- pmacs lib 1334; crdt daemon tests pass
- m4_acceptance 88, m11_5_semantic_acceptance (--features crdt) 2

Still awaiting visual confirmation (caret tracks arrow keys in a
running pmacs-gpu) before merge, per the framing's process rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-05-29 12:38:09 -04:00
parent 5eec8a6f10
commit 98ef140a84
1 changed files with 89 additions and 7 deletions

View File

@ -1354,15 +1354,22 @@ fn handle_dispatcher_event(
if let Some(render_state) = render_states.get_mut(&source) {
apply_event(editor, event, &mut term_size, render_state);
term_sizes.insert(source, term_size);
} else if semantic_states.contains_key(&source) {
// Phase B (session B1) — a semantic (grid-less)
// session has no `RenderState`, but its keyboard
// input still drives the shared editor core. The
// input events that don't need grid state
// (`Key`, `Mouse`) dispatch through the same
// `dispatch_key` / `dispatch_mouse` path the TUI
// uses; the resulting cursor move / edit flows
// back as `CursorByte` / `CrdtOp`. (Earlier this
// arm dropped these events — the "M11.5 scope"
// posture — which is why typing in pmacs-gpu did
// nothing before B1.)
apply_semantic_input_event(editor, event, term_size);
} else {
// T M11.2 — a semantic (grid-less) session has
// no `RenderState`. Key/Mouse/Paste/Focus
// command handling for semantic frontends is
// M11.5 scope; until then these events are
// dropped rather than panicking the
// dispatcher on the absent grid state.
debug_assert!(
semantic_states.contains_key(&source),
false,
"fid with neither a render_state nor a semantic_state \
sent a frontend event"
);
@ -1897,6 +1904,30 @@ fn build_presence_snapshot(editor: &EditorState, frontend_id: FrontendId) -> Pre
}
}
/// Dispatch a semantic (grid-less) frontend's input event into the
/// shared editor core (Phase B, session B1). Mirrors the `Key` / `Mouse`
/// arms of [`apply_event`] but takes no `RenderState` — a semantic
/// frontend lays out locally, so the only state these events touch is
/// the editor core (cursor, buffer, commands), which `dispatch_key` /
/// `dispatch_mouse` operate on directly. `Resize` / `Paste` / `Focus`
/// have no grid-less effect yet and are dropped; `Viewport` / `CrdtOp`
/// are handled in their own dispatcher arms and never reach here.
#[allow(clippy::needless_pass_by_value)] // consumes the event, mirroring `apply_event`.
fn apply_semantic_input_event(editor: &mut EditorState, ev: FrontendEvent, term_size: CellSize) {
match ev {
FrontendEvent::Key(pmacs_key) => {
if let Some(ct_key) = key_to_crossterm(&pmacs_key) {
editor.dispatch_key(pmacs_key.frontend_id, ct_key);
}
}
FrontendEvent::Mouse(pmacs_mouse) => {
let ct_mouse = mouse_to_crossterm(&pmacs_mouse);
editor.dispatch_mouse(pmacs_mouse.frontend_id, ct_mouse, term_size);
}
_ => {}
}
}
// Takes `ev` by value because it semantically consumes the event;
// the caller pulls events out of the channel one at a time and never
// needs to look at them again.
@ -2100,4 +2131,55 @@ mod tests {
"buffer.after-edit must fire when handle_remote_crdt_op produces a text Edit"
);
}
/// Session B1 regression: a `Key` event from a *semantic*
/// (grid-less) frontend must reach the editor core. Before B1 the
/// dispatcher's catch-all only called `apply_event` when the
/// frontend had a `RenderState`, so a semantic frontend's keys were
/// silently dropped — typing in pmacs-gpu did nothing. The routing
/// now goes through `apply_semantic_input_event`; a printable char
/// must self-insert at the frontend's window cursor.
#[cfg(feature = "crdt")]
#[test]
fn semantic_frontend_key_event_reaches_the_core() {
use crate::editor::EditorState;
use crate::protocol::FrontendId;
use pmacs_protocol::{Key, KeyEvent, Modifiers};
let mut editor = EditorState::new();
let fid = FrontendId(99);
let view = build_fresh_frontend_view(&mut editor);
editor.core.borrow_mut().register_frontend_view(fid, view);
let before = editor
.core
.borrow()
.active_window_for(fid)
.expect("fid window")
.cursor;
apply_semantic_input_event(
&mut editor,
FrontendEvent::Key(KeyEvent {
frontend_id: fid,
key: Key::Char('X'),
mods: Modifiers::NONE,
timestamp_ns: 0,
}),
CellSize::new(24, 80),
);
let after = editor
.core
.borrow()
.active_window_for(fid)
.expect("fid window")
.cursor;
assert_eq!(
after,
before + 1,
"a semantic frontend's printable Key must self-insert and advance its window cursor \
(pre-B1 the dispatcher dropped it)"
);
}
}