iris: pin the nested-scroll axis rule, and record the capture fix in RUST.md
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b863f9f3df
commit
8310431497
2 files changed
+139
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user