Iris's 2026-09-08 report, both halves, and her own diagnosis of the second: "tapping outside of something that a fling is currently active for should have no code in common with the fling that could influence it." `run_sensors` runs a widget one frame after the pointer leaves it (`ActivationState::End`, which is not `Off`) so `HoverEnd` can fire, and `should_run` derived the press and wheel senses from raw button state without consulting `hover`. That farewell frame carried a `PressStart` to a widget the finger was nowhere near -- and a press on already-coasting content is a catch, which commits to a pan with no `DRAG_SLOP`, so the widget captured the pointer and swallowed the whole gesture. Its hover was stale because a gesture that ends while captured returns from the capture branch, which never reaches the loop that updates it. Measured before the fix on the real screen: a fence flicked sideways, then a finger down on a row 500px above it dragged 160px down the screen -- the list moved by zero, the fence moved by zero, and the fence held the pointer throughout. After: the list follows the finger and the fence's fling carries on coasting, which is what she asked for and falls out of the fix rather than being arranged. `should_run` now requires `hover.is_on()` for every non-hover sense. `Drop`/`Cancel` are unaffected -- they are delivered deliberately to a widget that is not under the pointer, with an explicit `On`. Also: the composer is clipped to its own bar rather than inside its padding (`.masked_by(rect(BAR_FILL))` in place of a `.masked()` + `.background()` pair) -- "the box should be clipped rather than the inset text". A long message was being sliced mid-glyph 12dp in from the bar's edge, leaving a band of bare surface above the cut. New: `Scroll::is_scrolling`, the name `List` already uses; the phone rig's `--message TEXT` and `--ime PX`, since the composer's overflowing and keyboard-open states cannot otherwise be looked at headlessly. Tests fail on the old code, one per layer: `a_press_does_not_reach_a_widget_the_pointer_has_just_left` (sensors, no screen) and `a_drag_away_from_a_coasting_fence_scrolls_the_list_and_ leaves_it_coasting` (the report itself, layer 1). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
231 lines
8.4 KiB
Rust
231 lines
8.4 KiB
Rust
//! Layer 1 for Iris's 2026-09-08 "flinging doesn't work in horizontal
|
|
//! scroll areas": a real markdown fence in the real transcript screen,
|
|
//! flicked sideways, has to keep moving after the finger leaves.
|
|
//!
|
|
//! The fence is pushed here rather than hunted for in the bench fixture,
|
|
//! so the test knows which row it is pressing and where. The `Scroll` it
|
|
//! asserts on is found by walking what is actually drawn -- there is no
|
|
//! handle to it from the outside, and a coordinate would only prove that
|
|
//! *something* moved.
|
|
|
|
use iris::harness::{Harness, TouchAction};
|
|
use iris::prelude::*;
|
|
use transcript_fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
|
|
|
/// The horizontal scroll area drawn inside `top..bottom`, with the box
|
|
/// it was drawn at -- a fence is the only thing in a transcript that pans
|
|
/// sideways. Found by walking what is actually drawn, because there is no
|
|
/// handle to a fence's own `Scroll` from the outside and a bare
|
|
/// coordinate would only prove that *something* moved.
|
|
fn fence_scroll_in(h: &Harness, top: f32, bottom: f32) -> Option<(WidgetId, PixelRegion)> {
|
|
h.render
|
|
.active
|
|
.keys()
|
|
.copied()
|
|
.filter(|&id| {
|
|
h.rsc
|
|
.ui
|
|
.widgets
|
|
.get_dyn(id)
|
|
.and_then(|w| w.as_any().downcast_ref::<Scroll>())
|
|
.is_some_and(|s| s.axis() == Axis::X)
|
|
})
|
|
.find_map(|id| {
|
|
let r = h.render.window_region(&id, &h.rsc)?;
|
|
(r.top_left.y >= top && r.bot_right.y <= bottom).then_some((id, r))
|
|
})
|
|
}
|
|
|
|
fn is_scrolling(h: &Harness, id: WidgetId) -> bool {
|
|
h.rsc
|
|
.ui
|
|
.widgets
|
|
.get_dyn(id)
|
|
.and_then(|w| w.as_any().downcast_ref::<Scroll>())
|
|
.expect("the fence's scroll area is still drawn")
|
|
.is_scrolling()
|
|
}
|
|
|
|
fn amt(h: &Harness, id: WidgetId) -> f32 {
|
|
h.rsc
|
|
.ui
|
|
.widgets
|
|
.get_dyn(id)
|
|
.and_then(|w| w.as_any().downcast_ref::<Scroll>())
|
|
.expect("the fence's scroll area is still drawn")
|
|
.amt()
|
|
}
|
|
|
|
#[test]
|
|
fn a_flick_across_a_code_fence_keeps_moving_after_the_finger_leaves() {
|
|
use client_core::transcript_fold::{TranscriptItem, TranscriptRow};
|
|
|
|
let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
|
let opened = transcript_fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
|
|
let screen = opened.screen;
|
|
h.frame(0);
|
|
h.frame(PHONE_FRAME_MS);
|
|
|
|
let fence = TranscriptRow::Single(TranscriptItem::AssistantMsg {
|
|
seq: 9_000_000,
|
|
text: "```\none two three four five six seven eight nine ten eleven twelve \
|
|
thirteen fourteen fifteen sixteen seventeen eighteen twenty twentyone\n```"
|
|
.to_string(),
|
|
settled: true,
|
|
});
|
|
screen.push_row(&mut h.rsc, &fence);
|
|
(screen.list)(&mut h.rsc).jump_to_end();
|
|
h.frame(100);
|
|
h.frame(108);
|
|
|
|
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 (fence_scroll, box_) = fence_scroll_in(&h, top, bottom)
|
|
.expect("the pushed fence draws a horizontal scroll area of its own");
|
|
assert_eq!(amt(&h, fence_scroll), 0.0, "a fence opens at its start");
|
|
|
|
// Down the middle of the fence's own box, so the press is on the
|
|
// text inside the scroll area rather than on the row's sender label.
|
|
let y = (box_.top_left.y + box_.bot_right.y) / 2.0;
|
|
|
|
// A flick sideways: four samples 8ms apart, accelerating, then the
|
|
// finger leaves.
|
|
h.touch(TouchAction::Down, Vec2::new(900.0, y), 200);
|
|
for (i, x) in [860.0, 800.0, 720.0, 620.0].into_iter().enumerate() {
|
|
h.touch(TouchAction::Move, Vec2::new(x, y), 208 + 8 * i as u64);
|
|
}
|
|
h.touch(TouchAction::Up, Vec2::new(620.0, y), 240);
|
|
|
|
let at_release = amt(&h, fence_scroll);
|
|
assert!(
|
|
at_release > 0.0,
|
|
"the flick itself must have panned the fence, got {at_release}"
|
|
);
|
|
|
|
// Frames for the next half second, with nothing touching the screen.
|
|
let mut t = 240;
|
|
while t <= 740 {
|
|
h.frame(t);
|
|
t += PHONE_FRAME_MS;
|
|
}
|
|
let coasted = amt(&h, fence_scroll);
|
|
assert!(
|
|
coasted > at_release + 1.0,
|
|
"the fence stopped dead at the release: {at_release} -> {coasted}"
|
|
);
|
|
|
|
// ...and it settles rather than running forever.
|
|
let settled = coasted;
|
|
while t <= 4_000 {
|
|
h.frame(t);
|
|
t += PHONE_FRAME_MS;
|
|
}
|
|
let after = amt(&h, fence_scroll);
|
|
assert!(
|
|
after >= settled,
|
|
"a fling must not run backwards: {settled} -> {after}"
|
|
);
|
|
let last = after;
|
|
h.frame(t);
|
|
assert_eq!(last, amt(&h, fence_scroll), "the fling never settled");
|
|
}
|
|
|
|
/// Iris's 2026-09-08 report: "if I try to scroll vertically while a
|
|
/// horizontal scroll animation is still active, it stays locked to the
|
|
/// horizontal scroll. It should let it keep going and instead only affect
|
|
/// vertical scrolling."
|
|
///
|
|
/// Her own diagnosis was the right one -- "tapping outside of something
|
|
/// that a fling is currently active for should have no code in common
|
|
/// with the fling that could influence it" -- and
|
|
/// `sense_tests::a_press_does_not_reach_a_widget_the_pointer_has_just_left`
|
|
/// is the mechanism in isolation. This is the same thing over the real
|
|
/// screen, which is where it was found: the finger goes down on an
|
|
/// ordinary row 500px above a coasting fence, and what must move is the
|
|
/// list, while the fence carries on coasting untouched.
|
|
#[test]
|
|
fn a_drag_away_from_a_coasting_fence_scrolls_the_list_and_leaves_it_coasting() {
|
|
use client_core::transcript_fold::{TranscriptItem, TranscriptRow};
|
|
|
|
let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
|
let opened = transcript_fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
|
|
let screen = opened.screen;
|
|
h.frame(0);
|
|
h.frame(PHONE_FRAME_MS);
|
|
|
|
let fence = TranscriptRow::Single(TranscriptItem::AssistantMsg {
|
|
seq: 9_000_000,
|
|
text: format!(
|
|
"```\n{}\n```",
|
|
(1..=200)
|
|
.map(|i| format!("word{i}"))
|
|
.collect::<Vec<_>>()
|
|
.join(" ")
|
|
),
|
|
settled: true,
|
|
});
|
|
screen.push_row(&mut h.rsc, &fence);
|
|
(screen.list)(&mut h.rsc).jump_to_end();
|
|
h.frame(100);
|
|
h.frame(108);
|
|
|
|
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 (fence_scroll, box_) = fence_scroll_in(&h, top, bottom)
|
|
.expect("the pushed fence draws a horizontal scroll area of its own");
|
|
let y = (box_.top_left.y + box_.bot_right.y) / 2.0;
|
|
|
|
// Flick the fence sideways and let go, exactly as above.
|
|
h.touch(TouchAction::Down, Vec2::new(900.0, y), 200);
|
|
for (i, x) in [860.0, 800.0, 720.0, 620.0].into_iter().enumerate() {
|
|
h.touch(TouchAction::Move, Vec2::new(x, y), 208 + 8 * i as u64);
|
|
}
|
|
h.touch(TouchAction::Up, Vec2::new(620.0, y), 240);
|
|
h.frame(248);
|
|
assert!(
|
|
is_scrolling(&h, fence_scroll),
|
|
"the fence has to still be coasting for this to be the reported case",
|
|
);
|
|
|
|
// A row well clear of the fence, taken by its own extent rather than
|
|
// by a coordinate: the gaps between rows are pointer-transparent, so a
|
|
// y picked by hand lands on nothing often enough to make a green run
|
|
// meaningless.
|
|
let probe = box_.top_left.y - 500.0;
|
|
let row = (screen.list)(&mut h.rsc)
|
|
.key_at(probe)
|
|
.expect("a row that far up the screen");
|
|
let (row_top, row_bottom) = (screen.list)(&mut h.rsc).extent(row).expect("its extent");
|
|
let from = (row_top + row_bottom) / 2.0;
|
|
|
|
let list_before = (screen.list)(&mut h.rsc).anchor_position_display();
|
|
let fence_before = amt(&h, fence_scroll);
|
|
h.touch(TouchAction::Down, Vec2::new(540.0, from), 256);
|
|
let mut t = 264;
|
|
for i in 1..=8 {
|
|
h.touch(
|
|
TouchAction::Move,
|
|
Vec2::new(540.0, from + 20.0 * i as f32),
|
|
t,
|
|
);
|
|
t += 8;
|
|
}
|
|
h.touch(TouchAction::Up, Vec2::new(540.0, from + 160.0), t);
|
|
|
|
assert_ne!(
|
|
list_before,
|
|
(screen.list)(&mut h.rsc).anchor_position_display(),
|
|
"the drag was nowhere near the fence, so it belongs to the list",
|
|
);
|
|
assert!(
|
|
amt(&h, fence_scroll) > fence_before,
|
|
"the fence's fling must carry on through a gesture that was never \
|
|
its own: {fence_before} -> {}",
|
|
amt(&h, fence_scroll),
|
|
);
|
|
}
|