From 4cd968bc0c4553a4937c3d834082b5abdf4b2deb Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 30 May 2026 09:49:53 -0400 Subject: [PATCH] =?UTF-8?q?B1=20fix=20=E2=80=94=20align=20semantic=20front?= =?UTF-8?q?end's=20window=20to=20its=20displayed=20buffer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up the "arrow keys do nothing in the GUI" investigation. Root cause is the multi-buffer mismatch the manual investigation theorized, now confirmed in code and tested: - `build_fresh_frontend_view` binds an attaching frontend's window to LOCAL's active buffer (a scratch the TUI never switched LOCAL away from). - `send_buffer_snapshots` ships a snapshot per buffer in registry order; pmacs-gpu treats each as "switch visible buffer", so its `current_buffer_id` (and what it displays) becomes the LAST one — the file the TUI opened. - So the GUI displays the file, but its daemon-side window edits the scratch. Arrow keys → `dispatch_key` → move the scratch cursor → `CursorByte { buffer_id: scratch }` → pmacs-gpu ignores it (its `current_buffer_id` is the file). The caret never tracks. Fix: the `Viewport` event already declares which buffer the frontend is displaying. The daemon now calls `align_semantic_window_to_buffer` on it — re-pointing the semantic frontend's window at the declared buffer (rebuild the cheap `TextView` line index, reset cursor; a semantic frontend has no grid overlays to migrate, it renders from the wire). Input and the `CursorByte` it produces then target the buffer the user is actually looking at. The guard makes it a no-op when the buffer is unchanged (so per-edit Viewport re-declarations don't reset the cursor). Tests: - `viewport_aligns_semantic_window_to_displayed_buffer` — window starts on scratch, declares the file via align, a key then self-inserts into the *file*. - `semantic_frontend_key_event_reaches_the_core` (from the prior commit) still green. Also adds `PMACS_GPU_DEBUG_INPUT=1`: logs keys sent and each `CursorByte` with `buf`/`current`/`match` so the displayed-vs-edited buffer alignment is visible at a glance on retest. Gates green: fmt; clippy --all-targets --workspace -D warnings (default + crdt); pmacs lib 1334; crdt daemon tests 7; pmacs-gpu unit 18; m4_acceptance 88; m11_5_semantic_acceptance 2. Still needs visual confirmation (arrow keys move the caret in a running pmacs-gpu) before merge. Co-Authored-By: Claude Opus 4.8 (1M context) --- pmacs-gpu/src/main.rs | 27 ++++++++- src/daemon.rs | 132 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 155 insertions(+), 4 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 76d0c29..a724eea 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -378,9 +378,13 @@ impl ApplicationHandler for App { if let Some((pkey, pmods)) = translate_key(&key.logical_key, self.modifiers) && is_motion_key(pkey) && let Some(client) = self.attach_client.as_ref() - && let Err(e) = client.send_key(pkey, pmods) { - eprintln!("pmacs-gpu: send_key failed: {e}"); + 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) => { @@ -846,6 +850,14 @@ impl State { buffer_id, byte_pos, } => { + if debug_input() { + eprintln!( + "pmacs-gpu cursor: buf={buffer_id:?} byte={byte_pos} \ + current={:?} match={}", + self.current_buffer_id, + self.current_buffer_id == Some(buffer_id) + ); + } self.own_cursor = Some(OwnCursor { buffer_id, byte: byte_pos, @@ -1711,6 +1723,17 @@ fn debug_frame() -> bool { *FLAG.get_or_init(|| std::env::var_os("PMACS_GPU_DEBUG_FRAME").is_some()) } +/// One-shot env flag: `PMACS_GPU_DEBUG_INPUT=1` logs the input path — +/// keys sent and `CursorByte` received (with the buffer it targets vs +/// the buffer being displayed). The buffer comparison is the B1 +/// diagnostic: if `CursorByte` targets a different buffer than +/// `current`, the caret won't track (the displayed/edited buffers are +/// out of sync). +fn debug_input() -> bool { + static FLAG: std::sync::OnceLock = std::sync::OnceLock::new(); + *FLAG.get_or_init(|| std::env::var_os("PMACS_GPU_DEBUG_INPUT").is_some()) +} + /// Translate a winit logical key + current modifier state into a /// protocol `(Key, Modifiers)`. Returns `None` for keys the protocol /// has no representation for (the daemon ignores `Key::Unknown`, so diff --git a/src/daemon.rs b/src/daemon.rs index 43d8eea..a1a0a93 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1246,6 +1246,7 @@ fn handle_session_established( } #[allow(clippy::too_many_arguments)] +#[allow(clippy::too_many_lines)] // per-variant dispatcher match. fn handle_dispatcher_event( event: DispatcherEvent, editor: &mut EditorState, @@ -1342,8 +1343,20 @@ fn handle_dispatcher_event( // session never sends this; if one does, there is // no `SemanticRenderState` to update and it is a // benign no-op. - if let Some(sem) = semantic_states.get_mut(&source) { - sem.set_viewport(buffer_id, visible, generation); + if semantic_states.contains_key(&source) { + // Phase B (B1) — the Viewport declares *which + // buffer this frontend is displaying*. Align its + // editor window to that buffer so keyboard input + // (`dispatch_key`) and the `CursorByte` it emits + // target the displayed buffer. Without this, a + // semantic frontend's window stays bound to + // LOCAL's attach-time buffer (often a scratch the + // user isn't viewing), so arrow keys moved an + // off-screen cursor and the caret never tracked. + align_semantic_window_to_buffer(editor, source, buffer_id); + if let Some(sem) = semantic_states.get_mut(&source) { + sem.set_viewport(buffer_id, visible, generation); + } } } _ => { @@ -1843,6 +1856,53 @@ fn handle_remote_crdt_op( /// `pmacs.editor.open(path)` to switch their window to a different /// buffer; the per-frontend window-tree refactor (M10.8 Q1) makes /// this independent. +/// Re-point a semantic frontend's active window at `buffer_id` — the +/// buffer it just declared (via `FrontendEvent::Viewport`) that it is +/// displaying. No-op when the window is already on that buffer or the +/// buffer is gone. +/// +/// A semantic frontend renders from the wire (`StyleSpans` + its local +/// CRDT replica), so its daemon-side window holds only the cursor and +/// the buffer identity — no grid overlays to migrate. Rebuilding the +/// `TextView` (a cheap line index) and resetting the cursor is the +/// whole switch. This is the input/display alignment fix for B1: the +/// frontend's *declared* buffer becomes the buffer its keys edit and +/// its `CursorByte` reports. +fn align_semantic_window_to_buffer( + editor: &mut EditorState, + fid: FrontendId, + buffer_id: crate::buffer::BufferId, +) { + use crate::text_view::TextView; + + let text_view = { + let core = editor.core.borrow(); + let Some(win_id) = core.views.get(&fid).map(|v| v.active) else { + return; + }; + if core.windows.get(&win_id).map(|w| w.buffer_id) == Some(buffer_id) { + return; // Already displaying this buffer. + } + let reg = core.registry.borrow(); + let Ok(buf) = reg.get(buffer_id) else { + return; // Unknown buffer — leave the window as-is. + }; + TextView::new(buf) + }; + + let mut core = editor.core.borrow_mut(); + let Some(win_id) = core.views.get(&fid).map(|v| v.active) else { + return; + }; + if let Some(win) = core.windows.get_mut(&win_id) { + win.buffer_id = buffer_id; + win.text_view = text_view; + win.cursor = 0; + win.selection = None; + win.overlays.clear(); + } +} + fn build_fresh_frontend_view(editor: &mut EditorState) -> crate::window::FrontendView { use crate::text_view::TextView; use crate::window::{FrontendView, Layout, Window, WindowId}; @@ -2182,4 +2242,72 @@ mod tests { (pre-B1 the dispatcher dropped it)" ); } + + /// B1 input/display alignment: a semantic frontend's window is bound + /// to LOCAL's attach-time buffer, but the buffer it *displays* is + /// the one it declares via `Viewport`. `align_semantic_window_to_buffer` + /// re-points the window so keys edit the displayed buffer — without + /// it, arrow keys moved an off-screen cursor in the wrong buffer and + /// the caret never tracked. + #[cfg(feature = "crdt")] + #[test] + fn viewport_aligns_semantic_window_to_displayed_buffer() { + use crate::editor::EditorState; + use crate::protocol::FrontendId; + use pmacs_protocol::{Key, KeyEvent, Modifiers}; + + let mut editor = EditorState::new(); + let scratch = editor.core.borrow().active_window().buffer_id; + let file = { + let core = editor.core.borrow(); + core.registry + .borrow_mut() + .create_from_bytes("file".to_owned(), b"hello\nworld\n") + }; + assert_ne!(scratch, file); + + // Attach: window shares LOCAL's active (scratch). + let fid = FrontendId(99); + let view = build_fresh_frontend_view(&mut editor); + editor.core.borrow_mut().register_frontend_view(fid, view); + assert_eq!( + editor + .core + .borrow() + .active_window_for(fid) + .unwrap() + .buffer_id, + scratch + ); + + // The frontend declares it is displaying the file buffer. + align_semantic_window_to_buffer(&mut editor, fid, file); + assert_eq!( + editor + .core + .borrow() + .active_window_for(fid) + .unwrap() + .buffer_id, + file, + "Viewport must re-point the window at the displayed buffer" + ); + + // A key now edits the *displayed* buffer, advancing its cursor. + apply_semantic_input_event( + &mut editor, + FrontendEvent::Key(KeyEvent { + frontend_id: fid, + key: Key::Char('Z'), + mods: Modifiers::NONE, + timestamp_ns: 0, + }), + CellSize::new(24, 80), + ); + assert_eq!( + editor.core.borrow().active_window_for(fid).unwrap().cursor, + 1, + "key must self-insert into the displayed buffer, not the attach-time scratch" + ); + } }