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>
207 lines
8.0 KiB
Rust
207 lines
8.0 KiB
Rust
//! 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
|
|
);
|
|
}
|