iris: a finger put down on a moving list catches it at that sample

Iris, from the phone (docs/IRIS_TODO.md, 2026-09-07 night): "sometimes
when I try to catch it while it's still moving (particularly if I drag)
then it fails to stop & snap to where finger is." The fling did stop on
the down -- `Selection::drag` has cancelled it since the fling landed --
but the *gesture* then went through `DRAG_SLOP` like any other press, so
for the first few frames the finger was down and the content under it
did not move. Compose does not do that: `scrollable`'s
`startDragImmediately` is `isScrollInProgress`, and the drag starts on
the DOWN with no slop.

So `DragArbiter::press_start` takes a `PressState` -- what the target
looked like when the press landed, `already_selected` and `scrolling` --
and a press on moving content enters `Panning` immediately. A catch that
is released without ever moving is `Released(None)`: not a `Tapped`,
because Compose consumes that DOWN and no click detector under it sees
the gesture, so stopping a fling must not also follow the link it landed
on; and not a velocity, because there is none to hand on. The moment it
moves anything it is an ordinary pan release again and flings normally.

`DragGesture::starts_press` is the one rule for "this frame opens a
press", read by `handle` and by `Selection::drag` -- which has to prepare
its list (cancel the fling, report whether there was one) on exactly the
frames `handle` will call `press_start` on, including the recovery frames
where no `PressStart` ever arrived.

It deliberately does **not** special-case `PressStart` to true, which is
the defect the layer-1 test found: one touch-down reaches every sensor
under the finger, and a transcript row's block and the tool row
containing it drive the same shared `DragGesture`, so `handle` sees one
`PressStart` twice. Restarting on the second delivery re-read
`PressState` after the first had already acted on it -- the fling was
cancelled by then, `scrolling` came back false, and every catch quietly
became an ordinary slop-waiting press again.

Tests. Layer 1, `transcript-fixture/tests/catch_a_fling.rs`: the
recorded 120Hz flick, 150ms of fling, then a down and three 2px moves --
the content tracks the finger sample for sample
(`a_press_on_a_flinging_list_pins_the_content_to_the_finger`, which
fails at the parent commit with "the content 0.0px"); a catch released
without moving neither taps nor flings; and the half this had no reason
to touch, `the_same_small_drag_on_a_settled_list_moves_nothing` -- 6px
total is inside `DRAG_SLOP`, so making every press pin the content would
pass the first test and take the slop away from every ordinary one.
Unit, in `sense.rs`: the catch pans from the first sample, the same
press on settled content stays undecided, a catch that drags still
flings, and the double-delivered `PressStart` stays one press.
This commit is contained in:
iris committed 2026-09-07 22:18:16 -04:00
1 parent 80a75c128e
commit b87f5a597e
4 files changed
+613 -81

No files matched your search

+376 -67
View File
@@ -600,6 +600,25 @@ pub enum DragOutcome {
SelectExtend,
}
/// What the thing a press landed on looked like at the moment it landed
/// -- the two facts a [`DragArbiter`] cannot see for itself and that
/// decide what the press is allowed to become. One struct rather than two
/// boolean parameters because they are read together, on exactly one call
/// (`press_start`), and a bare `false, false` at a call site says nothing
/// about which is which.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct PressState {
/// Whether anything was already selected *before* this press -- it
/// decides whether an early horizontal move extends that selection
/// instead of waiting for a long-press.
pub already_selected: bool,
/// Whether the target was already moving under its own momentum (a
/// `List` with a fling in flight, `List::is_scrolling`). See
/// [`DragArbiter::press_start`]: a press on moving content is a catch,
/// and catches skip the slop entirely.
pub scrolling: bool,
}
#[derive(Clone, Copy, PartialEq)]
enum ArbiterState {
Idle,
@@ -665,15 +684,31 @@ impl DragArbiter {
}
}
/// A fresh press-down at `pos`. `already_selected` is whatever the
/// caller's selection state was *before* this press -- it decides
/// whether an early horizontal move extends that selection instead of
/// waiting for a long-press.
pub fn press_start(&mut self, pos: Vec2, now: Instant, already_selected: bool) {
/// A fresh press-down at `pos`, with whatever the caller's target
/// looked like at that moment ([`PressState`]).
///
/// `press.scrolling` short-circuits the whole decision: the press
/// commits to panning on this very sample, with no [`DRAG_SLOP`] and
/// no long-press timer, because a finger put down on content that is
/// already moving means "stop it here" and nothing else. That is
/// Compose's `scrollable`, whose `startDragImmediately` is
/// `ScrollingLogic.shouldScrollImmediately()` --
/// `scrollableState.isScrollInProgress` -- and whose
/// `DragGestureNode.processInitialDownState` then consumes the DOWN
/// on the `Initial` pass and calls `sendDragStart` +
/// `sendDragEvent(Offset.Zero)` on the `Main` pass of that same
/// event, rather than moving to its await-touch-slop state.
pub fn press_start(&mut self, pos: Vec2, now: Instant, press: PressState) {
self.origin = pos;
self.origin_at = now;
self.last = pos;
self.state = ArbiterState::Undecided { already_selected };
self.state = if press.scrolling {
ArbiterState::Panning
} else {
ArbiterState::Undecided {
already_selected: press.already_selected,
}
};
}
/// Whether this arbiter has no press in flight -- either it has never
@@ -849,6 +884,14 @@ pub enum GestureOutcome {
pub struct DragGesture {
arbiter: DragArbiter,
velocity: VelocityTracker,
/// Set when a press began as a *catch* (`PressState::scrolling`) and
/// cleared by the first frame that actually moves the content. While
/// it is set the gesture is a pan that has panned nothing, so its
/// release is `Released(None)`: not a `Tapped`, because Compose's
/// scrollable consumed that DOWN and no click or long-press detector
/// under it ever saw the gesture at all, and not a
/// `Released(Some(v))`, because there is no velocity to hand on.
catch_unmoved: bool,
}
impl Default for DragGesture {
@@ -867,6 +910,7 @@ impl DragGesture {
Self {
arbiter: DragArbiter::on(axis),
velocity: VelocityTracker::new(),
catch_unmoved: false,
}
}
@@ -879,14 +923,42 @@ impl DragGesture {
self.arbiter.is_idle()
}
/// Whether feeding `sense` to [`Self::handle`] right now would begin
/// a **new press**: any frame that is not a release, arriving while
/// no press is in flight. That covers a `PressStart` and equally the
/// first `Pressing` frame of a gesture whose `PressStart` never
/// arrived (`handle`'s recovery branch below). A caller reads this to
/// prepare the thing being dragged on exactly the frames `handle`
/// will call `DragArbiter::press_start` --
/// `transcript_ui::Selection::drag` stops its list's fling and
/// reports whether there was one -- rather than keeping its own copy
/// of that rule, which is the one place the two could disagree.
///
/// **A `PressStart` is not special-cased to `true`**, which it was
/// for one afternoon: one touch-down reaches every sensor under the
/// finger, and a transcript row's block and the tool row containing
/// it both drive this same shared `DragGesture`, so `handle` sees one
/// `PressStart` twice. Restarting on the second delivery re-reads the
/// caller's [`PressState`] *after* the first delivery has already
/// acted on it -- the list's fling is cancelled by then, so
/// `scrolling` comes back false and a catch silently becomes an
/// ordinary slop-waiting press. The second delivery is a continuation
/// of a press already in flight, and this says so.
pub fn starts_press(&self, sense: CursorSense) -> bool {
match sense {
CursorSense::Drop | CursorSense::PressEnd(_) => false,
_ => self.arbiter.is_idle(),
}
}
/// Feed one frame of a gesture through. `id` is the widget iris should
/// give exclusive pointer input to once this gesture commits to
/// panning or selecting -- a stable widget that outlives the gesture
/// (a `List`'s own id, not one of its virtualised rows, which can be
/// retired mid-drag as content scrolls). `render` is `CursorData`'s
/// own field, already in hand at every call site. `already_selected`
/// only matters for the first frame of a gesture (`PressStart`, or the
/// recovery branch below) -- see `DragArbiter::press_start`'s doc.
/// own field, already in hand at every call site. `press` only matters
/// on the frames [`Self::starts_press`] answers true for -- see
/// `DragArbiter::press_start`'s doc.
pub fn handle(
&mut self,
render: &UiRenderState,
@@ -894,35 +966,25 @@ impl DragGesture {
sense: CursorSense,
pos_window: Vec2,
now: Instant,
already_selected: bool,
press: PressState,
) -> GestureOutcome {
match sense {
CursorSense::PressStart(_) => {
self.velocity.reset();
// Where the finger went down is a sample, exactly as
// Compose's `DragGestureNode.sendDragStart` feeds the DOWN
// change to its tracker before any move. It is one of the
// three a fit needs, and it is the one that fixes the
// origin of the curve; without it a 120Hz flick delivering
// its whole motion in two frames has too few points and
// does not fling at all.
self.velocity
.add_position(pos_window.axis(self.arbiter.axis()), now);
self.arbiter.press_start(pos_window, now, already_selected);
if crate::diagnostics::trace_enabled() {
log::debug!(
target: "iris::input",
"iris gesture: press start pos=({:.1},{:.1})",
pos_window.x, pos_window.y,
);
}
self.dispatch(render, id, pos_window, now)
}
CursorSense::Drop | CursorSense::PressEnd(_) => {
// Once: a `velocity()` is a full Lsq2 fit, and the log
// line below wants the same number the outcome carries.
let released = self.velocity.velocity();
let outcome = if self.arbiter.is_panning() {
// `catch_unmoved` is only ever set beside `Panning` (a
// catch enters it on the down) and only ever left set
// while nothing has moved, so `Panning` is the one state
// it can be observed in. If that stops holding, the
// branch below is silently swallowing a tap.
debug_assert!(
!self.catch_unmoved || self.arbiter.is_panning(),
"a caught press that never moved must still be panning at release",
);
let outcome = if self.catch_unmoved {
GestureOutcome::Released(None)
} else if self.arbiter.is_panning() {
GestureOutcome::Released(Some(released))
} else if self.arbiter.is_undecided() {
GestureOutcome::Tapped
@@ -959,22 +1021,39 @@ impl DragGesture {
);
}
self.arbiter.release();
self.catch_unmoved = false;
render.release_pointer();
outcome
}
// See `DragArbiter::update`'s own doc: a `Pressing` frame can
// arrive with no matching `PressStart` if the touch-down
// landed outside whichever hit region first noticed it.
_ if self.arbiter.is_idle() => {
// A `Pressing` frame can arrive with no matching `PressStart`
// if the touch-down landed outside whichever hit region first
// noticed it -- `DragArbiter::update`'s own doc. Both that
// recovery and an ordinary `PressStart` open a press the same
// way, so they are one branch: `starts_press` is the rule, and
// it is the same one the caller reads.
_ if self.starts_press(sense) => {
self.velocity.reset();
// Where the finger went down is a sample, exactly as
// Compose's `DragGestureNode.sendDragStart` feeds the DOWN
// change to its tracker before any move. It is one of the
// three a fit needs, and it is the one that fixes the
// origin of the curve; without it a 120Hz flick delivering
// its whole motion in two frames has too few points and
// does not fling at all.
self.velocity
.add_position(pos_window.axis(self.arbiter.axis()), now);
self.arbiter.press_start(pos_window, now, already_selected);
self.arbiter.press_start(pos_window, now, press);
self.catch_unmoved = press.scrolling;
if crate::diagnostics::trace_enabled() {
let how = if matches!(sense, CursorSense::PressStart(_)) {
""
} else {
" (recovered, no PressStart seen)"
};
log::debug!(
target: "iris::input",
"iris gesture: press start (recovered, no PressStart seen) pos=({:.1},{:.1})",
pos_window.x, pos_window.y,
"iris gesture: press start{how} pos=({:.1},{:.1}) scrolling={}",
pos_window.x, pos_window.y, press.scrolling,
);
}
self.dispatch(render, id, pos_window, now)
@@ -994,6 +1073,11 @@ impl DragGesture {
DragOutcome::Undecided => GestureOutcome::Undecided,
DragOutcome::Pan(dy) => {
render.capture_pointer(id);
if dy != 0.0 {
// The catch has moved something, so its release is an
// ordinary pan release again -- see `catch_unmoved`.
self.catch_unmoved = false;
}
// The raw position, not `dy`: `dy` has the touch slop
// subtracted out of the frame that crossed it, and the
// tracker fits a curve through where the finger *was*.
@@ -1922,14 +2006,14 @@ mod drag_arbiter_tests {
#[test]
fn small_jitter_stays_undecided() {
let mut a = DragArbiter::new();
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
a.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default());
assert_eq!(a.update(Vec2::new(1.0, 1.0), t(10)), DragOutcome::Undecided);
}
#[test]
fn a_vertical_drag_pans_immediately() {
let mut a = DragArbiter::new();
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
a.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default());
// The transition frame applies only the motion past `DRAG_SLOP`
// (20 - 8 = 12), not the full 20px since `press_start` -- see the
// `Pan` arm's own comment for why replaying the whole withheld
@@ -1954,7 +2038,7 @@ mod drag_arbiter_tests {
#[test]
fn crossing_the_slop_by_a_little_pans_by_a_little() {
let mut a = DragArbiter::new();
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
a.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default());
assert_eq!(
a.update(Vec2::new(0.0, DRAG_SLOP + 0.5), t(10)),
DragOutcome::Pan(0.5)
@@ -1964,7 +2048,7 @@ mod drag_arbiter_tests {
#[test]
fn a_horizontal_drag_with_nothing_selected_does_not_select() {
let mut a = DragArbiter::new();
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
a.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default());
// Horizontal movement alone, with no prior selection, is not any
// of the three named gestures -- it stays undecided rather than
// guessing (it will resolve to a long-press-selection if the
@@ -1978,7 +2062,7 @@ mod drag_arbiter_tests {
#[test]
fn a_long_press_without_moving_starts_a_selection() {
let mut a = DragArbiter::new();
a.press_start(Vec2::new(5.0, 5.0), t(0), false);
a.press_start(Vec2::new(5.0, 5.0), t(0), PressState::default());
assert_eq!(a.update(Vec2::new(5.0, 5.0), t(10)), DragOutcome::Undecided);
assert_eq!(
a.update(Vec2::new(6.0, 5.0), t(LONG_PRESS.as_millis() as u64 + 1)),
@@ -1989,7 +2073,7 @@ mod drag_arbiter_tests {
#[test]
fn after_a_long_press_any_further_drag_extends() {
let mut a = DragArbiter::new();
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
a.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default());
assert_eq!(
a.update(Vec2::new(0.0, 0.0), t(LONG_PRESS.as_millis() as u64 + 1)),
DragOutcome::SelectStart
@@ -2006,7 +2090,14 @@ mod drag_arbiter_tests {
#[test]
fn a_horizontal_drag_on_already_selected_text_extends_immediately() {
let mut a = DragArbiter::new();
a.press_start(Vec2::new(0.0, 0.0), t(0), true);
a.press_start(
Vec2::new(0.0, 0.0),
t(0),
PressState {
already_selected: true,
..Default::default()
},
);
assert_eq!(
a.update(Vec2::new(20.0, 2.0), t(10)),
DragOutcome::SelectExtend
@@ -2016,7 +2107,14 @@ mod drag_arbiter_tests {
#[test]
fn a_vertical_drag_still_pans_even_with_a_prior_selection() {
let mut a = DragArbiter::new();
a.press_start(Vec2::new(0.0, 0.0), t(0), true);
a.press_start(
Vec2::new(0.0, 0.0),
t(0),
PressState {
already_selected: true,
..Default::default()
},
);
assert_eq!(
a.update(Vec2::new(0.0, 20.0), t(10)),
DragOutcome::Pan(12.0)
@@ -2059,7 +2157,7 @@ mod drag_arbiter_tests {
// Simulates the real call site: a `Pressing` frame arrives with no
// matching `PressStart` ever having reached this arbiter.
assert!(a.is_idle());
a.press_start(Vec2::new(0.0, 700.0), t(0), false);
a.press_start(Vec2::new(0.0, 700.0), t(0), PressState::default());
assert_eq!(
a.update(Vec2::new(0.0, 720.0), t(10)),
DragOutcome::Pan(12.0)
@@ -2074,7 +2172,7 @@ mod drag_arbiter_tests {
#[test]
fn a_press_that_never_moved_is_still_undecided_at_release() {
let mut a = DragArbiter::new();
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
a.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default());
a.update(Vec2::new(1.0, 1.0), t(10));
assert!(a.is_undecided());
assert!(!a.is_panning());
@@ -2086,7 +2184,7 @@ mod drag_arbiter_tests {
#[test]
fn a_press_that_panned_is_not_undecided_at_release() {
let mut a = DragArbiter::new();
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
a.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default());
a.update(Vec2::new(0.0, 40.0), t(10));
assert!(a.is_panning());
assert!(!a.is_undecided());
@@ -2096,7 +2194,7 @@ mod drag_arbiter_tests {
#[test]
fn a_long_press_that_selected_is_not_undecided() {
let mut a = DragArbiter::new();
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
a.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default());
assert_eq!(
a.update(Vec2::new(0.0, 1.0), t(LONG_PRESS.as_millis() as u64 + 10)),
DragOutcome::SelectStart
@@ -2111,14 +2209,14 @@ mod drag_arbiter_tests {
#[test]
fn a_horizontal_arbiter_pans_on_the_drag_a_vertical_one_ignores() {
let mut across = DragArbiter::on(Axis::X);
across.press_start(Vec2::new(0.0, 0.0), t(0), false);
across.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default());
assert_eq!(
across.update(Vec2::new(20.0, 0.0), t(10)),
DragOutcome::Pan(12.0)
);
let mut down = DragArbiter::new();
down.press_start(Vec2::new(0.0, 0.0), t(0), false);
down.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default());
assert_eq!(
down.update(Vec2::new(20.0, 0.0), t(10)),
DragOutcome::Undecided
@@ -2128,7 +2226,7 @@ mod drag_arbiter_tests {
// which is what lets the list behind a code fence still be
// panned by a finger that started on the fence.
let mut across = DragArbiter::on(Axis::X);
across.press_start(Vec2::new(0.0, 0.0), t(0), false);
across.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default());
assert_eq!(
across.update(Vec2::new(0.0, 20.0), t(10)),
DragOutcome::Undecided
@@ -2138,7 +2236,7 @@ mod drag_arbiter_tests {
#[test]
fn release_resets_to_idle() {
let mut a = DragArbiter::new();
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
a.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default());
a.update(Vec2::new(0.0, 20.0), t(10));
a.release();
assert_eq!(
@@ -2197,7 +2295,7 @@ mod drag_gesture_tests {
CursorSense::PressStart(CursorButton::Left),
Vec2::ZERO,
t(0),
false,
PressState::default(),
);
g.handle(
&r,
@@ -2205,7 +2303,7 @@ mod drag_gesture_tests {
CursorSense::Pressing(CursorButton::Left),
Vec2::new(0.0, 100.0),
t(8),
false,
PressState::default(),
);
g.handle(
&r,
@@ -2213,7 +2311,7 @@ mod drag_gesture_tests {
CursorSense::Pressing(CursorButton::Left),
Vec2::new(0.0, 220.0),
t(16),
false,
PressState::default(),
);
let out = g.handle(
&r,
@@ -2221,7 +2319,7 @@ mod drag_gesture_tests {
CursorSense::PressEnd(CursorButton::Left),
Vec2::new(0.0, 220.0),
t(24),
false,
PressState::default(),
);
// (0, 0) (8, 100) (16, 220) through Compose's Lsq2 fit. Note the
@@ -2255,7 +2353,7 @@ mod drag_gesture_tests {
CursorSense::PressStart(CursorButton::Left),
Vec2::ZERO,
t(0),
false,
PressState::default(),
);
g.handle(
&r,
@@ -2263,7 +2361,7 @@ mod drag_gesture_tests {
CursorSense::Pressing(CursorButton::Left),
Vec2::new(0.0, 100.0),
t(8),
false,
PressState::default(),
);
let out = g.handle(
&r,
@@ -2271,7 +2369,7 @@ mod drag_gesture_tests {
CursorSense::PressEnd(CursorButton::Left),
Vec2::new(0.0, 100.0),
t(16),
false,
PressState::default(),
);
assert_eq!(out, GestureOutcome::Released(Some(0.0)));
}
@@ -2292,7 +2390,7 @@ mod drag_gesture_tests {
CursorSense::PressStart(CursorButton::Left),
Vec2::ZERO,
t(0),
false,
PressState::default(),
);
let out = g.handle(
&r,
@@ -2300,7 +2398,7 @@ mod drag_gesture_tests {
CursorSense::PressEnd(CursorButton::Left),
Vec2::ZERO,
t(20),
false,
PressState::default(),
);
assert_eq!(out, GestureOutcome::Tapped);
}
@@ -2322,7 +2420,7 @@ mod drag_gesture_tests {
CursorSense::PressStart(CursorButton::Left),
Vec2::ZERO,
t(0),
false,
PressState::default(),
);
// Held still past LONG_PRESS, which is what starts a selection.
g.handle(
@@ -2331,7 +2429,7 @@ mod drag_gesture_tests {
CursorSense::Pressing(CursorButton::Left),
Vec2::ZERO,
t(0) + LONG_PRESS,
false,
PressState::default(),
);
let out = g.handle(
&r,
@@ -2339,8 +2437,219 @@ mod drag_gesture_tests {
CursorSense::PressEnd(CursorButton::Left),
Vec2::new(0.0, 50.0),
t(0) + LONG_PRESS + Duration::from_millis(10),
false,
PressState::default(),
);
assert_eq!(out, GestureOutcome::Released(None));
}
/// A catch: the press lands on content that is already moving, so it
/// pans from its very first sample with no `DRAG_SLOP` withheld --
/// docs/IRIS_TODO.md's "it fails to stop & snap to where finger is".
#[test]
fn a_press_on_moving_content_pans_from_the_first_sample() {
let mut ui = UiData::default();
let id = some_id(&mut ui);
let r = render();
let mut g = DragGesture::new();
let caught = PressState {
scrolling: true,
..Default::default()
};
assert_eq!(
g.handle(
&r,
id,
CursorSense::PressStart(CursorButton::Left),
Vec2::ZERO,
t(0),
caught,
),
// The down itself moves nothing; it only stops the fling.
GestureOutcome::Pan(0.0),
);
// A move of 2px, a quarter of `DRAG_SLOP` -- an ordinary press
// would still be `Undecided` here.
assert_eq!(
g.handle(
&r,
id,
CursorSense::Pressing(CursorButton::Left),
Vec2::new(0.0, 2.0),
t(8),
caught,
),
GestureOutcome::Pan(2.0),
);
}
/// The sibling of the case above, and the pair that says the catch is
/// not simply "every press pans": the same two samples with nothing
/// moving underneath stay inside the slop and decide nothing.
#[test]
fn the_same_press_on_settled_content_stays_undecided() {
let mut ui = UiData::default();
let id = some_id(&mut ui);
let r = render();
let mut g = DragGesture::new();
g.handle(
&r,
id,
CursorSense::PressStart(CursorButton::Left),
Vec2::ZERO,
t(0),
PressState::default(),
);
assert_eq!(
g.handle(
&r,
id,
CursorSense::Pressing(CursorButton::Left),
Vec2::new(0.0, 2.0),
t(8),
PressState::default(),
),
GestureOutcome::Undecided,
);
}
/// A catch released without moving is `Released(None)`: not a
/// `Tapped`, because Compose's scrollable consumed that DOWN and no
/// click detector under it ever saw the gesture -- so stopping a
/// fling with a finger must not also follow the link it landed on --
/// and not a `Released(Some(v))`, because there is no velocity to
/// hand on.
#[test]
fn a_catch_released_without_moving_is_neither_a_tap_nor_a_fling() {
let mut ui = UiData::default();
let id = some_id(&mut ui);
let r = render();
let mut g = DragGesture::new();
let caught = PressState {
scrolling: true,
..Default::default()
};
g.handle(
&r,
id,
CursorSense::PressStart(CursorButton::Left),
Vec2::ZERO,
t(0),
caught,
);
let out = g.handle(
&r,
id,
CursorSense::PressEnd(CursorButton::Left),
Vec2::ZERO,
t(20),
caught,
);
assert_eq!(out, GestureOutcome::Released(None));
// The identical gesture with nothing moving underneath is the
// tap it looks like -- `a_tap_is_still_a_tap_and_flings_nothing`
// above. The two differ by `scrolling` and nothing else.
}
/// Once a catch has actually moved the content, its release is an
/// ordinary pan release again and hands on a velocity -- otherwise
/// "catch it, then keep flicking" would stop dead every time.
#[test]
fn a_catch_that_then_drags_still_flings() {
let mut ui = UiData::default();
let id = some_id(&mut ui);
let r = render();
let mut g = DragGesture::new();
let caught = PressState {
scrolling: true,
..Default::default()
};
g.handle(
&r,
id,
CursorSense::PressStart(CursorButton::Left),
Vec2::ZERO,
t(0),
caught,
);
for (i, y) in [100.0, 220.0].into_iter().enumerate() {
g.handle(
&r,
id,
CursorSense::Pressing(CursorButton::Left),
Vec2::new(0.0, y),
t(8 * (i as u64 + 1)),
caught,
);
}
let out = g.handle(
&r,
id,
CursorSense::PressEnd(CursorButton::Left),
Vec2::new(0.0, 220.0),
t(24),
caught,
);
assert!(
matches!(out, GestureOutcome::Released(Some(v)) if v.abs() > 1.0),
"a catch that dragged must release with a velocity, got {out:?}"
);
}
/// One touch-down reaches every sensor under the finger, and a
/// transcript row's block and the tool row containing it share one
/// `DragGesture` -- so `handle` sees the same `PressStart` twice, and
/// the second delivery carries a `PressState` the first has already
/// acted on (`Selection::drag` has cancelled the fling by then, so
/// `scrolling` is false). It must be a continuation, not a restart;
/// as a restart it silently turned every catch back into an ordinary
/// slop-waiting press, which is how this was found.
#[test]
fn a_second_delivery_of_one_press_start_does_not_restart_it() {
let mut ui = UiData::default();
let id = some_id(&mut ui);
let r = render();
let mut g = DragGesture::new();
let caught = PressState {
scrolling: true,
..Default::default()
};
assert!(g.starts_press(CursorSense::PressStart(CursorButton::Left)));
g.handle(
&r,
id,
CursorSense::PressStart(CursorButton::Left),
Vec2::ZERO,
t(0),
caught,
);
assert!(
!g.starts_press(CursorSense::PressStart(CursorButton::Left)),
"the second sensor must be told this press is already in flight"
);
g.handle(
&r,
id,
CursorSense::PressStart(CursorButton::Left),
Vec2::ZERO,
t(0),
PressState::default(),
);
assert_eq!(
g.handle(
&r,
id,
CursorSense::Pressing(CursorButton::Left),
Vec2::new(0.0, 2.0),
t(8),
PressState::default(),
),
GestureOutcome::Pan(2.0),
"the catch survived only if the second delivery left it panning"
);
}
}
+8 -6
View File
@@ -1,5 +1,5 @@
use crate::prelude::*;
use crate::sense::{DragGesture, GestureOutcome};
use crate::sense::{DragGesture, GestureOutcome, PressState};
use std::time::Instant;
pub struct Scroll {
@@ -119,14 +119,16 @@ impl Scroll {
pos_window: Vec2,
now: Instant,
) {
// `already_selected: false` -- a scroll area has no selection of
// its own to extend, so a horizontal drag stays `Undecided` and a
// A default `PressState`: a scroll area has no selection of its
// own to extend, so a horizontal drag stays `Undecided` and a
// vertical one past the slop pans, which is the whole contract
// here. A caller that *does* own a selection (the transcript's
// `Selection`) drives `DragGesture` itself instead.
// here; and it never flings (see this method's doc), so there is
// never a moving target to catch either. A caller that *does* own
// a selection, or a fling (the transcript's `Selection`), drives
// `DragGesture` itself instead.
match self
.gesture
.handle(render, id, sense, pos_window, now, false)
.handle(render, id, sense, pos_window, now, PressState::default())
{
// `scroll(dy)`, not `scroll(-dy)` -- `Selection::drag` passes
// `-dy` to `List::scroll` because a `List`'s anchor offset and
@@ -0,0 +1,211 @@
//! Layer 1 of docs/RUST.md's "Three test layers", for catching a fling:
//! the real transcript screen over the real bench fixture, at the phone's
//! size and density, with no window, no compositor and no GPU.
//!
//! docs/IRIS_TODO.md's 2026-09-07 night report -- "sometimes when I try to
//! catch it while it's still moving (particularly if I drag) then it fails
//! to stop & snap to where finger is". The finger goes down on content
//! that is still travelling and the content does not follow it until
//! `DRAG_SLOP` has been crossed, which at a fling's speed is several
//! frames of the content sliding *away* from a finger that is already
//! down. Compose does not do that: a down while `isScrollInProgress`
//! starts the drag immediately (`scrollable`'s `startDragImmediately`).
use iris::harness::{Harness, TouchAction, TouchScript};
use iris::prelude::*;
use iris::sense::DRAG_SLOP;
use transcript_fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
/// The screen open on the fixture, framed twice -- once to draw, once for
/// `List::repair_anchor` to resolve the opening `snap_end` into a real
/// anchor, which is what every assertion about scroll position reads.
fn opened() -> (Harness, transcript_ui::TranscriptScreen) {
let mut h = Harness::new(phone_size(), PHONE_SCALE);
let opened = transcript_fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
h.frame(0);
h.frame(PHONE_FRAME_MS);
(h, opened.screen)
}
/// Where the content is, in window pixels: the top of whichever row is
/// under the middle of the viewport. `List` has no travel accessor and
/// this needs none -- a row's own extent moves exactly as far as the
/// content does, and the row is picked once so the two readings compare.
fn tracked_row(h: &mut Harness, screen: &transcript_ui::TranscriptScreen) -> (RowKey, f32) {
let middle = phone_size().y / 2.0;
let list = (screen.list)(&mut h.rsc);
let key = list.key_at(middle).expect("a row under the viewport");
let (top, _) = list.extent(key).expect("that row has an extent");
(key, top)
}
fn row_top(h: &mut Harness, screen: &transcript_ui::TranscriptScreen, key: RowKey) -> f32 {
(screen.list)(&mut h.rsc)
.extent(key)
.expect("the tracked row is still loaded")
.0
}
/// Three finger samples 8ms apart, each moving `STEP` further down the
/// screen. `STEP * 3` is deliberately **under** `DRAG_SLOP`: a gesture
/// this small moves nothing at all on a settled list (the control below),
/// so anything it moves here is the catch and not the slop being crossed.
const STEP: f32 = 2.0;
const SAMPLES: usize = 3;
const CATCH_X: f32 = 540.0;
/// Feeds the down and its `SAMPLES` moves from `y0` at `t0`, asserting
/// after each one that the content moved by exactly the finger's own
/// delta. Returns the release time.
fn drag_from(
h: &mut Harness,
screen: &transcript_ui::TranscriptScreen,
key: RowKey,
y0: f32,
t0: u64,
expect_tracking: bool,
) -> u64 {
let before = row_top(h, screen, key);
h.touch(TouchAction::Down, Vec2::new(CATCH_X, y0), t0);
assert_eq!(
row_top(h, screen, key),
before,
"the down itself must not move the content, only stop it"
);
let mut t = t0;
for i in 1..=SAMPLES {
let moved = STEP * i as f32;
t = t0 + 8 * i as u64;
h.touch(TouchAction::Move, Vec2::new(CATCH_X, y0 + moved), t);
let travelled = row_top(h, screen, key) - before;
if expect_tracking {
assert!(
(travelled - moved).abs() < 0.5,
"sample {i}: the finger has moved {moved}px since the down and the content \
{travelled:.1}px -- it is not pinned to the finger"
);
} else {
assert!(
travelled.abs() < 0.5,
"sample {i}: a {moved}px drag is inside DRAG_SLOP ({DRAG_SLOP}px) and must move \
nothing, but the content moved {travelled:.1}px"
);
}
}
t += 8;
h.touch(
TouchAction::Up,
Vec2::new(CATCH_X, y0 + STEP * SAMPLES as f32),
t,
);
t
}
/// The report itself: flick, let the fling run for 150ms, then put a
/// finger down and drag it a little. From the down onwards the content is
/// pinned to the finger, sample for sample -- no slop, and no coasting
/// past the place the finger stopped it.
#[test]
fn a_press_on_a_flinging_list_pins_the_content_to_the_finger() {
let (mut h, screen) = opened();
let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch"))
.unwrap_or_else(|e| panic!("flick-120hz.touch: {e}"));
h.replay(&flick);
// Frames, not a file: the second half of this gesture has to arrive
// *while* the fling is ticking, and a `.touch` replay inserts no
// frames between its samples, so a fling recorded that way would be
// running on paper and stationary in fact.
let catch_at = flick.end_ms() + 150;
h.frames_until(
flick.end_ms() + PHONE_FRAME_MS,
catch_at - PHONE_FRAME_MS,
PHONE_FRAME_MS,
);
assert!(
(screen.list)(&mut h.rsc).is_scrolling(),
"the fling must still be running 150ms in, or this test catches nothing"
);
let (key, _) = tracked_row(&mut h, &screen);
drag_from(&mut h, &screen, key, 1200.0, catch_at, true);
}
/// The other half of the same rule: a catch that never moved at all is a
/// `Released(None)`, not a tap. Compose's scrollable consumes that DOWN,
/// so no click detector under it ever sees the gesture -- stopping a
/// fling with a finger must not also follow the link it landed on, and
/// must not hand the list a velocity to start again with.
#[test]
fn a_catch_that_never_moved_is_not_a_tap_and_does_not_fling() {
let (mut h, screen) = opened();
let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch"))
.unwrap_or_else(|e| panic!("flick-120hz.touch: {e}"));
h.replay(&flick);
let catch_at = flick.end_ms() + 150;
h.frames_until(
flick.end_ms() + PHONE_FRAME_MS,
catch_at - PHONE_FRAME_MS,
PHONE_FRAME_MS,
);
assert!(
(screen.list)(&mut h.rsc).is_scrolling(),
"the fling must still be running 150ms in, or this test catches nothing"
);
let (key, _) = tracked_row(&mut h, &screen);
h.touch(TouchAction::Down, Vec2::new(CATCH_X, 1200.0), catch_at);
let stopped_at = row_top(&mut h, &screen, key);
h.touch(TouchAction::Up, Vec2::new(CATCH_X, 1200.0), catch_at + 8);
assert_eq!(
(screen.list)(&mut h.rsc).fling_velocity(),
None,
"a press that stopped a fling and moved nothing must not start another"
);
h.frames_until(catch_at + 16, catch_at + 500, PHONE_FRAME_MS);
assert!(
(row_top(&mut h, &screen, key) - stopped_at).abs() < 0.5,
"the content moved after a catch was released without moving"
);
assert_eq!(
h.state.opened_urls,
Vec::<String>::new(),
"a catch is not a tap: nothing under it may be followed"
);
}
/// The half this change had no reason to touch: on a list that is *not*
/// moving, the same tiny drag is still inside `DRAG_SLOP` and still moves
/// nothing. Without this, making every press pin the content would pass
/// the test above and take the slop away from every ordinary press.
#[test]
fn the_same_small_drag_on_a_settled_list_moves_nothing() {
let (mut h, screen) = opened();
let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch"))
.unwrap_or_else(|e| panic!("flick-120hz.touch: {e}"));
h.replay(&flick);
// Long past the spline's own 2071ms for this recording.
let settled = h.frames_until(
flick.end_ms() + PHONE_FRAME_MS,
flick.end_ms() + 4000,
PHONE_FRAME_MS,
);
assert!(
!(screen.list)(&mut h.rsc).is_scrolling(),
"the fling must have stopped, or this is the same case as the test above"
);
let (key, _) = tracked_row(&mut h, &screen);
drag_from(
&mut h,
&screen,
key,
1200.0,
settled + PHONE_FRAME_MS,
false,
);
}
+18 -8
View File
@@ -259,16 +259,26 @@ impl Selection {
now: Instant,
render: &UiRenderState,
) -> GestureOutcome {
if matches!(sense, CursorSense::PressStart(_)) {
// A fresh touch-down cancels any fling still coasting from
// the previous gesture -- `List::fling`'s own doc, and
// Android's `Scroller::abortAnimation` for the same reason.
// A fresh touch-down cancels any fling still coasting from the
// previous gesture -- `List::fling`'s own doc, and Android's
// `Scroller::abortAnimation` for the same reason -- and, since
// 2026-09-07, *tells the gesture there was one*. A press that
// caught moving content is a catch: it pans from this very sample
// rather than waiting out `DRAG_SLOP`, which is what pins the
// content to the finger instead of leaving it coasting for the
// first few frames (Iris's "it fails to stop & snap to where
// finger is"). See `DragArbiter::press_start` for Compose's own
// mechanism. `starts_press` rather than a `PressStart` test of our
// own, so this fires on the recovered-press frames too.
let mut press = PressState::default();
if self.gesture.starts_press(sense) {
press.scrolling = list(ui).is_scrolling();
list(ui).cancel_fling();
}
let already_selected = self.has_selection(ui);
let outcome =
self.gesture
.handle(render, list.id(), sense, pos_window, now, already_selected);
press.already_selected = self.has_selection(ui);
let outcome = self
.gesture
.handle(render, list.id(), sense, pos_window, now, press);
match outcome {
GestureOutcome::Undecided => {}
GestureOutcome::Pan(dy) => list(ui).scroll(-dy),