From ec6444ed25a6163a018d2e83a38856d6d2ebb1a6 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 2 Sep 2026 00:35:39 +0200 Subject: [PATCH] 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. --- pmacs-gpu/src/main.rs | 192 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index a0af0b4..eb798dc 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -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 /// 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 // remember a call it will not remember. 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(); } + /// 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 /// render tests drive `render_offscreen` directly (F-014). 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. /// /// The decision half of the lifecycle row above, kept separate so a