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:
1 parent
9e301f30c6
commit
fc82d9d7e8
9 files changed
+558
-95
No files matched your search
@@ -714,17 +714,24 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
||||
MotionAction::Move => {
|
||||
ui_state.cursor.pos = vec2(x, y);
|
||||
}
|
||||
// `Cancel` ends the gesture the same way `Up` does, and must:
|
||||
// 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 => {
|
||||
MotionAction::Up => {
|
||||
ui_state.cursor.pos = vec2(x, y);
|
||||
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,
|
||||
}
|
||||
if trace_input {
|
||||
|
||||
+12
-4
@@ -42,9 +42,11 @@ pub enum TouchAction {
|
||||
Move,
|
||||
Up,
|
||||
/// The gesture taken away by the system (a parent view claiming it, a
|
||||
/// call arriving). It ends the press exactly as `Up` does -- a
|
||||
/// release that never arrives leaves pointer capture held forever --
|
||||
/// which is why a replay file can say it.
|
||||
/// call arriving, the swipe up from the bottom edge to leave the
|
||||
/// app). It ends the press, because a release that never arrives
|
||||
/// 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,
|
||||
}
|
||||
|
||||
@@ -393,7 +395,13 @@ impl Harness {
|
||||
self.cursor.buttons.left.update(true);
|
||||
}
|
||||
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
|
||||
// doc): no batching happens here, so `historical` is always empty
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -151,6 +151,26 @@ pub struct CursorState {
|
||||
/// which is exactly the inferred-as-measured number UI_RULES.md
|
||||
/// forbids. Carrying the sample's own time makes the span real.
|
||||
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 {
|
||||
@@ -161,6 +181,7 @@ impl Default for CursorState {
|
||||
buttons: CursorButtons::default(),
|
||||
scroll_delta: Vec2::ZERO,
|
||||
time: Instant::now(),
|
||||
cancelled: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,6 +223,7 @@ impl CursorState {
|
||||
pub fn end_frame(&mut self) {
|
||||
self.buttons.end_frame();
|
||||
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();
|
||||
|
||||
// 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
|
||||
// some widget has committed to a drag, every other widget sees
|
||||
// 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
|
||||
// for, since its origin then measures the *next* touch and
|
||||
// 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 => {
|
||||
if crate::diagnostics::trace_enabled() {
|
||||
log::debug!(
|
||||
|
||||
+82
-17
@@ -1002,12 +1002,23 @@ impl List {
|
||||
/// skip-or-move `draw_inner` already provides for an unchanged or
|
||||
/// merely-translated widget. Caching the height once measured is what
|
||||
/// 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
|
||||
/// cached height (the row's content changed height since) briefly
|
||||
/// offers the wrong box; the height recorded from what it *actually*
|
||||
/// reports this frame corrects it starting next frame -- the same
|
||||
/// one-frame lag `Scroll`'s own content-length cache accepts, per
|
||||
/// LAYOUT.md.
|
||||
/// first-seen one still gets a correctly-sized initial paint.
|
||||
///
|
||||
/// **A row whose measurement disagrees with the box it was offered is
|
||||
/// drawn again, this frame, at the box its own height implies** --
|
||||
/// both placements, since both offer a cached height and both can be
|
||||
/// 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) {
|
||||
// Every current caller derives `slot` from `repair_anchor`/
|
||||
// `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 used = painter.widget_within(self.slot_widget(slot), region);
|
||||
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)
|
||||
}
|
||||
(Placement::Top(top), None) => {
|
||||
@@ -1085,17 +1100,8 @@ impl List {
|
||||
let used = painter.widget_within(self.slot_widget(slot), region);
|
||||
let height = resolve(used);
|
||||
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);
|
||||
painter.reposition(self.slot_widget(slot), corrected);
|
||||
painter.widget_within(self.slot_widget(slot), corrected);
|
||||
}
|
||||
(bottom - height, bottom, height)
|
||||
}
|
||||
@@ -1392,6 +1398,17 @@ mod tests {
|
||||
/// background rect's own id (to check what it actually painted at)
|
||||
/// alongside the row widget.
|
||||
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_id = bg.id();
|
||||
let fg_rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
||||
@@ -1400,11 +1417,59 @@ mod tests {
|
||||
x: None,
|
||||
y: Some(Len::abs(height)),
|
||||
});
|
||||
let fg_weak = fg.weak();
|
||||
let stack = Stack {
|
||||
children: vec![bg.any(), fg.any()],
|
||||
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]
|
||||
|
||||
@@ -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, ¶);
|
||||
(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(¶.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
|
||||
@@ -217,9 +217,9 @@ where
|
||||
|
||||
let tap_links = links.clone();
|
||||
field
|
||||
// `| CursorSense::unclick()` on top of the usual click-or-drag set
|
||||
// -- this block's own registration only ever needs to see a
|
||||
// gesture's *first* frame (`PressStart`, or a `Pressing` that
|
||||
// The whole `drag_senses()` set, which is what every widget
|
||||
// driving a `DragGesture` registers. This block normally only sees
|
||||
// a gesture's *first* frames (`PressStart`, or a `Pressing` that
|
||||
// missed it -- `DragGesture::handle`'s idle-recovery branch); once
|
||||
// it commits, `DragGesture` takes pointer capture on `list`'s own
|
||||
// id and every further frame, including the terminal `Drop`,
|
||||
@@ -227,37 +227,43 @@ where
|
||||
// `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-
|
||||
// drag as content scrolls.
|
||||
.on(
|
||||
CursorSense::click_or_drag() | CursorSense::unclick(),
|
||||
move |ctx, rsc| {
|
||||
let (pos, size, cursor) = (ctx.data.pos, ctx.data.size, ctx.data.cursor.pos);
|
||||
let outcome = selection.borrow_mut().drag(
|
||||
rsc,
|
||||
list,
|
||||
Some((key, pos, size)),
|
||||
cursor,
|
||||
ctx.data.sense,
|
||||
ctx.data.cursor.time,
|
||||
ctx.data.pointer,
|
||||
);
|
||||
// A *tap*, decided by the same `DragArbiter` the pan and
|
||||
// the selection are: a gesture that panned the list past
|
||||
// this link, or held long enough to select, must not also
|
||||
// follow it (`GestureOutcome::Tapped`'s doc).
|
||||
if outcome == GestureOutcome::Tapped {
|
||||
let byte = field.edit(rsc).byte_at(cursor, size);
|
||||
let url = tap_links
|
||||
.borrow()
|
||||
.iter()
|
||||
.find(|l| l.range.contains(&byte))
|
||||
.map(|l| l.url.clone());
|
||||
if let Some(url) = url {
|
||||
log::info!("iris link: opening {url}");
|
||||
<Rsc::State as OpenUrl>::open_url(ctx.state, &url);
|
||||
}
|
||||
//
|
||||
// `Cancel` is the one that is *not* optional, and leaving it out
|
||||
// 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 outcome = selection.borrow_mut().drag(
|
||||
rsc,
|
||||
list,
|
||||
Some((key, pos, size)),
|
||||
cursor,
|
||||
ctx.data.sense,
|
||||
ctx.data.cursor.time,
|
||||
ctx.data.pointer,
|
||||
);
|
||||
// A *tap*, decided by the same `DragArbiter` the pan and
|
||||
// the selection are: a gesture that panned the list past
|
||||
// this link, or held long enough to select, must not also
|
||||
// follow it (`GestureOutcome::Tapped`'s doc).
|
||||
if outcome == GestureOutcome::Tapped {
|
||||
let byte = field.edit(rsc).byte_at(cursor, size);
|
||||
let url = tap_links
|
||||
.borrow()
|
||||
.iter()
|
||||
.find(|l| l.range.contains(&byte))
|
||||
.map(|l| l.url.clone());
|
||||
if let Some(url) = url {
|
||||
log::info!("iris link: opening {url}");
|
||||
<Rsc::State as OpenUrl>::open_url(ctx.state, &url);
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
.add(rsc);
|
||||
|
||||
// The column holds the *framed* widget; the field is what
|
||||
|
||||
@@ -89,6 +89,8 @@ const GAP_DP: f32 = 8.0;
|
||||
const RAW_RADIUS_DP: f32 = 4.0;
|
||||
/// The room inside a verbatim block.
|
||||
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
|
||||
/// rest behind a tap.
|
||||
@@ -190,23 +192,23 @@ fn on_tap<Rsc: HasEvents>(
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
let (list, selection) = (shared.list, shared.selection.clone());
|
||||
ptr.on(
|
||||
CursorSense::click_or_drag() | CursorSense::unclick(),
|
||||
move |ctx, rsc| {
|
||||
let outcome = selection.borrow_mut().drag(
|
||||
rsc,
|
||||
list,
|
||||
None,
|
||||
ctx.data.cursor.pos,
|
||||
ctx.data.sense,
|
||||
ctx.data.cursor.time,
|
||||
ctx.data.pointer,
|
||||
);
|
||||
if outcome == GestureOutcome::Tapped {
|
||||
f(rsc);
|
||||
}
|
||||
},
|
||||
)
|
||||
// The whole `drag_senses()` set -- what any widget driving a
|
||||
// `DragGesture` registers, `Cancel` included. See `row.rs`'s twin
|
||||
// registration for what leaving `Cancel` out did.
|
||||
ptr.on(CursorSense::drag_senses(), move |ctx, rsc| {
|
||||
let outcome = selection.borrow_mut().drag(
|
||||
rsc,
|
||||
list,
|
||||
None,
|
||||
ctx.data.cursor.pos,
|
||||
ctx.data.sense,
|
||||
ctx.data.cursor.time,
|
||||
ctx.data.pointer,
|
||||
);
|
||||
if outcome == GestureOutcome::Tapped {
|
||||
f(rsc);
|
||||
}
|
||||
})
|
||||
.add(rsc);
|
||||
}
|
||||
|
||||
@@ -665,22 +667,26 @@ where
|
||||
.add_strong(rsc)
|
||||
.any(),
|
||||
);
|
||||
// The cards go straight into the group's own `Span`, not into a
|
||||
// second one inside it. **A `Span` of `Pad`ded children inside another
|
||||
// `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
|
||||
// as empty bars with somebody else's summary in them. Bisected on
|
||||
// 2026-09-06 against `iris/run-headless.sh transcript` with
|
||||
// `IRIS_TOOLS_EXPANDED=1`: removing the inner `Span` fixes it and
|
||||
// removing the cards' own `Pad` fixes it, while the card background,
|
||||
// the `Sized` wrappers and the per-card `WidgetPtr` all make no
|
||||
// difference. It is a framework defect rather than this file's --
|
||||
// docs/RUST.md's P1b box and docs/IRIS_TODO.md carry the repro -- and
|
||||
// one `Span` is the shape that works today. What it costs is the 4dp
|
||||
// inset the Compose group holds its cards off its edge by; the cards'
|
||||
// own padding stands in for it.
|
||||
for index in 0..count {
|
||||
group.push(build_card_ptr(rsc, shared, index).0);
|
||||
// The cards sit in their own `Span` inside the group's, inset from
|
||||
// its edge the way `ToolRows.kt` insets them.
|
||||
//
|
||||
// This shape was flattened into one `Span` between 2026-09-06 and
|
||||
// 2026-09-08 to work around "a `Span` of `Pad`ded children inside
|
||||
// another `Span` places those children a slot out of step", which
|
||||
// cost the group that inset. **Not reproducible on 2026-09-08**:
|
||||
// `IRIS_TOOLS_EXPANDED=1 iris/run-headless.sh transcript --shot` puts
|
||||
// every card's content in its own box with the two spans nested, and
|
||||
// `iris`'s `a_span_of_padded_children_inside_a_span_draws_each_where_
|
||||
// its_box_is` pins that at layer 1. Something between those dates
|
||||
// fixed it -- most likely f5b8893's `mov`-vs-`reposition` work or the
|
||||
// nested-mask pass -- so the workaround is gone rather than kept
|
||||
// against a defect that no longer exists.
|
||||
{
|
||||
let mut cards = Span::empty(Dir::DOWN);
|
||||
for index in 0..count {
|
||||
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
|
||||
// bottom of a long group, and what they are looking at is what follows
|
||||
|
||||
Reference in new issue
Block a user