feat(gui-1b): B6's routing rows --- the minimap scrolls the document

B6's mechanism landed with B1: WheelTarget::Minimap maps to its own
ResidualOwner, and the horizontal leg already skips it. What was owed is
the evidence, and apply_wheel had none --- the accumulator had unit rows,
but nothing drove a wheel through dispatch_window_event to the effect.

Three rows on EffectHarness, which holds a real App and a real daemon
socket, so the classifier, the accumulator and the local step are all
production code:

- one notch over the minimap scrolls the document viewport by one notch
  and re-declares it, exactly as over text;
- a part-notch over the minimap does not complete one over the document
  --- the surface-switch jump B1 forbids, and the minimap is its sharpest
  case because both surfaces move the same viewport, so sharing a bank
  would look harmless and produce a jump nothing explains. A third step
  gives the document bank the rest of its notch, so the row cannot pass
  by measuring nothing;
- a horizontal notch over the minimap is inert while the same notch over
  text scrolls sideways.

All three mutations fire, disjointly: an inert arm for the minimap fires
the first, ResidualOwner::Document fires the second, dropping the
horizontal guard fires the third.

The third row is why EffectHarness now takes a document. Its first
version asserted inertness over the default fixture and the setup
assertion caught it twice: four-column lines pin the saturated right
bound to zero, and wrapping --- on by default --- pins the left edge to
zero regardless of target. Either would have reported inertness the row
never tested. It now uses wide lines and turns wrap off through
LineWrapFacts, the message production uses, asserting the wrap landed.
`new()` keeps the 200-line fixture and its M22 rationale verbatim.

Gates: fmt; clippy --workspace --all-targets -D warnings (which caught a
float_cmp in the first draft of the inertness assertion); pmacs-gpu 312;
--lib 1997; --lib --features crdt 2190; git diff --check.
This commit is contained in:
Levi Neuwirth 2026-09-01 20:29:48 +02:00
parent a0705399e2
commit 2dccc2b69c
No known key found for this signature in database
1 changed files with 234 additions and 8 deletions

View File

@ -4239,6 +4239,20 @@ impl EffectHarness {
/// failure; without it the harness still panics rather than skips,
/// because these rows are the whole of P2.
fn new() -> Self {
// A document tall enough to scroll. A two-line fixture made the
// wheel row pass vacuously: `scroll_by_lines` returns `None`
// when there is nothing below the fold, so the row asserted
// "every outbound event is a Viewport" over an EMPTY transcript.
// Mutation M22 surfaced the first half of that and this fixture
// is the second.
Self::with_document(&"line\n".repeat(200))
}
/// The same harness over a caller-chosen document, for rows whose
/// claim depends on the text's shape — B6's horizontal contrast
/// needs lines wider than the viewport, which the default fixture's
/// four columns can never provide.
fn with_document(document: &str) -> Self {
let (client_stream, mut daemon) =
std::os::unix::net::UnixStream::pair().expect("socketpair");
@ -4260,14 +4274,7 @@ impl EffectHarness {
.set_read_timeout(Some(Self::READ_CEILING))
.expect("arm the outbound read ceiling");
// A document tall enough to scroll. A two-line fixture made the
// wheel row pass vacuously: `scroll_by_lines` returns `None`
// when there is nothing below the fold, so the row asserted
// "every outbound event is a Viewport" over an EMPTY transcript.
// Mutation M22 surfaced the first half of that and this fixture
// is the second.
let document = "line\n".repeat(200);
let state = State::new_headless(640, 480, &document);
let state = State::new_headless(640, 480, document);
assert!(
state.is_some(),
"no wgpu adapter: the 1-pre effect rows are P2's only witness and must not be skipped"
@ -4985,6 +4992,225 @@ mod input_routing_tests {
);
}
/// A pixel inside the minimap band, and one inside the document
/// text. Both are asserted by their rows before use, so a fixture
/// whose geometry drifts fails loudly instead of quietly measuring
/// the wrong surface.
fn minimap_probe(h: &EffectHarness) -> (f64, f64) {
let state = h.app.state.as_ref().expect("harness state");
let left = minimap_left(state.config.width).expect("the fixture has a minimap band");
(f64::from(left + 2.0), f64::from(MINIMAP_TOP + 4.0))
}
fn document_probe(h: &EffectHarness) -> (f64, f64) {
let state = h.app.state.as_ref().expect("harness state");
(
f64::from(state.text_left() + 8.0),
f64::from(TEXT_TOP + 4.0),
)
}
fn move_pointer(h: &mut EffectHarness, (x, y): (f64, f64)) {
h.feed(&WindowEvent::CursorMoved {
device_id: DeviceId::dummy(),
position: PhysicalPosition::new(x, y),
});
}
fn wheel(dx: f32, dy: f32) -> WindowEvent {
WindowEvent::MouseWheel {
device_id: DeviceId::dummy(),
delta: MouseScrollDelta::LineDelta(dx, -dy),
phase: TouchPhase::Moved,
}
}
/// B6 — a wheel over the **minimap** scrolls the **document
/// viewport**, the same effect a wheel over the text has.
///
/// The minimap is not a scrollable surface of its own: it is a
/// picture of the document, and turning the wheel over a picture of
/// the document moves the document. Click and drag over it remain
/// scrub, which is a different gesture on the same pixels.
///
/// This is the routing half of B6. It reaches `apply_wheel` through
/// `dispatch_window_event`, so the classifier, the accumulator and
/// the local step are all production code here.
///
/// *Mutation: give `WheelTarget::Minimap` its own inert arm ahead of
/// the one that applies the local line step → this row. (Deleting it
/// from that arm outright would not compile, so the mutation run is
/// the compiling equivalent: the minimap reaches no line step.)*
#[test]
fn b6_a_wheel_over_the_minimap_scrolls_the_document_viewport() {
let mut h = EffectHarness::new();
let probe = minimap_probe(&h);
move_pointer(&mut h, probe);
assert_eq!(
h.app.classify_wheel_target(probe.0, probe.1),
WheelTarget::Minimap,
"setup: the probe pixel must be minimap, or this row measures \
the document twice"
);
let step = h.feed(&wheel(0.0, 1.0));
assert_eq!(
step.local,
vec![LocalEffect::Scroll {
top: WHEEL_LINES_PER_TICK as usize
}],
"one notch over the minimap moves the document one notch"
);
assert!(
!step.outbound.is_empty(),
"a minimap wheel must re-declare the viewport, like any other \
document scroll"
);
assert!(
step.outbound
.iter()
.all(|e| matches!(e, pmacs_protocol::FrontendEvent::Viewport { .. })),
"got {:?}",
step.outbound
);
}
/// B6 — the minimap banks into **its own** accumulator, so a
/// part-notch over it cannot complete a notch over the document.
///
/// This is the surface-switch case B1 exists to forbid, and the
/// minimap is its sharpest instance precisely *because* both
/// surfaces move the same viewport: sharing one bank would look
/// harmless and produce a jump the user's last gesture does not
/// explain. Two part-notches on different surfaces must stay two
/// part-notches.
///
/// The third step proves the row is not passing by measuring
/// nothing: the same document bank, given the rest of its notch,
/// does fire.
///
/// *Mutation: map `WheelTarget::Minimap` to `ResidualOwner::Document`
/// → this row, at the second step.*
#[test]
fn b6_a_part_notch_over_the_minimap_does_not_complete_one_over_the_document() {
let mut h = EffectHarness::new();
let minimap = minimap_probe(&h);
let document = document_probe(&h);
move_pointer(&mut h, minimap);
assert_eq!(
h.app.classify_wheel_target(minimap.0, minimap.1),
WheelTarget::Minimap,
"setup: minimap pixel"
);
let step = h.feed(&wheel(0.0, 0.6));
assert!(
step.local.is_empty() && step.outbound.is_empty(),
"0.6 of a notch is not a notch: {:?} {:?}",
step.local,
step.outbound
);
move_pointer(&mut h, document);
assert_eq!(
h.app.classify_wheel_target(document.0, document.1),
WheelTarget::Document,
"setup: document pixel"
);
let step = h.feed(&wheel(0.0, 0.6));
assert!(
step.local.is_empty() && step.outbound.is_empty(),
"the minimap's 0.6 must not have been waiting in the \
document's bank: {:?} {:?}",
step.local,
step.outbound
);
// The document's own bank still works: 0.6 + 0.6 completes it.
let step = h.feed(&wheel(0.0, 0.6));
assert_eq!(
step.local,
vec![LocalEffect::Scroll {
top: WHEEL_LINES_PER_TICK as usize
}],
"the document's accumulator must still accumulate, or the \
step above proves nothing"
);
}
/// B6 — the minimap's **horizontal** axis is inert.
///
/// It banks vertically like any other target, but a sideways notch
/// over a fixed-width picture of the document has nothing to mean,
/// so §2a's enumeration rules it inert. The contrast is the point:
/// the identical event over document text does scroll sideways.
///
/// *Mutation: drop the `!matches!(target, WheelTarget::Minimap)`
/// guard from the horizontal leg → this row.*
#[test]
fn b6_a_horizontal_wheel_over_the_minimap_is_inert() {
// Lines far wider than the viewport, so the saturated right
// bound leaves somewhere to scroll. The default fixture's four
// columns pin `max_left` to zero, which would make the contrast
// below vacuous — the setup assertion caught exactly that.
let mut h = EffectHarness::with_document(&format!("{}\n", "wide ".repeat(120)).repeat(200));
// Wrapping is on by default, and `scroll_by_columns` pins the
// left edge to zero while it is — so with wrap left alone, BOTH
// legs below would sit still and the row would report inertness
// it never tested. Turned off through the daemon message that
// production uses.
{
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: the wrap-off message must have landed on this buffer"
);
}
let minimap = minimap_probe(&h);
let document = document_probe(&h);
let left_of = |h: &EffectHarness| {
h.app
.state
.as_ref()
.expect("harness state")
.code_scroll_left
};
move_pointer(&mut h, minimap);
let before = left_of(&h);
h.feed(&wheel(1.0, 0.0));
// Unchanged, not merely small: any real horizontal scroll is at
// least one character advance, which is orders above this.
assert!(
(left_of(&h) - before).abs() < f32::EPSILON,
"a horizontal notch over the minimap must not move the \
document sideways: {before} -> {}",
left_of(&h)
);
// The same event over text, to show the delta was real and the
// row is not asserting that horizontal wheels do nothing at all.
move_pointer(&mut h, document);
h.feed(&wheel(1.0, 0.0));
assert!(
left_of(&h) > before,
"setup: the same notch over text must scroll, else the \
assertion above is vacuous"
);
}
/// P2, pointer — motion updates the cached pointer position, which
/// is the state mutation the drag path later reads. It is not a
/// `LocalEffect` variant because it is `State`-internal, so the row