//! 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 `ScrollArea` 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 ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size}; use iris::harness::{Harness, TouchAction}; use iris::prelude::*; /// 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 `ScrollArea` 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::()) .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::()) .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::()) .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 ai_app::client::transcript_fold::{TranscriptItem, TranscriptRow}; let mut h = Harness::new(phone_size(), PHONE_SCALE); let opened = ai_app::ui::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 = ai_app::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 ai_app::client::transcript_fold::{TranscriptItem, TranscriptRow}; let mut h = Harness::new(phone_size(), PHONE_SCALE); let opened = ai_app::ui::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::>() .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 = ai_app::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), ); }