From 8310431497c48ad7c50102ddda2dbb2e264c8e2d Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Tue, 8 Sep 2026 13:46:39 -0400 Subject: [PATCH] iris: pin the nested-scroll axis rule, and record the capture fix in RUST.md Co-Authored-By: Claude Opus 5 --- docs/RUST.md | 59 ++++++++++++++++++++++++++++++ iris/src/sense_tests.rs | 80 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) diff --git a/docs/RUST.md b/docs/RUST.md index eb7683e..b5d3514 100644 --- a/docs/RUST.md +++ b/docs/RUST.md @@ -7885,6 +7885,65 @@ arbiter may never have seen the `press_start` (`DragArbiter::update`'s doc records that recovery path for `Selection`). Say what the arbitration actually is rather than adding a second recovery. +#### Done, 2026-09-08 (b863f9f and the commit after it) + +Both symptoms were one thing each, and neither was the arbitration: the +axis test in `DragArbiter` was already right, so a vertical drag on a +fence always did fall through. What was wrong was **how a gesture ends**. + +- **The snap back while scrolling horizontally.** `scrollable_on` + registered `click_or_drag() | unclick()`. A `Scroll` that commits to a + pan takes pointer capture, and a captured widget's gesture ends with + `CursorSense::Drop` in place of `PressEnd` -- which `should_run` only + matches for a widget that registered `Drop`. It had not, so it never + learned its own gesture had ended: the `DragArbiter` stayed `Panning` + with `last` at the position the finger left, and the *next* drag's + first frame was measured from there and applied in one step. Now + `CursorSense::drag_senses()` (frames + `unclick` + `Drop` + `Cancel`) + is what every widget driving a `DragGesture` registers, stated once + rather than per call site. Test: + `a_scroll_area_that_captured_the_pointer_learns_its_gesture_ended`, + which fails with the old registration reporting exactly the symptom + ("a fresh touch-down moved the content by -40"). +- **The jump on a tap.** Taking pointer capture is a one-way door for + everybody else: from that frame `run_sensors` delivers to the capturer + alone, so anything else tracking the same press sees no `PressEnd` and + no `Drop` either. The transcript's own `DragGesture` was therefore left + `Undecided` at the origin of the gesture the fence stole, and the next + touch anywhere on screen was measured from it -- `Undecided`'s slop + test crossed instantly, and the list panned by the distance between two + unrelated fingers. `CursorSense::Cancel` is the state that was missing: + delivered exactly once to each loser of a capture race, the way Android + sends `ACTION_CANCEL` and the web sends `pointercancel`. It is a + separate sense from `Drop` deliberately -- `Drop` means "your gesture + finished" and callers act on it (a fling, a tap, a link followed), + which is precisely the wrong thing here. `GestureOutcome::Cancelled` + is its half of the enum, and `attr.rs`'s `press_track` registers it + too, since a `press_origin` left set has the same failure (a stray + selection, or a keyboard summoned by a tap somewhere else). Test: + `taking_the_pointer_cancels_everyone_else_tracking_the_press`. +- **The direction the change had no reason to touch**, pinned so it stays + true: `a_drag_pans_whichever_nested_scroll_area_owns_its_axis` drives a + real X area inside a real Y one, both through `scrollable_on`'s own + registration, and checks each drag moves one and leaves the other at 0. + +**Where the pointer's state lives now.** Iris, on seeing this work: +"never reach for atomics or locking stuff first, and if you genuinely +think it's necessary, bring it up with me first", and "everything global +should be stored in the general input handler, not in specific senses +with locking stuff." So the captured widget and the pressed set are no +longer a `std::sync::Mutex` on `UiRenderState` (a structure that merely +happened to be reachable from every handler). They are `PointerInput`, +the cursor senses' `Event::Global` -- owned by the event manager that +runs the dispatch and reached by `&mut`, with `run_sensors` taking it out +for the length of a pass the same way it already takes the active-sensor +map, and a per-dispatch `PointerRequests` slot for what a handler asks +of it. What had forced the lock in the first place was a `Data: Send` +bound on `task_on` that nothing needs: the future it spawns takes +`|_, rsc|` and never sees the event's data at all. **Still a `Mutex` and +worth revisiting: `UiRenderState::last_input_at`**, for the same +`&self` reason, untouched here. + ### 2. "Opening the folded cards seems pretty buggy right now" -- and the ### sanity check she asked for diff --git a/iris/src/sense_tests.rs b/iris/src/sense_tests.rs index 35c2254..cf749b6 100644 --- a/iris/src/sense_tests.rs +++ b/iris/src/sense_tests.rs @@ -536,3 +536,83 @@ fn taking_the_pointer_cancels_everyone_else_tracking_the_press() { normally -- acting on that is the tap it never made" ); } + +/// Iris's rule for nested scrolling, 2026-09-08: "it should only trigger +/// horizontal if you drag left or right, and vertical should fall through +/// if you drag up or down." +/// +/// One mechanism does both, and it is `DragArbiter`'s existing axis test: +/// each scroll area's gesture commits only on its own axis, so a drag +/// along the other one is never claimed and the enclosing area's gesture +/// -- which sees the same press, being an ancestor rather than a sibling +/// layer -- is the one that commits and captures. This pins the pair, +/// including the direction the change had no reason to touch. +#[test] +fn a_drag_pans_whichever_nested_scroll_area_owns_its_axis() { + for (name, to, pans, still) in [ + ("vertical", Vec2::new(50.0, 80.0 - (DRAG_SLOP + 40.0)), 0, 1), + ( + "horizontal", + Vec2::new(50.0 - (DRAG_SLOP + 40.0), 80.0), + 1, + 0, + ), + ] { + let mut rsc = SenseRsc { + ui: UiData::default(), + events: EventManager::default(), + }; + // 1000px square of content in a 100px window: room to pan either + // way, in an X area inside a Y one. + let seen = Rc::new(Cell::new(None)); + let record = seen.clone(); + let outer_strong = rect(UiColor::WHITE) + .width(Len::abs(1000.0)) + .height(Len::abs(1000.0)) + .scrollable_on(Axis::X) + // The inner area's own handle, taken as the chain is built -- + // the whole point is to exercise `scrollable_on`'s real + // registration on both, so neither is assembled by hand. + .with_id(move |_rsc, id| { + record.set(Some(id)); + id + }) + .scrollable() + .add_strong(&mut rsc); + let inner = seen.get().unwrap(); + let outer = outer_strong.weak(); + let root = outer_strong.any(); + let areas = [outer, inner]; + + let mut render = UiRenderState::new(); + render.resize((100.0, 100.0)); + render.update(&root, &mut rsc); + // The second frame, where each area knows its content length -- + // LAYOUT.md section 4's one-frame lag, and what drops `snap_end`. + for a in areas { + rsc.ui.widgets.get_mut(&a).unwrap().scroll(0.0); + } + render.update(&root, &mut rsc); + + let mut state = (); + let win = Vec2::new(100.0, 100.0); + let mut down = cursor_at((50.0, 80.0).into()); + down.buttons.left = ActivationState::Start; + render.run_sensors(&mut rsc, &mut state, down, win); + let mut drag = cursor_at(to); + drag.buttons.left = ActivationState::On; + render.run_sensors(&mut rsc, &mut state, drag, win); + + let moved = rsc.ui.widgets.get(&areas[pans]).unwrap().amt(); + let unmoved = rsc.ui.widgets.get(&areas[still]).unwrap().amt(); + assert!( + (moved - 40.0).abs() < 0.01, + "a {name} drag should have panned the {name} area by the 40px \ + past the slop, got {moved}" + ); + assert_eq!( + unmoved, 0.0, + "a {name} drag must not move the area that owns the other axis" + ); + } +}