diff --git a/iris/src/android/view.rs b/iris/src/android/view.rs index be97be4..39ce3d7 100644 --- a/iris/src/android/view.rs +++ b/iris/src/android/view.rs @@ -714,17 +714,24 @@ impl ViewPeer for IrisViewPeer { 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 { diff --git a/iris/src/harness.rs b/iris/src/harness.rs index dd3fe14..ee2e1ca 100644 --- a/iris/src/harness.rs +++ b/iris/src/harness.rs @@ -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 diff --git a/iris/src/layout_tests.rs b/iris/src/layout_tests.rs index 6794c3b..0d5720b 100644 --- a/iris/src/layout_tests.rs +++ b/iris/src/layout_tests.rs @@ -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:?}" + ); + } +} diff --git a/iris/src/sense.rs b/iris/src/sense.rs index 2639a63..864ae7a 100644 --- a/iris/src/sense.rs +++ b/iris/src/sense.rs @@ -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 = 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::().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!( diff --git a/iris/src/widget/list.rs b/iris/src/widget/list.rs index 5a67e57..959a221 100644 --- a/iris/src/widget/list.rs +++ b/iris/src/widget/list.rs @@ -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, 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] diff --git a/iris/transcript-fixture/tests/gesture_cancel.rs b/iris/transcript-fixture/tests/gesture_cancel.rs new file mode 100644 index 0000000..694678a --- /dev/null +++ b/iris/transcript-fixture/tests/gesture_cancel.rs @@ -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 + ); +} diff --git a/iris/transcript-fixture/touch/flick-cancelled.touch b/iris/transcript-fixture/touch/flick-cancelled.touch new file mode 100644 index 0000000..043dd6a --- /dev/null +++ b/iris/transcript-fixture/touch/flick-cancelled.touch @@ -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 diff --git a/iris/transcript-ui/src/row.rs b/iris/transcript-ui/src/row.rs index 157d057..fdd439a 100644 --- a/iris/transcript-ui/src/row.rs +++ b/iris/transcript-ui/src/row.rs @@ -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}"); - ::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}"); + ::open_url(ctx.state, &url); } - }, - ) + } + }) .add(rsc); // The column holds the *framed* widget; the field is what diff --git a/iris/transcript-ui/src/tool.rs b/iris/transcript-ui/src/tool.rs index b7825b2..e7ca8e2 100644 --- a/iris/transcript-ui/src/tool.rs +++ b/iris/transcript-ui/src/tool.rs @@ -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::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