diff --git a/src/daemon.rs b/src/daemon.rs index 549facb..e95810b 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -3847,6 +3847,7 @@ fn align_primary_document_window( win.cursor = 0; win.selection = None; win.overlays.clear(); + win.forget_manual_horizontal_origin(); } Some(win_id) } @@ -5556,6 +5557,98 @@ mod tests { ); } + /// GUI Stage 1b, lifetime clause 5 — **the daemon's alignment path + /// is a buffer replacement too**, and must forget a manual + /// horizontal origin like the other two. + /// + /// `align_primary_document_window` re-points a window at the buffer + /// its frontend declares. That is a replacement by any measure: a + /// sideways origin carried across it renders the successor scrolled + /// with nothing about that buffer to explain it. This row lives + /// here rather than beside L8b/L8c because the function is private + /// to this module. + /// + /// The latch is armed the production way — a real wheel gesture + /// through `dispatch_mouse` — not by writing the fields, so the row + /// cannot pass against a state the running editor never reaches. + /// + /// **Not `crdt`-gated**, unlike its neighbour above: nothing here + /// needs the feature, and gating it would keep it out of the + /// default `--lib` leg for no reason — the same blind spot that + /// already lets `crdt`-only code go unlinted locally. + /// + /// *Mutation: drop the `forget_manual_horizontal_origin()` call + /// from `align_primary_document_window` → this row.* + #[test] + fn l8d_the_alignment_path_clears_a_manual_horizontal_origin() { + use crate::editor::EditorState; + use crate::protocol::FrontendId; + use crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind}; + + let mut editor = EditorState::new(); + let wide = |name: &str| { + let core = editor.core.borrow(); + let mut content = b"wide\n".to_vec(); + content.extend_from_slice(&b"w".repeat(400)); + content.push(b'\n'); + core.registry + .borrow_mut() + .create_from_bytes(name.to_owned(), &content) + }; + let first = wide("first"); + let second = wide("second"); + let fid = FrontendId(99); + let view = build_fresh_frontend_view(&mut editor, false, false); + editor.core.borrow_mut().register_frontend_view(fid, view); + + // The window starts on LOCAL's narrow scratch buffer, where + // B7's `widest − viewport` bound is zero and every notch is + // absorbed. Put a wide buffer under it first — through the very + // function under test — so the gesture below can be effective. + align_primary_document_window(&mut editor, fid, first); + + for _ in 0..10 { + editor.dispatch_mouse( + fid, + MouseEvent { + kind: MouseEventKind::ScrollRight, + column: 5, + row: 5, + modifiers: KeyModifiers::NONE, + }, + crate::cell::CellSize::new(24, 80), + ); + } + let armed = editor + .core + .borrow() + .active_window_for(fid) + .expect("the semantic frontend has a window") + .view_left; + assert!( + armed > 0 + && editor + .core + .borrow() + .active_window_for(fid) + .expect("window") + .manual_left_authority, + "setup: a real wheel gesture must have moved the origin and \ + armed authority, else this row measures nothing" + ); + + align_primary_document_window(&mut editor, fid, second); + + let win = editor.core.borrow(); + let win = win.active_window_for(fid).expect("window"); + assert_eq!( + win.view_left, 0, + "the successor must not inherit the predecessor's sideways \ + viewport" + ); + assert!(!win.manual_left_authority, "nor the authority defending 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_primary_document_window` diff --git a/src/editor.rs b/src/editor.rs index 5b1cf6b..f61df64 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -10638,15 +10638,29 @@ mod tests { /// A buffer with one line far wider than any viewport these rows /// use, so B7's `widest − viewport` bound can never absorb their /// gestures and read as correct. + /// The fixture's widest line, in columns. Named so L7a's exact + /// bound and the fixture that produces it cannot drift apart. + const WIDEST_COLUMNS: u32 = 400; + fn wide_fixture() -> EditorState { let mut content = b"short\n".to_vec(); - content.extend_from_slice(&b"w".repeat(400)); + content.extend_from_slice(&b"w".repeat(WIDEST_COLUMNS as usize)); content.push(b'\n'); // Tall as well as wide. L3 needs a line to move DOWN to and L4 // needs somewhere to scroll: in a two-line document the vertical // wheel has nothing to do, carries no point, and L4's setup // assertion fires — which is how this was found. - content.extend_from_slice(&b"filler\n".repeat(200)); + // + // The filler lines are **wide too**, and that is L4's + // requirement specifically: a caret landing on a SHORT line + // clamps to its end, which is left of the manual origin and so + // outside the viewport — the very state in which the vertical + // wheel carries no point. 120 columns keeps them clear of the + // 400-column line that fixes `widest`. + for _ in 0..200 { + content.extend_from_slice(&b"f".repeat(120)); + content.push(b'\n'); + } fresh_with(&content) } @@ -10656,6 +10670,22 @@ mod tests { } } + /// Whether the caret is inside the window's horizontal viewport, + /// asked the way production asks it: `pos_to_display` returns + /// `None` for a position LEFT of the edge (Q#HS7(c′)), which is the + /// exact condition that decides whether a vertical wheel can carry + /// point at all. + fn caret_inside_viewport(s: &EditorState) -> bool { + let core = s.core.borrow(); + let win = core.active_window(); + let registry = core.registry.clone(); + let reg = registry.borrow(); + let buf = reg.get(win.buffer_id).expect("live buffer"); + win.text_view + .pos_to_display(buf, win.cursor, win.layout_ctx()) + .is_some() + } + /// The state every L-row starts from: a real sideways wheel gesture /// with the caret left at column 0 — **outside** the resulting /// viewport. That is what makes the rows discriminate: with the @@ -10814,6 +10844,14 @@ mod tests { "setup: the vertical wheel must actually carry point, else \ the row does not exercise what it is about" ); + assert!( + caret_inside_viewport(&s), + "setup: and it must still be INSIDE the viewport afterwards. \ + A caret that landed on a short line would clamp to that \ + line's end, left of the origin — and then the origin would \ + discriminate too, so the claim below about the latch being \ + the only discriminator would be false" + ); paint_once(&s, term_size_24x80()); // **Authority is the discriminator, not the origin.** With the @@ -10934,12 +10972,24 @@ mod tests { paint_once(&s, wide); paint_once(&s, wide); + // **The exact bound, not merely a smaller number.** `widest − + // viewport` is the whole content of clause 3's re-clamp; an + // assertion that the origin merely fell would accept any + // arbitrary reduction, including an off-by-one that leaves a + // column of text permanently unreachable. let after = s.core.borrow().active_window().view_left; + let cols = s.core.borrow().active_window().last_content_cols; + let expected = WIDEST_COLUMNS.saturating_sub(cols); assert!( after < narrow_origin, "a wider viewport lowers the maximum origin, so the origin \ must come down with it: {narrow_origin} -> {after}" ); + assert_eq!( + after, expected, + "and it must land on `widest − viewport` exactly \ + ({WIDEST_COLUMNS} − {cols})" + ); assert!( s.core.borrow().active_window().manual_left_authority, "clamped, NOT released — the gesture survives at the new bound" @@ -10994,6 +11044,98 @@ mod tests { ); } + /// A second wide buffer, so a successor window has somewhere to + /// scroll and "the origin came back to zero" is not just the only + /// value available. + fn other_wide_buffer(s: &EditorState) -> crate::buffer::BufferId { + let mut content = b"other\n".to_vec(); + content.extend_from_slice(&b"o".repeat(WIDEST_COLUMNS as usize)); + content.push(b'\n'); + s.lua_host + .registry() + .borrow_mut() + .create_from_bytes("other", &content) + } + + /// L8, replacement leg — **a buffer switch clears the origin AND + /// the latch** (clause 5's second half). + /// + /// The origin describes the document being shown. Carried into a + /// successor it renders the new buffer scrolled sideways with + /// nothing about that buffer to explain it. The GPU has had this + /// reset since it hit the symptom; the TUI's three replacement + /// paths had neither half. + /// + /// *Mutation: drop the `forget_manual_horizontal_origin()` call + /// from `switch_active_buffer_for` → this row.* + #[test] + fn l8b_switching_the_active_buffer_clears_the_origin_and_the_latch() { + let (s, _) = scrolled_sideways(); + let other = other_wide_buffer(&s); + + s.core + .borrow_mut() + .switch_active_buffer(other) + .expect("switch to the successor buffer"); + + assert_eq!( + s.core.borrow().active_window().view_left, + 0, + "the successor must not inherit the predecessor's sideways \ + viewport" + ); + assert!( + !s.core.borrow().active_window().manual_left_authority, + "nor the authority defending it" + ); + + // And the latch is really gone, not merely the origin: put the + // caret far out in the NEW buffer and the follow must move. + { + let mut core = s.core.borrow_mut(); + let id = core.active_window_id(); + core.windows.get_mut(&id).expect("live window").cursor = 6 + 300; + } + paint_once(&s, term_size_24x80()); + assert!( + s.core.borrow().active_window().view_left > 0, + "a stale latch would have frozen the successor's viewport at \ + zero with its caret 300 columns off-screen" + ); + } + + /// L8, replacement leg — the same for **`install_buffer_in_window`**, + /// the path that targets an explicit window rather than the active + /// one. + /// + /// A separate row because the clear is a separate call: one helper + /// on `Window`, but each call site removable on its own, so a + /// forgotten one is individually visible. + /// + /// *Mutation: drop the `forget_manual_horizontal_origin()` call + /// from `install_buffer_in_window` → this row.* + #[test] + fn l8c_installing_a_buffer_in_a_window_clears_the_origin_and_the_latch() { + let (s, _) = scrolled_sideways(); + let other = other_wide_buffer(&s); + let win = s.core.borrow().active_window_id(); + + s.core + .borrow_mut() + .install_buffer_in_window(win, other) + .expect("install the successor buffer"); + + assert_eq!( + s.core.borrow().active_window().view_left, + 0, + "an explicit install inherits nothing either" + ); + assert!( + !s.core.borrow().active_window().manual_left_authority, + "and drops the latch with it" + ); + } + /// *Mutation: drop the wrap guard in `scroll_window_columns` → this /// row, and only this row.* #[test] diff --git a/src/editor_core.rs b/src/editor_core.rs index 01e2fe6..e98f6d6 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -4075,6 +4075,7 @@ impl EditorCore { window.cursor = 0; window.selection = None; window.view_top = 0; + window.forget_manual_horizontal_origin(); window.goal_col = None; Ok(()) } @@ -5650,6 +5651,7 @@ impl EditorCore { aw.cursor = 0; aw.selection = None; aw.view_top = 0; + aw.forget_manual_horizontal_origin(); aw.goal_col = None; Ok(()) } diff --git a/src/window.rs b/src/window.rs index b8d2824..8963c51 100644 --- a/src/window.rs +++ b/src/window.rs @@ -487,6 +487,26 @@ impl Window { } } + /// Forget a manual horizontal origin because this window is + /// adopting a **different buffer** (lifetime clause 5). + /// + /// The origin is a fact about the document being shown, not about + /// the window. Carried into a successor it renders the new buffer + /// scrolled sideways with nothing about that buffer to explain it, + /// until some later cursor motion repairs it by accident. The GPU + /// learned this once already — `code_scroll_left` has its own line + /// in that frontend's replacement reset, added after exactly this + /// symptom — and the TUI's three replacement paths had neither the + /// origin reset nor the latch clear. + /// + /// One helper rather than three copies, so a fourth replacement + /// path gets the rule by calling it; each **call site** stays + /// individually removable, which is what keeps its own row honest. + pub fn forget_manual_horizontal_origin(&mut self) { + self.view_left = 0; + self.manual_left_authority = false; + } + /// Width in cells this window's line-number gutter occupies, or `0` /// when disabled (UX gutter arc, Q#UX3). `digits(line_count) + PAD`; /// the renderer caps this against the window width and applies it as a