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
|
records that recovery path for `Selection`). Say what the arbitration
|
||||||
actually is rather than adding a second recovery.
|
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
|
### 2. "Opening the folded cards seems pretty buggy right now" -- and the
|
||||||
### sanity check she asked for
|
### 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"
|
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