fix(gui-1b): clause 5 for the TUI's three buffer-replacement paths

The latch commit implemented clause 5's wrap half and left its
replacement half undone. Three paths replace a window's buffer ---
switch_active_buffer_for, install_buffer_in_window, and the daemon's
align_primary_document_window --- and none cleared view_left or the
latch. Two of them already reset cursor, selection and view_top one
line at a time; the horizontal origin was simply missing from the list.
A successor inherited both, rendering sideways with nothing about that
buffer to explain it. The GPU carries this reset for exactly that
symptom.

One `Window::forget_manual_horizontal_origin` rather than three copies,
so a fourth path gets the rule by calling it, with each call site
removable on its own --- which is what lets each leg have its own row.
L8b, L8c and L8d, one per site; dropping any one call fires only its
own. L8d lives in daemon.rs because the function is private there, arms
the latch through a real wheel gesture rather than by writing fields,
and is deliberately NOT crdt-gated like its neighbour, so it runs in
the default --lib leg too.

L7a asserted only that the origin came DOWN, which any arbitrary
reduction satisfies. It now asserts `widest − viewport` exactly, with
the fixture's widest named as a constant so the row and the fixture
cannot drift apart. Mutation-checked with an off-by-one clamp, which
the old assertion could not see and the new one fires on.

And L4's rationale was false as written. It said the caret stays inside
the viewport after the vertical wheel; with short filler lines the
caret clamps to their end, LEFT of the origin, so the origin
discriminated too and the row's stated reason for using the latch
instead did not hold. The filler lines are now 120 columns wide and the
row asserts the caret is still inside afterwards, through
`pos_to_display` --- the same rule production uses to decide whether the
wheel can carry point at all.

One repair of my own making: the helper landed between `#[must_use]`
and `layout_ctx`, stealing the attribute and leaving that function
wearing the tail of my doc comment. Third time this file's neighbours
have been damaged by an insertion. The method now sits after
`layout_ctx`, whose attribute and body were read back intact.

Gates: fmt; clippy --workspace --all-targets -D warnings (which caught
the stolen attribute); --lib 2007; --lib --features crdt 2200;
pmacs-gpu 312; git diff --check.
This commit is contained in:
Levi Neuwirth 2026-09-01 21:59:58 +02:00
parent cf75a51867
commit a2d5b2657b
No known key found for this signature in database
4 changed files with 259 additions and 2 deletions

View File

@ -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`

View File

@ -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]

View File

@ -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(())
}

View File

@ -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