From 5888ced15a784aedbb418976fbd6f000459c8080 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 30 May 2026 10:13:43 -0400 Subject: [PATCH] =?UTF-8?q?session=20B2=20=E2=80=94=20text=20editing=20in?= =?UTF-8?q?=20pmacs-gpu=20(Key=20round-trip)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Broadens the send gate from motion-only (B1) to plain text editing: `should_forward_key` forwards Char / Backspace / Enter / Delete / Tab in addition to motion keys. Editing rides the same round trip B1 proved — the daemon's `dispatch_key` self-inserts / deletes on the viewport-aligned buffer, authors the CRDT op (`CrdtOpOrigin::DaemonKey`, excluded from no recipient), and broadcasts it back; pmacs-gpu applies it (existing session-3 CrdtOp path) and the edit also propagates to the TUI. No editing logic in the frontend. Ctrl/Alt/Meta chords are deliberately withheld: they drive commands and minibuffer flows the GUI can't render or interact with yet (the minibuffer is instance-side global state; a GUI frontend opening one with no way to see/cancel it would wedge input). Those land in a later command-parity session with GUI minibuffer rendering. Shift is not a chord modifier — Shift+a already arrives as `Char('A')`. Test `should_forward_key_gates_editing_keys_and_excludes_chords`: editing keys + uppercase forward; Ctrl/Alt + char withheld; motion keys forward regardless of modifiers. Gates green: fmt; clippy --all-targets --workspace -D warnings; pmacs-gpu unit 20 (+1). Daemon/lib untouched (it already dispatches semantic-frontend keys, B1 fix). Awaiting visual confirmation: typing in pmacs-gpu inserts text that propagates to the TUI; backspace/enter/delete work; CRDT stays converged. Per the framing's process rule, not merged until confirmed. Co-Authored-By: Claude Opus 4.8 (1M context) --- pmacs-gpu/src/main.rs | 74 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 66 insertions(+), 8 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 56e3d12..282e50f 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -371,12 +371,13 @@ impl ApplicationHandler for App { event_loop.exit(); return; } - // Session B1 forwards cursor-motion keys only; editing - // keys (chars, Backspace, Enter, Delete) open in B2. - // `translate_key` handles the full set so B2 just drops - // the `is_motion_key` gate. + // 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). if let Some((pkey, pmods)) = translate_key(&key.logical_key, self.modifiers) - && is_motion_key(pkey) + && should_forward_key(pkey, pmods) && let Some(client) = self.attach_client.as_ref() { if debug_input() { @@ -1801,9 +1802,9 @@ fn translate_key( Some((pkey, pmods)) } -/// Session B1 forwards cursor-motion keys only; editing keys wait for -/// B2. Keeping the gate as a single predicate means B2 removes one -/// call site, not a translation rewrite. +/// Cursor-motion keys — forwarded with any modifier set (e.g. `C-Left` +/// is word-motion, `S-Down` extends a selection; the daemon's keymap +/// decides). fn is_motion_key(key: ProtocolKey) -> bool { matches!( key, @@ -1818,6 +1819,33 @@ fn is_motion_key(key: ProtocolKey) -> bool { ) } +/// Whether to forward a translated key to the daemon (session B2). +/// Motion keys go through with any modifiers. Plain text-editing keys +/// (`Char` / `Backspace` / `Enter` / `Delete` / `Tab`) go through only +/// *without* a Ctrl/Alt/Meta chord modifier: a bare key edits text, +/// but a chord drives commands and minibuffer flows the GUI can't +/// render or interact with yet (deferred to a later session). Shift is +/// not a chord modifier — `Shift`+a already arrives as `Char('A')`. +fn should_forward_key(key: ProtocolKey, mods: Modifiers) -> bool { + if is_motion_key(key) { + return true; + } + let chord = mods.contains(Modifiers::CTRL) + || mods.contains(Modifiers::ALT) + || mods.contains(Modifiers::META); + if chord { + return false; + } + matches!( + key, + ProtocolKey::Char(_) + | ProtocolKey::Backspace + | ProtocolKey::Enter + | ProtocolKey::Delete + | ProtocolKey::Tab + ) +} + /// Buffer-absolute byte offset of the start of each `\n`-delimited /// line (index 0 = byte 0). Indexed by cosmic-text's /// `LayoutRun::line_i` to rebase line-relative glyph offsets. @@ -2251,6 +2279,36 @@ mod tests { assert!(!is_motion_key(bk)); } + #[test] + fn should_forward_key_gates_editing_keys_and_excludes_chords() { + let none = Modifiers::NONE; + let ctrl = Modifiers::CTRL; + let shift = Modifiers::SHIFT; + + // Plain text-editing keys forward. + for key in [ + ProtocolKey::Char('a'), + ProtocolKey::Char('A'), + ProtocolKey::Backspace, + ProtocolKey::Enter, + ProtocolKey::Delete, + ProtocolKey::Tab, + ] { + assert!(should_forward_key(key, none), "{key:?} should forward"); + } + // Shift is not a chord modifier (Shift+a already arrives as 'A'). + assert!(should_forward_key(ProtocolKey::Char('A'), shift)); + + // Ctrl/Alt/Meta + a non-motion key is a chord — withheld in B2. + assert!(!should_forward_key(ProtocolKey::Char('x'), ctrl)); + assert!(!should_forward_key(ProtocolKey::Char('f'), Modifiers::ALT)); + + // Motion keys forward regardless of modifiers (C-Left = word-left). + assert!(should_forward_key(ProtocolKey::Left, ctrl)); + assert!(should_forward_key(ProtocolKey::Down, shift)); + assert!(should_forward_key(ProtocolKey::PageUp, none)); + } + #[test] fn translate_key_carries_modifiers() { use winit::keyboard::{Key as WKey, ModifiersState, NamedKey};