iris: a cancelled gesture is not a release, and a row height is not last frame's

Three of the four defects in Iris's 2026-09-08 report, each with a
layer-1 repro that fails without the change.

**A gesture the platform takes away is now a cancel, not a release**
(`CursorState::cancelled`, `SensorUi::run_sensors`). Android mapped
`ACTION_CANCEL` onto the same arm as `ACTION_UP`, so the system's own
swipe up from the bottom edge to leave the app reached iris as a flick
released at speed and the transcript flung while the app was in the
background -- "leaving and reopening the app also randomly moved the
vertical scroll". A cancelled sample now delivers `CursorSense::Cancel`
to the capture holder *and* every widget still tracking the press,
clears both, and derives nothing else: no tap, no selection, no fling.
The harness's `TouchAction::Cancel` says the same thing, so it is
testable from a `.touch` file.

**A `DragGesture` ignores a `Cancel` when it is the one holding the
capture.** A cancel goes to every pressed widget that did not capture,
and one gesture is routinely driven by several of those -- a transcript
row's text block feeds `Selection`'s shared gesture, which captures
under the *list's* id, so the block is a "loser" on the very frame its
own pan committed. Acting on that released the pan the frame it started
(`catch_a_fling.rs` fails without the guard). With it, a row's block can
register the whole `drag_senses()` set, `Cancel` included, which is what
the doc on that set has always said a widget driving a gesture must do.

**A row whose measurement disagrees with the box it was offered is drawn
again at its true box, this frame** (`List::place`, both placements).
A row is offered its *cached* height and a `.background(rect(..))` fills
whatever box it is handed, so on the frame a row changed height its text
laid out at the new height and its background painted at the old one --
"collapsing and opening an edit card draws the card background a frame
late, so it looks closed even when there's text". The bottom-anchored
half used a `reposition`, which writes an offset and never a size, so it
could not fix it either.

**The nested-`Span` workaround in `tool.rs` is gone**, restoring the 4dp
inset a tool group holds its cards off its edge by. "A `Span` of
`Pad`ded children inside another `Span` places those children a slot out
of step" is **not reproducible on 2026-09-08** -- verified both with
`IRIS_TOOLS_EXPANDED=1 run-headless.sh transcript --shot` and with a new
layer-1 test.

Tests: `transcript-fixture/tests/gesture_cancel.rs` (three, including a
real code fence pushed into the screen so the pan has something to
capture it), `list.rs`'s
`a_row_that_changes_height_draws_its_background_at_the_new_height_immediately`,
`layout_tests.rs`'s
`a_span_of_padded_children_inside_a_span_draws_each_where_its_box_is`.
Each was confirmed to fail with the change backed out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-08 15:15:29 -04:00
1 parent 9e301f30c6
commit fc82d9d7e8
9 files changed
+520 -57

No files matched your search

+15 -8
View File
@@ -714,17 +714,24 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
MotionAction::Move => { MotionAction::Move => {
ui_state.cursor.pos = vec2(x, y); ui_state.cursor.pos = vec2(x, y);
} }
// `Cancel` ends the gesture the same way `Up` does, and must: MotionAction::Up => {
// a release that never arrives leaves whichever widget took
// pointer capture holding it forever, with every later touch
// delivered to a drag nobody is performing. Confirmed present
// before this pass rather than assumed -- it was one of the
// three suspects listed for the phone's missing fling, and it
// is not the cause.
MotionAction::Up | MotionAction::Cancel => {
ui_state.cursor.pos = vec2(x, y); ui_state.cursor.pos = vec2(x, y);
ui_state.cursor.buttons.left.update(false); ui_state.cursor.buttons.left.update(false);
} }
// A cancel ends the press -- a release that never arrives
// leaves whichever widget took pointer capture holding it
// forever -- but it is **not** a release, and saying so is
// `CursorState::cancelled`. It used to take the `Up` arm, so
// the system's own swipe up from the bottom edge to leave the
// app (moves, then `ACTION_CANCEL`) reached iris as a flick
// released at speed, and the transcript flung while the app
// was in the background: Iris's 2026-09-08 "leaving and
// reopening the app also randomly moved the vertical scroll".
MotionAction::Cancel => {
ui_state.cursor.pos = vec2(x, y);
ui_state.cursor.buttons.left.update(false);
ui_state.cursor.cancelled = true;
}
_ => return false, _ => return false,
} }
if trace_input { if trace_input {
+12 -4
View File
@@ -42,9 +42,11 @@ pub enum TouchAction {
Move, Move,
Up, Up,
/// The gesture taken away by the system (a parent view claiming it, a /// The gesture taken away by the system (a parent view claiming it, a
/// call arriving). It ends the press exactly as `Up` does -- a /// call arriving, the swipe up from the bottom edge to leave the
/// release that never arrives leaves pointer capture held forever -- /// app). It ends the press, because a release that never arrives
/// which is why a replay file can say it. /// leaves pointer capture held forever -- but it is not a release,
/// and nothing follows from it: no tap, no selection, no fling. See
/// `CursorState::cancelled`, which is what it sets.
Cancel, Cancel,
} }
@@ -393,7 +395,13 @@ impl Harness {
self.cursor.buttons.left.update(true); self.cursor.buttons.left.update(true);
} }
TouchAction::Move => {} TouchAction::Move => {}
TouchAction::Up | TouchAction::Cancel => self.cursor.buttons.left.update(false), TouchAction::Up => self.cursor.buttons.left.update(false),
// The platform taking the gesture away, not the finger
// lifting -- see `CursorState::cancelled`.
TouchAction::Cancel => {
self.cursor.buttons.left.update(false);
self.cursor.cancelled = true;
}
} }
// Layer 1's half of `iris::input` (`sense::log_input_event`'s own // Layer 1's half of `iris::input` (`sense::log_input_event`'s own
// doc): no batching happens here, so `historical` is always empty // doc): no batching happens here, so `historical` is always empty
+92
View File
@@ -963,3 +963,95 @@ fn a_scroll_area_opens_at_the_start_of_content_it_has_not_measured_yet() {
); );
} }
} }
/// docs/IRIS_TODO.md's "A `Span` of `Pad`ded children inside another
/// `Span` places those children a slot out of step", worked around in
/// `transcript-ui/src/tool.rs` by flattening the two spans into one --
/// which costs a tool group the inset its cards should sit inside.
///
/// The shape is the smallest one that reproduced it there: an outer
/// `Span(DOWN)` whose second child is another `Span(DOWN)` whose children
/// are each a `Pad` around a fixed-height rect. Each rect is asserted to
/// be *drawn* where its own box is -- `primitive_corners` rather than
/// `window_region`, since the report is about what is on screen and the
/// two resolve the move chain differently.
#[test]
fn a_span_of_padded_children_inside_a_span_draws_each_where_its_box_is() {
const PAD: f32 = 4.0;
const ROW: f32 = 20.0;
const HEADER: f32 = 30.0;
let mut rsc = TestRsc {
ui: UiData::default(),
};
let header_fill = rsc.ui.widgets.add_strong(Rect::new(UiColor::RED)).any();
let header_id = header_fill.id();
let header = rsc.ui.widgets.add_strong(Sized {
inner: header_fill,
x: None,
y: Some(Len::abs(HEADER)),
});
let mut inner = Span::empty(Dir::DOWN);
let mut rects = Vec::new();
for _ in 0..3 {
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
rects.push(rect.weak());
let sized = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(),
x: None,
y: Some(Len::abs(ROW)),
});
let padded = rsc.ui.widgets.add_strong(Pad {
padding: Padding::uniform(PAD),
inner: sized.any(),
});
let fill = rsc.ui.widgets.add_strong(Rect::new(UiColor::BLUE)).any();
let card = rsc.ui.widgets.add_strong(Stack {
children: vec![fill, padded.any()],
size: StackSize::Child(1),
});
let wide = rsc.ui.widgets.add_strong(Sized {
inner: card.any(),
x: Some(Len::rest(1.0)),
y: None,
});
inner.push(wide.any());
}
let inner = rsc.ui.widgets.add_strong(inner);
let outer = rsc.ui.widgets.add_strong(Span {
children: vec![header.any(), inner.any()],
dir: Dir::DOWN,
gap: Len::ZERO,
});
let mut list = List::new(Axis::Y);
list.push_back(ListRow::new(0, outer.any()));
let list = rsc.ui.widgets.add_strong(list);
let root = rsc
.ui
.widgets
.add_strong(Masked {
shape: None,
inner: list.any(),
})
.any();
let mut render = UiRenderState::new();
render.resize((200.0, 400.0));
render.update(&root, &mut rsc);
render.update(&root, &mut rsc);
let head_slot = render
.first_primitive(header_id)
.expect("the header drew a primitive");
let head_top = render.primitive_corners(head_slot, &rsc).top_left.y;
for (i, rect) in rects.iter().enumerate() {
let want = head_top + HEADER + (ROW + 2.0 * PAD) * i as f32 + PAD;
let slot = render
.first_primitive(rect.id())
.expect("each rect drew a primitive");
let drawn = render.primitive_corners(slot, &rsc);
assert!(
(drawn.top_left.y - want).abs() < 0.01,
"row {i} should be drawn at y={want}, got {drawn:?}"
);
}
}
+62
View File
@@ -151,6 +151,26 @@ pub struct CursorState {
/// which is exactly the inferred-as-measured number UI_RULES.md /// which is exactly the inferred-as-measured number UI_RULES.md
/// forbids. Carrying the sample's own time makes the span real. /// forbids. Carrying the sample's own time makes the span real.
pub time: Instant, pub time: Instant,
/// The platform took this gesture away rather than the finger
/// finishing it -- Android's `ACTION_CANCEL`, or a
/// `TouchAction::Cancel` line in a `harness` replay. (The winit
/// backend drives a mouse, which the platform never takes away
/// mid-gesture, so it has nothing to set this from yet; a touch
/// path there would set it from `TouchPhase::Cancelled`.)
///
/// It is **not** a release. Every widget tracking the press is sent
/// [`CursorSense::Cancel`], including whoever holds pointer capture,
/// and nothing follows from it: no tap, no selection, and above all
/// no fling. Reporting it as an ordinary `PressEnd` instead is what
/// made leaving the app move the transcript on Iris's phone
/// (2026-09-08): the swipe up from the bottom edge to go home is
/// delivered to the app as moves and then a cancel, so a cancel read
/// as a release handed the list the swipe's own velocity and it flung
/// while nobody was looking.
///
/// Set for the one sample that carries it and cleared by
/// [`CursorState::end_frame`], like `scroll_delta`.
pub cancelled: bool,
} }
impl Default for CursorState { impl Default for CursorState {
@@ -161,6 +181,7 @@ impl Default for CursorState {
buttons: CursorButtons::default(), buttons: CursorButtons::default(),
scroll_delta: Vec2::ZERO, scroll_delta: Vec2::ZERO,
time: Instant::now(), time: Instant::now(),
cancelled: false,
} }
} }
} }
@@ -202,6 +223,7 @@ impl CursorState {
pub fn end_frame(&mut self) { pub fn end_frame(&mut self) {
self.buttons.end_frame(); self.buttons.end_frame();
self.scroll_delta = Vec2::ZERO; self.scroll_delta = Vec2::ZERO;
self.cancelled = false;
} }
} }
@@ -372,6 +394,33 @@ impl SensorUi for UiRenderState {
}; };
let button_down = cursor.buttons.select(&CursorButton::Left).is_on(); let button_down = cursor.buttons.select(&CursorButton::Left).is_on();
// The platform took the gesture away (`CursorState::cancelled`).
// Everybody still tracking this press hears about it -- the
// capture holder included, which is what makes this different
// from the loser cancels at the end of an ordinary dispatch --
// and no other sense is derived from this sample, because there
// is no press left to derive one from. Nothing may follow: not a
// tap, not a selection, and not a fling.
if cursor.cancelled {
let captured = pointer.captured.take();
let mut told: Vec<WidgetId> = captured.into_iter().collect();
for id in pointer.pressed.drain(..) {
if Some(id) != captured {
told.push(id);
}
}
requests.release();
for id in told {
deliver_cancel(self, rsc, state, id, &cursor, window_size, &requests);
}
// Deliberately not `requests.holder()`: a cancel handler has
// no gesture left to claim, so one that asked for the pointer
// anyway is refused here rather than left holding a capture
// that no release will ever reach.
rsc.events_mut().get_type::<CursorSense>().global = pointer;
return;
}
// Exclusive pointer capture (`PointerRequests::capture`): once // Exclusive pointer capture (`PointerRequests::capture`): once
// some widget has committed to a drag, every other widget sees // some widget has committed to a drag, every other widget sees
// nothing from this pointer at all -- no hover, no click, no press // nothing from this pointer at all -- no hover, no click, no press
@@ -1216,6 +1265,19 @@ impl DragGesture {
// leaving the arbiter open is the fault this sense was added // leaving the arbiter open is the fault this sense was added
// for, since its origin then measures the *next* touch and // for, since its origin then measures the *next* touch and
// pans by the distance between two unrelated fingers. // pans by the distance between two unrelated fingers.
//
// **Unless this gesture is the winner.** A `Cancel` goes to
// every widget that was handed a frame of the press and did
// not capture it (`PointerInput`'s doc), and one gesture is
// routinely driven by several of those: a transcript row's
// text block feeds `transcript_ui::Selection`'s shared
// `DragGesture`, which captures under the *list's* id -- so
// the block is a "loser" on the very frame its own gesture
// committed. Acting on that would release the pan the frame
// it started. `id` is what this gesture captures as, so
// comparing it against the holder is exactly the question
// "was it us that won".
CursorSense::Cancel if pointer.holder() == Some(id) => GestureOutcome::Undecided,
CursorSense::Cancel => { CursorSense::Cancel => {
if crate::diagnostics::trace_enabled() { if crate::diagnostics::trace_enabled() {
log::debug!( log::debug!(
+82 -17
View File
@@ -1002,12 +1002,23 @@ impl List {
/// skip-or-move `draw_inner` already provides for an unchanged or /// skip-or-move `draw_inner` already provides for an unchanged or
/// merely-translated widget. Caching the height once measured is what /// merely-translated widget. Caching the height once measured is what
/// lets an already-seen row go back to that cheap path while a /// lets an already-seen row go back to that cheap path while a
/// first-seen one still gets a correctly-sized initial paint. A stale /// first-seen one still gets a correctly-sized initial paint.
/// cached height (the row's content changed height since) briefly ///
/// offers the wrong box; the height recorded from what it *actually* /// **A row whose measurement disagrees with the box it was offered is
/// reports this frame corrects it starting next frame -- the same /// drawn again, this frame, at the box its own height implies** --
/// one-frame lag `Scroll`'s own content-length cache accepts, per /// both placements, since both offer a cached height and both can be
/// LAYOUT.md. /// wrong the frame a row's content changes size. This is not an
/// optimisation to skip: a row is routinely `.background(rect(..))`
/// (a tool card *is* one), and `Rect::draw` fills whatever region it
/// is handed, so a row offered last frame's height paints its
/// background at last frame's height while its text lays out at the
/// new one -- Iris's 2026-09-08 report that "collapsing and opening an
/// edit card draws the card background a frame late, so it looks
/// closed even when there's text". A `reposition` does not fix it
/// (it writes an offset, never a size), which is what the bottom-
/// anchored half used to do. The extra draw happens only on the frame
/// a row actually changes height, which is a frame that was already
/// redrawing that row.
fn place(&mut self, painter: &mut Painter, slot: isize, placement: Placement) -> (f32, f32) { fn place(&mut self, painter: &mut Painter, slot: isize, placement: Placement) -> (f32, f32) {
// Every current caller derives `slot` from `repair_anchor`/ // Every current caller derives `slot` from `repair_anchor`/
// `prev_slot`/`next_slot`, which already check existence -- but // `prev_slot`/`next_slot`, which already check existence -- but
@@ -1069,6 +1080,10 @@ impl List {
let region = Self::abs_region(axis, top, top + h); let region = Self::abs_region(axis, top, top + h);
let used = painter.widget_within(self.slot_widget(slot), region); let used = painter.widget_within(self.slot_widget(slot), region);
let height = resolve(used); let height = resolve(used);
if height != h {
let corrected = Self::abs_region(axis, top, top + height);
painter.widget_within(self.slot_widget(slot), corrected);
}
(top, top + height, height) (top, top + height, height)
} }
(Placement::Top(top), None) => { (Placement::Top(top), None) => {
@@ -1085,17 +1100,8 @@ impl List {
let used = painter.widget_within(self.slot_widget(slot), region); let used = painter.widget_within(self.slot_widget(slot), region);
let height = resolve(used); let height = resolve(used);
if height != h { if height != h {
// The row's real height changed since it was cached
// (and was therefore redrawn -- an unchanged widget
// never disagrees with its own cache). It painted
// anchored at the *offered* box's leading edge
// (`bottom - h`, per every widget in this crate's
// top-left-anchoring convention), not where its true
// height means its bottom edge should be; correct with
// an O(1) reposition, `Aligned`'s own trick for this
// exact "learned a size after already drawing" case.
let corrected = Self::abs_region(axis, bottom - height, bottom); let corrected = Self::abs_region(axis, bottom - height, bottom);
painter.reposition(self.slot_widget(slot), corrected); painter.widget_within(self.slot_widget(slot), corrected);
} }
(bottom - height, bottom, height) (bottom - height, bottom, height)
} }
@@ -1392,6 +1398,17 @@ mod tests {
/// background rect's own id (to check what it actually painted at) /// background rect's own id (to check what it actually painted at)
/// alongside the row widget. /// alongside the row widget.
fn background_styled_row(rsc: &mut TestRsc, height: f32) -> (WidgetId, StrongWidget) { fn background_styled_row(rsc: &mut TestRsc, height: f32) -> (WidgetId, StrongWidget) {
let (bg_id, _, row) = resizable_background_row(rsc, height);
(bg_id, row)
}
/// [`background_styled_row`] with the foreground's own `Sized` handed
/// back too, so a test can change the row's height the way a tool card
/// being collapsed or opened does.
fn resizable_background_row(
rsc: &mut TestRsc,
height: f32,
) -> (WidgetId, WeakWidget<Sized>, StrongWidget) {
let bg = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); let bg = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let bg_id = bg.id(); let bg_id = bg.id();
let fg_rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); let fg_rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
@@ -1400,11 +1417,59 @@ mod tests {
x: None, x: None,
y: Some(Len::abs(height)), y: Some(Len::abs(height)),
}); });
let fg_weak = fg.weak();
let stack = Stack { let stack = Stack {
children: vec![bg.any(), fg.any()], children: vec![bg.any(), fg.any()],
size: StackSize::Child(1), size: StackSize::Child(1),
}; };
(bg_id, rsc.ui.widgets.add_strong(stack).any()) (bg_id, fg_weak, rsc.ui.widgets.add_strong(stack).any())
}
/// Iris's 2026-09-08 report: "collapsing and opening an edit card
/// draws the card background a frame late, so it looks closed even
/// when there's text, and then looks open even when the text is
/// collapsed."
///
/// A row is offered a box sized to its *cached* height, and a
/// `.background(rect(..))` fills whatever box it is given -- so on the
/// frame a row changes height its text is laid out at the new height
/// and its background painted at the old one. Every row is exercised,
/// in both directions, because which of `place`'s two placements a row
/// takes depends on where it sits relative to the anchor and the fault
/// was in both.
#[test]
fn a_row_that_changes_height_draws_its_background_at_the_new_height_immediately() {
for key_to_change in 0..5u64 {
for new_height in [50.0f32, 8.0] {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = List::new(Axis::Y);
let mut rows = Vec::new();
for key in 0..5u64 {
let (bg_id, fg, row) = resizable_background_row(&mut rsc, 20.0);
rows.push((bg_id, fg));
list.push_back(ListRow::new(key, row));
}
let (_, root) = add_list(&mut rsc, list);
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
render.update(&root, &mut rsc);
let (bg_id, fg) = rows[key_to_change as usize];
rsc.ui.widgets.get_mut(&fg).unwrap().y = Some(Len::abs(new_height));
render.update(&root, &mut rsc);
let px = render.active[&bg_id].region.to_px((100.0, 100.0).into());
let drawn = px.size().y;
assert!(
(drawn - new_height).abs() < 0.5,
"row {key_to_change} resized to {new_height}px drew its background at {drawn}px on the same frame"
);
}
}
} }
#[test] #[test]
@@ -0,0 +1,206 @@
//! Layer 1 of docs/RUST.md's "Three test layers" for a gesture the
//! *platform* takes away, over the real transcript screen and the real
//! bench fixture.
//!
//! Both halves of Iris's 2026-09-08 report about the transcript moving on
//! its own live here. A cancel is not a release, so nothing may follow it
//! -- and every widget that was tracking the press has to hear about it,
//! or the next press anywhere on screen is measured from the origin the
//! abandoned one left behind.
use iris::harness::{Harness, TouchAction, TouchScript};
use iris::prelude::*;
use transcript_fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
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)
}
fn script(name: &str, text: &str) -> TouchScript {
TouchScript::parse(text).unwrap_or_else(|e| panic!("{name}: {e}"))
}
/// Where the content actually is, in window pixels: the top of whichever
/// row is under the middle of the viewport, tracked by key. The anchor's
/// own `idx/off` display is not that -- the list rehomes its anchor to a
/// different row without the content moving at all -- so a test asserting
/// "nothing moved" reads a row's own extent, the way `catch_a_fling.rs`
/// does.
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
}
/// The system's own swipe up from the bottom edge to leave the app is
/// delivered to the app as moves and then `ACTION_CANCEL`. Read as a
/// release it hands the list that swipe's velocity, and the transcript
/// flings while nobody is looking -- "leaving and reopening the app also
/// randomly moved the vertical scroll".
#[test]
fn a_cancelled_flick_does_not_fling() {
let (mut h, screen) = opened();
let flick = script(
"flick-cancelled",
include_str!("../touch/flick-cancelled.touch"),
);
h.replay(&flick);
assert_eq!(
(screen.list)(&mut h.rsc).fling_velocity(),
None,
"a gesture the platform took away must not fling"
);
// ...and it must not be moving on its own over the following second
// either, which is what a fling started some other way would look
// like.
let (key, settled) = tracked_row(&mut h, &screen);
let end = flick.end_ms() + 1_000;
let mut t = flick.end_ms();
while t <= end {
h.frame(t);
t += PHONE_FRAME_MS;
}
let now = row_top(&mut h, &screen, key);
assert!(
(now - settled).abs() < 0.5,
"the list kept moving after a cancelled gesture: {settled} -> {now}"
);
}
/// The other half, and the one that made a *later* touch snap: a cancel
/// has to reach every widget that was handed a frame of the press, so the
/// gesture it was driving forgets its origin. Without it the arbiter is
/// still open with the abandoned press's touch-down as its origin, and
/// the next press is measured from there -- a jump the size of the
/// distance between two unrelated touches.
#[test]
fn a_press_ended_by_a_cancel_leaves_no_origin_for_the_next_one() {
let (mut h, screen) = opened();
// Press near the top of the transcript and let the platform take it.
h.touch(TouchAction::Down, Vec2::new(540.0, 700.0), 0);
h.touch(TouchAction::Cancel, Vec2::new(540.0, 700.0), 8);
let (key, before) = tracked_row(&mut h, &screen);
// A plain tap, a long way down the screen from where that press
// started. It must move nothing at all.
h.touch(TouchAction::Down, Vec2::new(540.0, 1900.0), 200);
h.touch(TouchAction::Up, Vec2::new(540.0, 1900.0), 250);
let after = row_top(&mut h, &screen, key);
assert!(
(after - before).abs() < 0.5,
"a tap after a cancelled press panned the list by {}px, the distance between them",
after - before
);
assert_eq!(
(screen.list)(&mut h.rsc).fling_velocity(),
None,
"and it must not have flung either"
);
}
/// The report itself: "if I scroll in a horizontal area and then tap in a
/// vertical area, it seems to snap."
///
/// A markdown fence pans sideways through its own `Scroll`, which takes
/// pointer capture the moment it commits. Everything else that was handed
/// a frame of that press is told so with `CursorSense::Cancel` -- and the
/// widget the press actually landed on is the fence's own text block,
/// which drives `transcript_ui::Selection`'s shared `DragGesture`. A
/// block that does not register `Cancel` never hears it, so the gesture
/// stays open with the fence's touch-down as its origin and the next
/// press anywhere is measured from there.
///
/// The fence is pushed here rather than hunted for in the fixture, so the
/// test knows exactly which row it is pressing and where.
#[test]
fn panning_a_code_fence_then_tapping_elsewhere_moves_nothing() {
use client_core::transcript_fold::{TranscriptItem, TranscriptRow};
let (mut h, screen) = opened();
let fence = TranscriptRow::Single(TranscriptItem::AssistantMsg {
seq: 9_000_000,
text: "```\none two three four five six seven eight nine ten eleven twelve\n\
thirteen fourteen fifteen sixteen seventeen eighteen nineteen\n```"
.to_string(),
settled: true,
});
// A plain paragraph under it, because the tap has to land on
// ordinary text: a tap that happens to hit a tool group's header
// toggles it, and a row changing height moves the list for a reason
// that has nothing to do with this.
let para = TranscriptRow::Single(TranscriptItem::AssistantMsg {
seq: 9_000_001,
text: "A plain paragraph with nothing to tap in it, only words, so that a \
press here is a press on ordinary text and nothing else."
.to_string(),
settled: true,
});
screen.push_row(&mut h.rsc, &fence);
screen.push_row(&mut h.rsc, &para);
(screen.list)(&mut h.rsc).jump_to_end();
h.frame(100);
h.frame(108);
// Press in the middle of the fence's own row, so the gesture starts on
// the text block inside the scroll area rather than in a gap.
let key = transcript_ui::row::row_key(&fence.key());
let (top, bottom) = (screen.list)(&mut h.rsc)
.extent(key)
.expect("the fence row is on screen");
let y = (top + bottom) / 2.0;
assert!(
y > 0.0 && y < phone_size().y,
"the fence row has to be on screen to be pressed: {top}..{bottom}"
);
// Sideways, well past `DRAG_SLOP`, so the fence commits and captures.
h.touch(TouchAction::Down, Vec2::new(800.0, y), 200);
for (i, x) in [760.0, 700.0, 620.0, 540.0].into_iter().enumerate() {
h.touch(TouchAction::Move, Vec2::new(x, y), 208 + 8 * i as u64);
}
h.touch(TouchAction::Up, Vec2::new(540.0, y), 248);
let (tracked, before) = tracked_row(&mut h, &screen);
// A tap on the paragraph, a long way down the screen from where that
// pan started.
let para_key = transcript_ui::row::row_key(&para.key());
let (ptop, pbottom) = (screen.list)(&mut h.rsc)
.extent(para_key)
.expect("the paragraph row is on screen");
h.touch(
TouchAction::Down,
Vec2::new(540.0, (ptop + pbottom) / 2.0),
400,
);
h.touch(
TouchAction::Up,
Vec2::new(540.0, (ptop + pbottom) / 2.0),
450,
);
let after = row_top(&mut h, &screen, tracked);
assert!(
(after - before).abs() < 0.5,
"a tap after panning a code fence moved the transcript by {}px",
after - before
);
}
@@ -0,0 +1,11 @@
# `flick-120hz.touch` to the sample, with the platform taking the gesture
# away instead of the finger lifting -- Android's `ACTION_CANCEL`, which
# is what the swipe up from the bottom edge to leave the app delivers
# after its moves. Nothing may follow from it: no tap, no selection and,
# the one that showed on Iris's phone, no fling.
0 down 540 1000
4 move 540 1040
8 move 540 1086
12 move 540 1138
16 move 540 1196
20 cancel 540 1196
+14 -8
View File
@@ -217,9 +217,9 @@ where
let tap_links = links.clone(); let tap_links = links.clone();
field field
// `| CursorSense::unclick()` on top of the usual click-or-drag set // The whole `drag_senses()` set, which is what every widget
// -- this block's own registration only ever needs to see a // driving a `DragGesture` registers. This block normally only sees
// gesture's *first* frame (`PressStart`, or a `Pressing` that // a gesture's *first* frames (`PressStart`, or a `Pressing` that
// missed it -- `DragGesture::handle`'s idle-recovery branch); once // missed it -- `DragGesture::handle`'s idle-recovery branch); once
// it commits, `DragGesture` takes pointer capture on `list`'s own // it commits, `DragGesture` takes pointer capture on `list`'s own
// id and every further frame, including the terminal `Drop`, // id and every further frame, including the terminal `Drop`,
@@ -227,9 +227,16 @@ where
// `iris::sense`'s pointer-capture doc for why that has to be a // `iris::sense`'s pointer-capture doc for why that has to be a
// stable id rather than this row's, which `List` can retire mid- // stable id rather than this row's, which `List` can retire mid-
// drag as content scrolls. // drag as content scrolls.
.on( //
CursorSense::click_or_drag() | CursorSense::unclick(), // `Cancel` is the one that is *not* optional, and leaving it out
move |ctx, rsc| { // is what made Iris's 2026-09-08 "scroll a horizontal area, then
// tap in a vertical one, and it snaps": a cancel is delivered to
// the widget that was **pressed**, not to whoever holds the
// capture, so when a code fence inside this block panned sideways
// and took the pointer, nothing ever told the shared gesture its
// press was over. It stayed open with the fence's touch-down as
// its origin, and the next press anywhere was measured from there.
.on(CursorSense::drag_senses(), move |ctx, rsc| {
let (pos, size, cursor) = (ctx.data.pos, ctx.data.size, ctx.data.cursor.pos); let (pos, size, cursor) = (ctx.data.pos, ctx.data.size, ctx.data.cursor.pos);
let outcome = selection.borrow_mut().drag( let outcome = selection.borrow_mut().drag(
rsc, rsc,
@@ -256,8 +263,7 @@ where
<Rsc::State as OpenUrl>::open_url(ctx.state, &url); <Rsc::State as OpenUrl>::open_url(ctx.state, &url);
} }
} }
}, })
)
.add(rsc); .add(rsc);
// The column holds the *framed* widget; the field is what // The column holds the *framed* widget; the field is what
+26 -20
View File
@@ -89,6 +89,8 @@ const GAP_DP: f32 = 8.0;
const RAW_RADIUS_DP: f32 = 4.0; const RAW_RADIUS_DP: f32 = 4.0;
/// The room inside a verbatim block. /// The room inside a verbatim block.
const RAW_PAD_DP: f32 = 8.0; const RAW_PAD_DP: f32 = 8.0;
/// How far a group holds its cards off its own edge (`ToolRows.kt`).
const GROUP_INSET_DP: f32 = 4.0;
/// How much of a tool's output an open card draws before it offers the /// How much of a tool's output an open card draws before it offers the
/// rest behind a tap. /// rest behind a tap.
@@ -190,9 +192,10 @@ fn on_tap<Rsc: HasEvents>(
Rsc::State: FocusHost + OpenUrl, Rsc::State: FocusHost + OpenUrl,
{ {
let (list, selection) = (shared.list, shared.selection.clone()); let (list, selection) = (shared.list, shared.selection.clone());
ptr.on( // The whole `drag_senses()` set -- what any widget driving a
CursorSense::click_or_drag() | CursorSense::unclick(), // `DragGesture` registers, `Cancel` included. See `row.rs`'s twin
move |ctx, rsc| { // registration for what leaving `Cancel` out did.
ptr.on(CursorSense::drag_senses(), move |ctx, rsc| {
let outcome = selection.borrow_mut().drag( let outcome = selection.borrow_mut().drag(
rsc, rsc,
list, list,
@@ -205,8 +208,7 @@ fn on_tap<Rsc: HasEvents>(
if outcome == GestureOutcome::Tapped { if outcome == GestureOutcome::Tapped {
f(rsc); f(rsc);
} }
}, })
)
.add(rsc); .add(rsc);
} }
@@ -665,22 +667,26 @@ where
.add_strong(rsc) .add_strong(rsc)
.any(), .any(),
); );
// The cards go straight into the group's own `Span`, not into a // The cards sit in their own `Span` inside the group's, inset from
// second one inside it. **A `Span` of `Pad`ded children inside another // its edge the way `ToolRows.kt` insets them.
// `Span` places those children a slot out of step** -- each card's //
// content drew one card's height below its own box, so the cards read // This shape was flattened into one `Span` between 2026-09-06 and
// as empty bars with somebody else's summary in them. Bisected on // 2026-09-08 to work around "a `Span` of `Pad`ded children inside
// 2026-09-06 against `iris/run-headless.sh transcript` with // another `Span` places those children a slot out of step", which
// `IRIS_TOOLS_EXPANDED=1`: removing the inner `Span` fixes it and // cost the group that inset. **Not reproducible on 2026-09-08**:
// removing the cards' own `Pad` fixes it, while the card background, // `IRIS_TOOLS_EXPANDED=1 iris/run-headless.sh transcript --shot` puts
// the `Sized` wrappers and the per-card `WidgetPtr` all make no // every card's content in its own box with the two spans nested, and
// difference. It is a framework defect rather than this file's -- // `iris`'s `a_span_of_padded_children_inside_a_span_draws_each_where_
// docs/RUST.md's P1b box and docs/IRIS_TODO.md carry the repro -- and // its_box_is` pins that at layer 1. Something between those dates
// one `Span` is the shape that works today. What it costs is the 4dp // fixed it -- most likely f5b8893's `mov`-vs-`reposition` work or the
// inset the Compose group holds its cards off its edge by; the cards' // nested-mask pass -- so the workaround is gone rather than kept
// own padding stands in for it. // against a defect that no longer exists.
{
let mut cards = Span::empty(Dir::DOWN);
for index in 0..count { for index in 0..count {
group.push(build_card_ptr(rsc, shared, index).0); cards.push(build_card_ptr(rsc, shared, index).0);
}
group.push(cards.pad(dp(GROUP_INSET_DP)).add_strong(rsc).any());
} }
// Shutting it from here anchors the other end: the reader is at the // Shutting it from here anchors the other end: the reader is at the
// bottom of a long group, and what they are looking at is what follows // bottom of a long group, and what they are looking at is what follows