fix(gui-1b): the fourth replacement path, and a probe open at one end

The replacement census was taken by recall and was short. `kill_buffer`
rebinds every window showing the doomed buffer to a fallback, resetting
cursor, selection and view_top a line at a time --- with the horizontal
origin missing from that list exactly as the other three had it.

Retaken by grepping every write of a window's buffer_id rather than by
listing the paths someone had thought of. Four production sites:
switch_active_buffer_for, install_buffer_in_window, kill_buffer's
fallback rebind, and the daemon's align_primary_document_window.
EditorCore::from_bytes also assigns one and is deliberately excluded ---
a fresh core has no prior origin to inherit --- and the helper's doc now
names that exclusion so the next census need not re-decide it. The
remaining seven buffer_id writes are test code.

L8e covers the new site and fires only on its own call's removal.

And L4's caret probe was open at one end. `pos_to_display(..).is_some()`
proves the caret is not LEFT of the viewport, which is the condition
deciding whether the vertical wheel carries point --- but a caret past
the RIGHT edge also returns Some, and there a normal follow moves the
origin. That is precisely the state in which the row's claim, that the
latch is its only discriminator, would be false. The probe now requires
`col < last_content_cols`. Verified by walking the caret 380 columns
right: the setup assertion fires, where `is_some()` alone let it pass.

Gates: fmt; clippy --workspace --all-targets -D warnings; --lib 2008;
--lib --features crdt 2201; pmacs-gpu 312; git diff --check.
This commit is contained in:
Levi Neuwirth 2026-09-01 23:51:50 +02:00
parent 0ce3544bf0
commit deb3f0b101
No known key found for this signature in database
3 changed files with 75 additions and 12 deletions

View File

@ -10670,11 +10670,16 @@ 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.
/// Whether the caret is inside the window's horizontal viewport —
/// **both edges**.
///
/// `pos_to_display` returns `None` for a position LEFT of the edge
/// (Q#HS7(c′)), which is the condition deciding whether a vertical
/// wheel can carry point at all. On its own that is only half the
/// question: a caret past the RIGHT edge still returns `Some`, and
/// there a normal follow would move the origin — so a row claiming
/// the latch is its only discriminator would be wrong in exactly
/// the way `is_some()` cannot see. The column bound closes it.
fn caret_inside_viewport(s: &EditorState) -> bool {
let core = s.core.borrow();
let win = core.active_window();
@ -10683,7 +10688,7 @@ mod tests {
let buf = reg.get(win.buffer_id).expect("live buffer");
win.text_view
.pos_to_display(buf, win.cursor, win.layout_ctx())
.is_some()
.is_some_and(|coord| coord.col < win.last_content_cols)
}
/// The state every L-row starts from: a real sideways wheel gesture
@ -10846,11 +10851,12 @@ mod tests {
);
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"
"setup: and it must still be INSIDE the viewport afterwards, \
on BOTH edges. Past either one a normal follow would move \
the origin — left, if the caret clamped to a short line's \
end; right, if it ran off the far side — and then the \
origin would discriminate too, making the claim below \
about the latch being the only discriminator false"
);
paint_once(&s, term_size_24x80());
@ -11104,6 +11110,46 @@ mod tests {
);
}
/// L8, replacement leg — **killing the displayed buffer** rebinds
/// the window to a fallback, which is a replacement like any other.
///
/// This path was missed by the first census, which listed the
/// replacement sites someone had thought of rather than the ones a
/// grep for `buffer_id` writes turns up. It resets cursor,
/// selection and `view_top` a line at a time, exactly like the
/// other two in this file, and had the horizontal origin missing
/// from the same list.
///
/// *Mutation: drop the `forget_manual_horizontal_origin()` call
/// from `kill_buffer`'s fallback rebind → this row.*
#[test]
fn l8e_killing_the_displayed_buffer_clears_the_origin_and_the_latch() {
let (s, _) = scrolled_sideways();
let doomed = s.core.borrow().active_window().buffer_id;
s.core
.borrow_mut()
.kill_buffer(doomed)
.expect("the scratch buffer stands as a fallback");
assert_ne!(
s.core.borrow().active_window().buffer_id,
doomed,
"setup: the window must actually have been rebound, else \
this row measures nothing"
);
assert_eq!(
s.core.borrow().active_window().view_left,
0,
"a fallback rebind must not inherit the dead buffer's \
sideways viewport"
);
assert!(
!s.core.borrow().active_window().manual_left_authority,
"nor the authority defending it"
);
}
/// L8, replacement leg — the same for **`install_buffer_in_window`**,
/// the path that targets an explicit window rather than the active
/// one.

View File

@ -5416,6 +5416,7 @@ impl EditorCore {
win.cursor = 0;
win.selection = None;
win.view_top = 0;
win.forget_manual_horizontal_origin();
win.goal_col = None;
}
}

View File

@ -499,9 +499,25 @@ impl Window {
/// 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
/// One helper rather than a copy per site, so a new replacement
/// path gets the rule by calling it; each **call site** stays
/// individually removable, which is what keeps its own row honest.
///
/// **The census, taken by grepping every write of a window's
/// `buffer_id` rather than by recalling which paths exist** — an
/// earlier version of this doc said "three" and was wrong, because
/// it listed the paths someone had thought of. Four production
/// sites rebind a live window to a different buffer:
/// `EditorCore::switch_active_buffer_for`,
/// `EditorCore::install_buffer_in_window`,
/// `EditorCore::kill_buffer`'s fallback rebind, and the daemon's
/// `align_primary_document_window`. Each has its own row
/// (L8b–L8e).
///
/// `EditorCore::from_bytes` also assigns `buffer_id`, and is
/// **deliberately not on that list**: it builds a fresh core whose
/// window has no prior origin to inherit. Named here so the next
/// census does not have to re-decide it.
pub fn forget_manual_horizontal_origin(&mut self) {
self.view_left = 0;
self.manual_left_authority = false;