feat(gui-1b): the GPU's clause 3, and why its L2 cannot witness the latch

The framing offers L2 --- wheel sideways, then a height-only resize ---
as the row that witnesses manual horizontal authority on the GPU. It
cannot. Measured before anything was added: the origin survives that
resize with `manual_left_authority` never read anywhere in the
frontend. What preserves it is Q#F6's painted-before policy. `resize`
runs `ensure_caret_painted` only when the caret was painted, and a
caret the user has scrolled off screen is not painted --- so the follow
that would snap the origin back never runs. Whenever the caret IS
painted it is inside the viewport, where `follow_left` returns the
origin it was handed. Either way the latch is unreachable.

What the GPU actually lacked was the other half of clause 3. Nothing
brought the origin DOWN when the maximum fell, for the same reason: the
follow that would is skipped in exactly that state. Measured: scroll to
the right bound at 640px, widen to 1600px, and the origin stayed 960px
past the new maximum --- most of the viewport blank with the text off
its left edge.

`clamp_code_scroll_left` at reshape's tail, beside B5's icon hook and
for the same reason: one point every geometry settle already passes
through. Gated on a non-zero origin, because it scans for the widest
line and most windows are never in this state.

L2 keeps both legs and says plainly that the first is required
behavior, not a witness of the latch. L5's GPU leg makes the stronger
statement available here: the wire stays silent, because on this
frontend moving point means telling the daemon.

One process note. The L5 mutation appeared not to fire; it had failed
to compile. A mutation that does not build produces exactly the output
of a test that passes, and only reading past the grep separated them.
Both were re-run after the mutant compiled: dropping the clamp fires
L2's widening leg, an off-by-one fires its exact bound, and a wire
event on the horizontal leg fires L5.

Still owed: the framing's L2 wording, which promises a witness this
frontend cannot provide.

Gates: fmt; clippy --workspace --all-targets -D warnings; pmacs-gpu
314; --lib 2009; --lib --features crdt 2202; git diff --check.
This commit is contained in:
Levi Neuwirth 2026-09-02 00:35:39 +02:00
parent feda8517f8
commit ec6444ed25
No known key found for this signature in database
1 changed files with 192 additions and 0 deletions

View File

@ -5075,6 +5075,63 @@ mod input_routing_tests {
); );
} }
/// L5, GPU leg — a horizontal wheel moves the **viewport only**.
///
/// Q#S1-11 ruled (B): carrying point would be a new wire operation,
/// which 1b's non-protocol scope forbids. The TUI leg asserts point
/// and selection directly; here the stronger statement is available
/// — **the wire stays silent**, because on this frontend moving
/// point means telling the daemon.
///
/// *Mutation: have the horizontal leg send a cursor event → this
/// row.*
#[test]
fn l5_a_horizontal_wheel_on_the_gpu_moves_neither_point_nor_the_wire() {
let mut h = EffectHarness::with_document(&format!("{}\n", "wide ".repeat(120)).repeat(200));
{
let buffer_id = h
.app
.state
.as_ref()
.expect("harness state")
.current_buffer_id
.expect("the harness stands in a buffer");
let state = h.app.state.as_mut().expect("harness state");
let _ = state.apply_attach_message(InstanceMessage::LineWrapFacts {
buffer_id,
wrap: false,
});
assert_eq!(state.buffer.wrap(), Wrap::None, "setup: wrap is off");
}
let document = document_probe(&h);
move_pointer(&mut h, document);
assert_eq!(
h.app.classify_wheel_target(document.0, document.1),
WheelTarget::Document,
"setup: document text"
);
let cursor_before = h.app.state.as_ref().expect("state").own_cursor;
let step = h.feed(&wheel(1.0, 0.0));
assert!(
h.app.state.as_ref().expect("state").code_scroll_left > 0.0,
"setup: the notch must actually have scrolled, or this row \
passes by doing nothing"
);
assert_eq!(
h.app.state.as_ref().expect("state").own_cursor,
cursor_before,
"the horizontal wheel is a viewport gesture"
);
assert!(
step.outbound.is_empty(),
"and it tells the daemon nothing: moving point here would be \
a wire operation, got {:?}",
step.outbound
);
}
/// B6 — the minimap banks into **its own** accumulator, so a /// B6 — the minimap banks into **its own** accumulator, so a
/// part-notch over it cannot complete a notch over the document. /// part-notch over it cannot complete a notch over the document.
/// ///
@ -11000,9 +11057,51 @@ impl State {
// them all at once instead of leaving each new geometry path to // them all at once instead of leaving each new geometry path to
// remember a call it will not remember. // remember a call it will not remember.
self.apply_panel_cursor_icon(); self.apply_panel_cursor_icon();
// GUI Stage 1b, lifetime clause 3 — the horizontal origin is
// **clamped** by the same settling, for the same reason.
self.clamp_code_scroll_left();
self.request_redraw(); self.request_redraw();
} }
/// Bring the horizontal origin back inside `0 ..= widest − viewport`
/// (lifetime clause 3).
///
/// Geometry and content both move that bound: a **wider** viewport
/// lowers it, and so does a shortened widest line.
///
/// **Nothing else brings the origin down.** `horizontal_follow`
/// would, but it runs only when the caret is painted (Q#F6's
/// painted-before policy) — and the caret is not painted precisely
/// when the user has scrolled it off screen, which is exactly the
/// state a stale origin survives in. Measured before this existed:
/// after a scroll to the right bound at 640px and a widen to
/// 1600px, the origin stayed 960px past the new maximum, leaving
/// most of the viewport blank with the text off its left edge.
///
/// Gated on a non-zero origin because it scans the document for the
/// widest line, and every reshape paying for that would be a steep
/// price for a state most windows are never in.
fn clamp_code_scroll_left(&mut self) {
if self.code_scroll_left <= 0.0 {
return;
}
if self.buffer.wrap() != Wrap::None {
self.code_scroll_left = 0.0;
return;
}
let advance = self.mono_advance();
let width = self.text_bounds_right() as f32 - self.text_left();
if advance <= 0.0 || width <= 0.0 {
return;
}
let cols = (width / advance).floor().max(0.0) as u32;
let max_left = self.widest_display_columns().saturating_sub(cols);
let current = (self.code_scroll_left / advance).round().max(0.0) as u32;
if current > max_left {
self.code_scroll_left = max_left as f32 * advance;
}
}
/// Ask the window to repaint. A no-op headless (no window), where the /// Ask the window to repaint. A no-op headless (no window), where the
/// render tests drive `render_offscreen` directly (F-014). /// render tests drive `render_offscreen` directly (F-014).
fn request_redraw(&self) { fn request_redraw(&self) {
@ -15828,6 +15927,99 @@ mod tests {
); );
} }
/// L2 — **the GPU's horizontal origin across geometry changes.**
///
/// Two legs, and the framing's expectation about the first was
/// wrong in a way worth recording.
///
/// *Preserved by a height-only resize.* The framing offers this as
/// the row that witnesses **manual authority** on this frontend. It
/// does not, and cannot: measured at the head that introduced this
/// row, the origin survives a height-only resize with
/// `manual_left_authority` **never read anywhere in the frontend**.
/// What preserves it is Q#F6's painted-before policy — `resize`
/// runs `ensure_caret_painted` only when the caret was painted, and
/// a caret the user has scrolled off screen is not painted. So the
/// follow that would snap the origin back never runs. The leg is
/// kept because the behaviour is required; it is documented here as
/// **not** a witness of the latch.
///
/// *Clamped by a widening resize* (clause 3). A wider viewport
/// LOWERS the maximum `widest − viewport`, and nothing else brings
/// the origin down — precisely because the follow is skipped in
/// this state. Before `clamp_code_scroll_left` existed, a scroll to
/// the right bound at 640px followed by a widen to 1600px left the
/// origin **960px past the new maximum**, most of the viewport
/// blank and the text off its left edge.
///
/// *Mutation: drop `clamp_code_scroll_left()` from `reshape`'s tail
/// → the widening leg. Clamp to `max_left - 1` → its exact bound.*
#[test]
fn l2_the_horizontal_origin_survives_height_and_is_clamped_by_width() {
let mut text = "w".repeat(400);
text.push('\n');
for _ in 0..200 {
text.push_str("filler\n");
}
let Some(mut state) = State::new_headless(640, 480, &text) else {
return;
};
let bid = BufferId::next();
state.current_buffer_id = Some(bid);
state.own_cursor = Some(OwnCursor {
buffer_id: bid,
byte: 0,
});
// Wrapping is on by default and pins the origin to zero, so
// without this both legs would measure nothing.
let _ = state.apply_attach_message(InstanceMessage::LineWrapFacts {
buffer_id: bid,
wrap: false,
});
assert_eq!(state.buffer.wrap(), Wrap::None, "setup: wrap is off");
// Out to the right bound.
state.scroll_by_columns(1000);
let scrolled = state.code_scroll_left;
assert!(scrolled > 0.0, "setup: the origin must have moved");
assert!(
!state.caret_painted_in_code_clip(),
"setup: the caret is off screen, which is the state in which \
the follow is skipped — and so the state the origin has to \
survive on its own"
);
// Leg 1: a height-only resize leaves the origin alone.
state.resize(640, 600);
assert!(
(state.code_scroll_left - scrolled).abs() < f32::EPSILON,
"a taller window is not a horizontal event: {scrolled} -> {}",
state.code_scroll_left
);
// Leg 2: a wider one lowers the maximum, and the origin comes
// down to meet it — exactly, not merely somewhere lower.
state.resize(1600, 600);
let advance = state.mono_advance();
let width = state.text_bounds_right() as f32 - state.text_left();
let cols = (width / advance).floor().max(0.0) as u32;
let max_left = state.widest_display_columns().saturating_sub(cols);
assert!(
state.code_scroll_left < scrolled,
"a wider viewport must bring the origin down"
);
assert!(
(state.code_scroll_left - max_left as f32 * advance).abs() < advance / 2.0,
"and it must land on `widest − viewport` exactly: {} vs {}",
state.code_scroll_left,
max_left as f32 * advance
);
assert!(
state.code_scroll_left > 0.0,
"clamped, NOT discarded — the gesture survives at the new bound"
);
}
/// B5 — an open context menu owns its pixels, and they are not text. /// B5 — an open context menu owns its pixels, and they are not text.
/// ///
/// The decision half of the lifecycle row above, kept separate so a /// The decision half of the lifecycle row above, kept separate so a