From 2326fa6e327a1816bc14ed2e2312ce80237e647b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 27 Jun 2026 13:02:53 -0400 Subject: [PATCH] search: route GUI keystrokes into the daemon's search (Q#SR5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix for "C-s highlights but typing still edits the buffer" in the GPU frontend. The GUI withholds Ctrl chords (`should_forward_key`) and treated Escape as a hard quit, so it could neither *start* a search (C-s never reached the daemon) nor route the query keys into one — they fell through to the optimistic-apply path and edited the document. The GUI now mirrors the daemon's input-interception state. A new `daemon_intercepts_keys` (true while a `SearchPrompt` is live or the daemon reports `DispatchIdle { idle: false }`) gates the key path: - While intercepting, every key round-trips to the daemon — no optimistic apply — so chars extend the query, C-s/C-r step, BS shortens, RET accepts, C-g cancels. This reuses the M11.6 gate the optimistic path already honored; the new part is round-tripping the *command* chords that `should_forward_key` would otherwise drop. - While idle, C-s / C-r are forwarded as search-entry chords (still withheld for every other Ctrl chord) so a search can begin. No optimistic local idle-flip: the daemon's `DispatchIdle` / `SearchPrompt` flip the gate one round-trip later, so a rebound C-s that doesn't start a search can never wedge the gate. - Escape cancels an active search (round-tripped to the daemon's `SearchKey::Cancel`) instead of quitting the window; it stays the local quit when nothing is intercepting. Also aligns the GUI status band's empty-query prompt with the TUI (`I-search: ` with no `[no match]` until a non-empty query misses). Tests: `is_search_entry_chord` (C-s/C-r + Ctrl only; and the fact that `should_forward_key` withholds it, which is what the entry path exists to override). The end-to-end routing is GUI-window behavior, validated manually. Co-Authored-By: Claude Opus 4.8 (1M context) --- pmacs-gpu/src/main.rs | 201 ++++++++++++++++++++++++++++++++---------- 1 file changed, 156 insertions(+), 45 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 637ac51..35abe0a 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -659,64 +659,124 @@ impl ApplicationHandler for App { if key.state != ElementState::Pressed { return; } - // Escape stays a local quit (no daemon round trip). + // 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); + + // Escape cancels an active intercept (e.g. a running + // search); otherwise it stays the local quit. if matches!(key.logical_key, Key::Named(NamedKey::Escape)) { - event_loop.exit(); + if intercept { + 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, pmods)) = translate_key(&key.logical_key, self.modifiers) else { + 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: 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) { + 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; + } + // 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) - && should_forward_key(pkey, pmods) - && let Some(client) = self.attach_client.as_ref() - { - if 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 !should_forward_key(pkey, pmods) { + return; + } + + if 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 Enter near the bottom edge can + // scroll; 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 send_crdt: key={pkey:?} buf={:?} bytes={}B", - op.buffer_id, - op.op.bytes.len() + "pmacs-gpu defer_key: {pkey:?} mods={pmods:?} \ + pending optimistic cursor" ); } - if let Err(e) = client.send_crdt_op(op.buffer_id, op.op) { - eprintln!("pmacs-gpu: send_crdt_op failed: {e}"); - } - // An optimistic Enter near the bottom edge can - // scroll; 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}"); - } + 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}"); } } WindowEvent::Resized(size) => { @@ -1447,6 +1507,16 @@ impl State { } } + /// `true` while the daemon is intercepting keystrokes — an active + /// incremental search (Q#SR5), surfaced by a live `SearchPrompt`, or + /// the daemon reporting non-idle (`DispatchIdle { idle: false }` for + /// a minibuffer / pending prefix). In this state the GUI round-trips + /// every key to the daemon's handler instead of optimistically + /// applying it to the buffer. + fn daemon_intercepts_keys(&self) -> bool { + self.search_prompt.is_some() || !self.dispatch_idle + } + /// Shared eligibility gates for the optimistic edit paths /// (insert + delete). `None` ⇒ the key must round-trip: /// - dispatcher busy (minibuffer/prefix flows own the keys), or @@ -2558,7 +2628,9 @@ impl State { .as_ref() .filter(|s| Some(s.buffer_id) == self.current_buffer_id) { - let count = if sp.total == 0 { + let count = if sp.query.is_empty() { + String::new() + } else if sp.total == 0 { " [no match]".to_string() } else { format!(" ({}/{})", sp.active.map_or(0, |a| a + 1), sp.total) @@ -4054,6 +4126,14 @@ fn should_forward_key(key: ProtocolKey, mods: Modifiers) -> bool { ) } +/// `C-s` / `C-r` — the chords that begin an incremental search +/// (Q#SR5). 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. +fn is_search_entry_chord(key: ProtocolKey, mods: Modifiers) -> bool { + mods == Modifiers::CTRL && matches!(key, ProtocolKey::Char('s' | 'r')) +} + fn is_plain_text_modifiers(mods: Modifiers) -> bool { !mods.contains(Modifiers::CTRL) && !mods.contains(Modifiers::ALT) @@ -5094,6 +5174,37 @@ mod tests { assert!(should_forward_key(ProtocolKey::Backspace, Modifiers::ALT)); } + #[test] + fn search_entry_chord_is_ctrl_s_or_ctrl_r_only() { + // C-s / C-r start a search (Q#SR5) — forwarded even though + // `should_forward_key` withholds them as command chords. + assert!(is_search_entry_chord( + ProtocolKey::Char('s'), + Modifiers::CTRL + )); + assert!(is_search_entry_chord( + ProtocolKey::Char('r'), + Modifiers::CTRL + )); + 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 C-s 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::CTRL | Modifiers::ALT + )); + } + #[test] fn translate_key_carries_modifiers() { use winit::keyboard::{Key as WKey, ModifiersState, NamedKey};