iris is the framework alone; the app is one crate in app-rust/

Iris: "the organization of the rust rewrite is a mess right now... there
shouldn't be anything related to the app inside of iris. Iris is supposed
to be the UI framework alone." And, on the crate count: "I'm confused why
the app only code needs more than one crate though."

Nine cargo workspaces become three, and the port's project code -- which
sat in five places, four of them inside the framework -- becomes one crate,
`ai-app`, in `app-rust/`:

  client-core                -> app-rust/src/client
  iris/transcript-ui         -> app-rust/src/ui
  iris/transcript-fixture    -> app-rust/src/ui/fixture.rs + tests/ + touch/
  iris/desktop-app           -> app-rust/src/desktop + src/bin_desktop.rs
  iris/android-app           -> app-rust/src/android + android-project/
  android-shell              -> app-rust/src/shell

iris/ keeps core, macro, the iris crate, tabs-ui and rig-input, and now
mentions no session, transcript, setup or server anywhere.

Only two of the old splits had a reason that survived reading. event-model
stays a crate at the repo root because server/ depends on it too, so a
crate is what makes the backend and the app agree by construction. The two
Android .so names looked like a hard constraint -- a package produces one
library artifact -- until P2 turned out to already plan merging those two
Android apps into one; both faces now come out of libai_app.so, picked
apart by features so `--no-default-features --features shell` keeps wgpu,
parley and iris out of the Compose app's APK. docs/RUST.md's "One app
crate" has the rest, including what each remaining feature is for.

DECISIONS.md and SUBAGENTS.md move into docs/ with everything else.

Verified: ./run-tests.sh and `cd iris && cargo test` green, clippy and fmt
clean in all five workspaces, `cargo ndk -t x86_64` links libai_app.so,
build-apk.sh produces an APK that installs and launches on this checkout's
emulator (Gl ... virgl, as expected), and the phone-sized headless
screenshot renders the transcript unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-08 23:36:38 -04:00
1 parent 7b54aaf3c4
commit a9312e9431
113 files changed
+23221 -2992

No files matched your search

+781
View File
@@ -0,0 +1,781 @@
//! IRIS_TODO.md's "Input does not fall through by input type": a widget
//! that only registered `click()` used to also block a `ScrollArea` meant for
//! whatever is behind it, because `run_sensors` decided "consumed, stop
//! looking at lower layers" from mere hover, not from anything actually
//! matching. Exercised as a plain unit test for the same reason
//! `layout_tests.rs` is one: `UiRenderState` and a minimal `HasEvents`
//! impl need no GPU or window.
use crate::prelude::*;
use std::{cell::Cell, rc::Rc, time::Instant};
struct SenseRsc {
ui: UiData,
events: EventManager<SenseRsc>,
}
impl UiRsc for SenseRsc {
fn ui(&self) -> &UiData {
&self.ui
}
fn ui_mut(&mut self) -> &mut UiData {
&mut self.ui
}
fn on_draw(&mut self, active: &ActiveData) {
self.events.draw(active);
}
fn on_undraw(&mut self, active: &ActiveData) {
self.events.undraw(active);
}
fn on_remove(&mut self, id: WidgetId) {
self.events.remove(id);
}
}
impl HasState for SenseRsc {
type State = ();
}
impl HasEvents for SenseRsc {
fn events(&self) -> &EventManager<Self> {
&self.events
}
fn events_mut(&mut self) -> &mut EventManager<Self> {
&mut self.events
}
}
fn cursor_at(pos: Vec2) -> CursorState {
CursorState {
pos,
exists: true,
buttons: Default::default(),
scroll_delta: Vec2::ZERO,
..Default::default()
}
}
#[test]
fn a_button_over_a_list_scrolls_the_list_and_still_clicks() {
let mut rsc = SenseRsc {
ui: UiData::default(),
events: EventManager::default(),
};
// Both cover the whole window -- the button "sitting over" the list,
// the case in IRIS_TODO.md's report.
let list = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let list_weak = list.weak();
let button = rsc.ui.widgets.add_strong(Rect::new(UiColor::RED));
let button_weak = button.weak();
let scrolled = Rc::new(Cell::new(false));
let clicked = Rc::new(Cell::new(false));
{
let scrolled = scrolled.clone();
rsc.register_event(list_weak, CursorSense::Scroll, move |_ctx, _rsc| {
scrolled.set(true);
});
}
{
let clicked = clicked.clone();
rsc.register_event(button_weak, CursorSense::click(), move |_ctx, _rsc| {
clicked.set(true);
});
}
// A Stack draws its children on separate layers in order, which is
// exactly the "one thing drawn over another" shape `run_sensors`
// walks top layer first.
let root = rsc
.ui
.widgets
.add_strong(Stack {
children: vec![list.any(), button.any()],
size: StackSize::default(),
})
.any();
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
let mut state = ();
let mut scroll_cursor = cursor_at((50.0, 50.0).into());
scroll_cursor.scroll_delta = (0.0, 10.0).into();
render.run_sensors(&mut rsc, &mut state, scroll_cursor, (100.0, 100.0).into());
render.update(&root, &mut rsc);
assert!(
scrolled.get(),
"a scroll over the button must still reach the list underneath it"
);
assert!(
!clicked.get(),
"a scroll is not a click; the button must not have fired"
);
let mut click_cursor = cursor_at((50.0, 50.0).into());
click_cursor.buttons.left = ActivationState::Start;
render.run_sensors(&mut rsc, &mut state, click_cursor, (100.0, 100.0).into());
render.update(&root, &mut rsc);
assert!(
clicked.get(),
"the button on top must still receive an actual click"
);
}
/// The bug behind "finger flings do nothing" (RUST.md's P0 phone report,
/// defect 2): a fast gesture's `PressEnd` can land at a screen position
/// nothing is registered at -- past the edge of whatever widget noticed
/// the press, in a gap, or off the loaded content entirely. Before pointer
/// capture, `run_sensors`' hit test simply delivered nothing that frame,
/// so a widget mid-drag never saw its release and never got a chance to
/// start a fling. `PointerRequests::capture`/`DragGesture` fix this
/// by giving the drag's widget every frame regardless of where the
/// pointer is, including the terminal `Drop` in place of `PressEnd`.
#[test]
fn a_release_outside_every_hit_region_still_reaches_the_captured_widget() {
let mut rsc = SenseRsc {
ui: UiData::default(),
events: EventManager::default(),
};
// A small draggable widget in the corner -- the release below lands
// far outside it, exactly the "moved off the hit region" case.
let draggable = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)).any();
let draggable_weak = draggable.weak();
let dropped = Rc::new(Cell::new(false));
{
let dropped = dropped.clone();
rsc.register_event(
draggable_weak,
CursorSense::click_or_drag() | CursorSense::unclick() | CursorSense::Drop,
move |ctx, rsc| match ctx.data.sense {
CursorSense::PressStart(_) | CursorSense::Pressing(_) => {
// Any committed drag takes capture -- a real caller
// would gate this on a `DragArbiter`/`DragGesture`
// decision, but this test only needs to exercise the
// capture-and-release mechanics themselves.
ctx.data.pointer.capture(draggable_weak.id());
let _ = rsc;
}
CursorSense::Drop => dropped.set(true),
_ => {}
},
);
}
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&draggable, &mut rsc);
let mut state = ();
let mut press = cursor_at((5.0, 5.0).into());
press.buttons.left = ActivationState::Start;
render.run_sensors(&mut rsc, &mut state, press, (100.0, 100.0).into());
render.update(&draggable, &mut rsc);
assert_eq!(
pointer_input(&mut rsc).holder(),
Some(draggable.id()),
"the press should have taken capture"
);
// The release lands nowhere near the widget's own region -- the exact
// shape of a fast fling's `ACTION_UP`.
let mut release = cursor_at((95.0, 95.0).into());
release.buttons.left = ActivationState::End;
render.run_sensors(&mut rsc, &mut state, release, (100.0, 100.0).into());
render.update(&draggable, &mut rsc);
assert!(
dropped.get(),
"a release outside every widget's hit region must still reach \
the widget holding pointer capture"
);
assert_eq!(
pointer_input(&mut rsc).holder(),
None,
"Drop must release the capture"
);
}
/// A widget that never registers `CursorSense::Drop` at all must not be
/// affected by someone else's capture -- capture is per-gesture, not
/// global suppression of the whole input system for widgets that were
/// never party to it. (Practically this matters because a captured
/// widget's registration list still has to include `Drop` for `should_run`
/// to ever match it; this pins that half of the contract.)
#[test]
fn capturing_one_widget_starves_every_other_widget_of_events() {
let mut rsc = SenseRsc {
ui: UiData::default(),
events: EventManager::default(),
};
let a = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let a_weak = a.weak();
let b = rsc.ui.widgets.add_strong(Rect::new(UiColor::RED));
let b_weak = b.weak();
let b_hovered = Rc::new(Cell::new(false));
{
let b_hovered = b_hovered.clone();
rsc.register_event(b_weak, CursorSense::Hovering, move |_ctx, _rsc| {
b_hovered.set(true);
});
}
let root = rsc
.ui
.widgets
.add_strong(Stack {
children: vec![a.any(), b.any()],
size: StackSize::default(),
})
.any();
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
pointer_input(&mut rsc).set_holder(Some(a_weak.id()));
let mut state = ();
let cursor = cursor_at((50.0, 50.0).into());
render.run_sensors(&mut rsc, &mut state, cursor, (100.0, 100.0).into());
render.update(&root, &mut rsc);
assert!(
!b_hovered.get(),
"while a's drag holds capture, b must see no hover at all"
);
}
/// IRIS_TODO.md's "the composer has no touch-drag scroll": `ScrollArea` only
/// answered a wheel, so a finger drag over overflowed text did nothing.
/// End-to-end over the real wiring -- `scrollable()`'s own registration,
/// `run_sensors`' dispatch, `ScrollController::drag`, `DragGesture`'s arbitration and
/// pointer capture -- rather than only `ScrollController::drag`'s own unit tests in
/// `scroll.rs`, because the registration is exactly the half those cannot
/// see.
#[test]
fn a_finger_drag_over_a_scroll_area_pans_it() {
let mut rsc = SenseRsc {
ui: UiData::default(),
events: EventManager::default(),
};
// 1000px of content in a 100px window: room to pan.
let scroll_strong = rect(UiColor::WHITE)
.height(Len::abs(1000.0))
.scrollable(Axis::Y, Pin::Start)
.add_strong(&mut rsc);
let scroll = scroll_strong.weak();
let root = scroll_strong.any();
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
// `ScrollArea` reads its content length back from the draw it just did, so
// the frame after is the first one that knows there is anything to pan
// -- the one-frame lag LAYOUT.md section 4 documents. `scroll(0.0)` is
// how `layout_tests.rs` asks for that second frame, and it also drops
// `snap_end`, leaving this parked at the start of the content.
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
render.update(&root, &mut rsc);
assert_eq!(rsc.ui.widgets.get(&scroll).unwrap().amt(), 0.0);
let mut state = ();
let mut down = cursor_at((50.0, 80.0).into());
down.buttons.left = ActivationState::Start;
render.run_sensors(&mut rsc, &mut state, down, (100.0, 100.0).into());
render.update(&root, &mut rsc);
assert_eq!(
rsc.ui.widgets.get(&scroll).unwrap().amt(),
0.0,
"the touch-down alone must not move anything"
);
// Inside the slop: still a tap as far as anything can tell.
let mut nudge = cursor_at((50.0, 80.0 - (DRAG_SLOP - 1.0)).into());
nudge.buttons.left = ActivationState::On;
render.run_sensors(&mut rsc, &mut state, nudge, (100.0, 100.0).into());
render.update(&root, &mut rsc);
assert_eq!(
rsc.ui.widgets.get(&scroll).unwrap().amt(),
0.0,
"a press inside DRAG_SLOP must not scroll"
);
// Past it, upward: the content follows the finger up, which for this
// widget means more `amt`.
let mut drag = cursor_at((50.0, 80.0 - (DRAG_SLOP + 40.0)).into());
drag.buttons.left = ActivationState::On;
render.run_sensors(&mut rsc, &mut state, drag, (100.0, 100.0).into());
render.update(&root, &mut rsc);
let after = rsc.ui.widgets.get(&scroll).unwrap().amt();
assert!(
(after - 40.0).abs() < 0.01,
"expected the 40px past the slop to pan it, got {after}"
);
// And the gesture holds the pointer, so the rest of it reaches this
// widget even once the finger leaves its box.
assert_eq!(pointer_input(&mut rsc).holder(), Some(scroll.id()));
}
/// docs/REVIEW-2026-09-07.md's D4. The first `MotionEvent` a view sees can
/// be a `Move` -- the `Down` went to another view, or the view was attached
/// mid-gesture -- and its batched samples are older than its own
/// timestamp. Anchoring on that timestamp clamped every one of them onto
/// the anchor, so the tracker saw three samples at one instant, the Lsq2
/// fit went degenerate, and the flick read 0 px/s.
#[test]
fn the_first_events_batched_samples_are_dated_apart() {
const MS: i64 = 1_000_000;
let now = Instant::now();
// A 120Hz batch: three historical samples at 0/4/8ms and the event's
// own at 12ms.
let clock = PointerClock::anchored(now, 12 * MS, 0);
assert_eq!(
clock.at(12 * MS),
now,
"the event's own sample is the one that arrived now"
);
let batch = [clock.at(0), clock.at(4 * MS), clock.at(8 * MS)];
assert!(
batch[0] < batch[1] && batch[1] < batch[2] && batch[2] < now,
"the batch must keep the 4ms between its samples, got {:?}",
batch
.iter()
.map(|t| now.duration_since(*t))
.collect::<Vec<_>>()
);
assert_eq!(clock.ms_since_anchor(8 * MS), 8);
}
/// The same clock has to keep ordering *across* events: the sample it
/// compares a new event's first sample against is the previous event's
/// last one, never the anchor.
#[test]
fn the_clock_orders_samples_across_events() {
const MS: i64 = 1_000_000;
let mut clock = PointerClock::anchored(Instant::now(), 12 * MS, 0);
let first = clock.sample(12 * MS);
let second = clock.sample(28 * MS);
assert!(second > first);
assert_eq!(
second.duration_since(first),
std::time::Duration::from_millis(16)
);
}
/// Iris's 2026-09-08 phone report, first half: "it keeps snapping back to
/// some position when horizontally scrolling."
///
/// A `ScrollArea` that has committed to a pan holds the pointer, so the
/// gesture's end arrives as `CursorSense::Drop` -- and `scrollable`
/// used to register `click_or_drag | unclick` only, which `should_run`
/// never matches a `Drop` against. So the widget never learned its own
/// gesture had ended: its `DragArbiter` stayed `Panning` at the position
/// the finger left, and the *next* drag's first frame was measured from
/// there and applied in one step. The registration is
/// `CursorSense::drag_senses()` now, which is the rule for every widget
/// driving a `DragGesture` rather than a fact about this one.
#[test]
fn a_scroll_area_that_captured_the_pointer_learns_its_gesture_ended() {
let mut rsc = SenseRsc {
ui: UiData::default(),
events: EventManager::default(),
};
let scroll_strong = rect(UiColor::WHITE)
.height(Len::abs(1000.0))
.scrollable(Axis::Y, Pin::Start)
.add_strong(&mut rsc);
let scroll = scroll_strong.weak();
let root = scroll_strong.any();
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
render.update(&root, &mut rsc);
let mut state = ();
let win = Vec2::new(100.0, 100.0);
let mut send = |render: &mut UiRenderState, rsc: &mut SenseRsc, y: f32, button| {
let mut c = cursor_at((50.0, y).into());
c.buttons.left = button;
render.run_sensors(rsc, &mut state, c, win);
render.update(&root, rsc);
};
// One pan of 40px past the slop, then a release well outside the
// widget -- the ordinary shape of a flick.
send(&mut render, &mut rsc, 80.0, ActivationState::Start);
send(
&mut render,
&mut rsc,
80.0 - (DRAG_SLOP + 40.0),
ActivationState::On,
);
let after_first = rsc.ui.widgets.get(&scroll).unwrap().amt();
assert!((after_first - 40.0).abs() < 0.01, "amt={after_first}");
send(&mut render, &mut rsc, 400.0, ActivationState::End);
assert_eq!(
pointer_input(&mut rsc).holder(),
None,
"the release must give the pointer back"
);
// A second gesture, starting where the first one did. If the arbiter
// were still panning from the release position, this first frame
// would apply the whole distance between the two at once.
send(&mut render, &mut rsc, 80.0, ActivationState::Start);
let after_second = rsc.ui.widgets.get(&scroll).unwrap().amt();
assert!(
(after_second - after_first).abs() < 0.01,
"a fresh touch-down moved the content by {} -- the previous \
gesture was never closed",
after_second - after_first,
);
}
/// The second half of the same report: "tapping sometimes seems to make
/// the scrolling jump, particularly when tapping on things that have
/// events like horizontal scrolling."
///
/// Two widgets see the same press -- a scroll area and, under it,
/// something tracking the gesture for a list. When the scroll area
/// captures, the other one is cut off completely: no `PressEnd`, no
/// `Drop`. It has to be told, or its gesture stays open at an origin
/// belonging to a finger that has long gone, and the next unrelated touch
/// is measured from it.
#[test]
fn taking_the_pointer_cancels_everyone_else_tracking_the_press() {
let mut rsc = SenseRsc {
ui: UiData::default(),
events: EventManager::default(),
};
// The bystander *contains* the capturer, which is the real shape: a
// transcript's `LazySpan` and one row's own text both track the same
// press, and a `Stack`'s siblings would be on separate layers where
// only the topmost is dispatched to at all.
let capturer = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let capturer_weak = capturer.weak();
let bystander = rsc.ui.widgets.add_strong(Stack {
children: vec![capturer.any()],
size: StackSize::default(),
});
let bystander_weak = bystander.weak();
let capturer_saw = Rc::new(Cell::new(0u32));
{
let capturer_saw = capturer_saw.clone();
rsc.register_event(
capturer_weak,
CursorSense::drag_senses(),
move |ctx, _rsc| {
capturer_saw.set(capturer_saw.get() + 1);
if matches!(ctx.data.sense, CursorSense::Pressing(_)) {
ctx.data.pointer.capture(capturer_weak.id());
}
},
);
}
let cancelled = Rc::new(Cell::new(0u32));
let ended = Rc::new(Cell::new(0u32));
{
let (cancelled, ended) = (cancelled.clone(), ended.clone());
rsc.register_event(
bystander_weak,
CursorSense::drag_senses(),
move |ctx, _rsc| match ctx.data.sense {
CursorSense::Cancel => cancelled.set(cancelled.get() + 1),
CursorSense::PressEnd(_) | CursorSense::Drop => ended.set(ended.get() + 1),
_ => {}
},
);
}
let root = bystander.any();
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
let mut state = ();
let win = Vec2::new(100.0, 100.0);
let mut down = cursor_at((50.0, 50.0).into());
down.buttons.left = ActivationState::Start;
render.run_sensors(&mut rsc, &mut state, down, win);
render.update(&root, &mut rsc);
assert_eq!(cancelled.get(), 0, "nothing has captured yet");
let mut moved = cursor_at((50.0, 20.0).into());
moved.buttons.left = ActivationState::On;
render.run_sensors(&mut rsc, &mut state, moved, win);
render.update(&root, &mut rsc);
assert!(capturer_saw.get() > 0, "the capturer never saw the press");
assert_eq!(
pointer_input(&mut rsc).holder(),
Some(capturer_weak.id()),
"the capture should have been taken on this frame"
);
assert_eq!(
cancelled.get(),
1,
"the widget that lost the gesture must be told exactly once"
);
// And exactly once: the frames after the capture reach the capturer
// alone, so there is nothing left to cancel.
let mut more = cursor_at((50.0, 10.0).into());
more.buttons.left = ActivationState::On;
render.run_sensors(&mut rsc, &mut state, more, win);
render.update(&root, &mut rsc);
let mut up = cursor_at((50.0, 10.0).into());
up.buttons.left = ActivationState::End;
render.run_sensors(&mut rsc, &mut state, up, win);
render.update(&root, &mut rsc);
assert_eq!(cancelled.get(), 1, "cancelled more than once");
assert_eq!(
ended.get(),
0,
"a cancelled widget must not also be told the gesture ended \
normally -- acting on that is the tap it never made"
);
}
/// Iris's rule for nested scrolling, 2026-09-08: "it should only trigger
/// horizontal if you drag left or right, and vertical should fall through
/// if you drag up or down."
///
/// One mechanism does both, and it is `DragArbiter`'s existing axis test:
/// each scroll area's gesture commits only on its own axis, so a drag
/// along the other one is never claimed and the enclosing area's gesture
/// -- which sees the same press, being an ancestor rather than a sibling
/// layer -- is the one that commits and captures. This pins the pair,
/// including the direction the change had no reason to touch.
#[test]
fn a_drag_pans_whichever_nested_scroll_area_owns_its_axis() {
for (name, to, pans, still) in [
("vertical", Vec2::new(50.0, 80.0 - (DRAG_SLOP + 40.0)), 0, 1),
(
"horizontal",
Vec2::new(50.0 - (DRAG_SLOP + 40.0), 80.0),
1,
0,
),
] {
let mut rsc = SenseRsc {
ui: UiData::default(),
events: EventManager::default(),
};
// 1000px square of content in a 100px window: room to pan either
// way, in an X area inside a Y one.
let seen = Rc::new(Cell::new(None));
let record = seen.clone();
let outer_strong = rect(UiColor::WHITE)
.width(Len::abs(1000.0))
.height(Len::abs(1000.0))
.scrollable(Axis::X, Pin::Start)
// The inner area's own handle, taken as the chain is built --
// the whole point is to exercise `scrollable`'s real
// registration on both, so neither is assembled by hand.
.with_id(move |_rsc, id| {
record.set(Some(id));
id
})
.scrollable(Axis::Y, Pin::Start)
.add_strong(&mut rsc);
let inner = seen.get().unwrap();
let outer = outer_strong.weak();
let root = outer_strong.any();
let areas = [outer, inner];
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
// The second frame, where each area knows its content length --
// LAYOUT.md section 4's one-frame lag, and what drops `snap_end`.
for a in areas {
rsc.ui.widgets.get_mut(&a).unwrap().scroll(0.0);
}
render.update(&root, &mut rsc);
let mut state = ();
let win = Vec2::new(100.0, 100.0);
let mut down = cursor_at((50.0, 80.0).into());
down.buttons.left = ActivationState::Start;
render.run_sensors(&mut rsc, &mut state, down, win);
render.update(&root, &mut rsc);
let mut drag = cursor_at(to);
drag.buttons.left = ActivationState::On;
render.run_sensors(&mut rsc, &mut state, drag, win);
render.update(&root, &mut rsc);
let moved = rsc.ui.widgets.get(&areas[pans]).unwrap().amt();
let unmoved = rsc.ui.widgets.get(&areas[still]).unwrap().amt();
assert!(
(moved - 40.0).abs() < 0.01,
"a {name} drag should have panned the {name} area by the 40px \
past the slop, got {moved}"
);
assert_eq!(
unmoved, 0.0,
"a {name} drag must not move the area that owns the other axis"
);
}
}
/// 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", with her own diagnosis -- "tapping outside of
/// something that a fling is currently active for should have no code in
/// common with the fling that could influence it."
///
/// She was right that it was global state, and this is where it lived.
/// `run_sensors` runs a widget one more frame *after* the pointer has
/// left it, so a `HoverEnd` can fire ([`ActivationState::End`], which is
/// not `Off`) -- and `should_run` derived a press from the button alone,
/// so that farewell frame also carried a `PressStart`. A widget nowhere
/// near the finger therefore opened a gesture, and a `ScrollArea` catching
/// its own fling commits with no slop, so it captured the pointer and the
/// whole gesture went to it.
///
/// Two areas side by side here rather than one, because "the press went
/// to the wrong widget" and "the press went nowhere" are different
/// failures and only the second area can tell them apart.
#[test]
fn a_press_does_not_reach_a_widget_the_pointer_has_just_left() {
let mut rsc = SenseRsc {
ui: UiData::default(),
events: EventManager::default(),
};
// Two 1000px-tall scroll areas, stacked: the top half of the window
// is the first, the bottom half the second. Each area's own handle is
// taken as its chain is built (`with_id`, the same way the nested-axes
// test above does it), since what is under test is `scrollable()`'s
// real registration rather than a `ScrollArea` assembled by hand.
let seen: [Rc<Cell<Option<WeakWidget<ScrollArea>>>>; 2] = Default::default();
let half = |slot: &Rc<Cell<Option<WeakWidget<ScrollArea>>>>| {
let record = slot.clone();
rect(UiColor::WHITE)
.height(Len::abs(1000.0))
.scrollable(Axis::Y, Pin::Start)
.with_id(move |_rsc, id| {
record.set(Some(id));
id
})
.height(Len::rel(0.5))
};
let root = (half(&seen[0]), half(&seen[1]))
.span(Dir::DOWN)
.add_strong(&mut rsc)
.any();
let (top_w, bottom_w) = (seen[0].get().unwrap(), seen[1].get().unwrap());
let win: Vec2 = (100.0, 200.0).into();
let mut render = UiRenderState::new();
render.resize((win.x, win.y));
render.update(&root, &mut rsc);
// The second frame is the first that knows how long the content is --
// see `a_finger_drag_over_a_scroll_area_pans_it`.
for w in [&top_w, &bottom_w] {
rsc.ui.widgets.get_mut(w).unwrap().scroll(0.0);
}
render.update(&root, &mut rsc);
let mut state = ();
// Flick the top area and let go: it is left flinging, and -- because
// the release goes through `run_sensors`' capture branch, which
// returns before the loop that would have updated anybody's hover --
// its sensor is left `On` with the pointer no longer on it. Both
// halves of the real gesture, since both are what the bug needs.
let base = Instant::now();
let mut t = 0;
let sample = |render: &mut UiRenderState,
rsc: &mut SenseRsc,
state: &mut (),
y: f32,
button: ActivationState,
at_ms: u64| {
let mut c = cursor_at((50.0, y).into());
c.buttons.left = button;
c.time = base + std::time::Duration::from_millis(at_ms);
render.run_sensors(rsc, state, c, win);
render.update(&root, rsc);
};
sample(
&mut render,
&mut rsc,
&mut state,
50.0,
ActivationState::Start,
t,
);
for y in [44.0, 32.0, 14.0] {
t += 8;
sample(&mut render, &mut rsc, &mut state, y, ActivationState::On, t);
}
t += 8;
sample(
&mut render,
&mut rsc,
&mut state,
14.0,
ActivationState::End,
t,
);
assert!(
rsc.ui.widgets.get(&top_w).unwrap().is_scrolling(),
"the flick must leave the top area coasting -- the press below is \
only dangerous while something is still moving",
);
let flung_to = rsc.ui.widgets.get(&top_w).unwrap().amt();
// Now press and drag in the *bottom* area: the top area's hover
// decays to `End` on this very sample, which is the frame that used
// to carry a `PressStart` to it.
t += 8;
sample(
&mut render,
&mut rsc,
&mut state,
150.0,
ActivationState::Start,
t,
);
t += 8;
sample(
&mut render,
&mut rsc,
&mut state,
150.0 - (DRAG_SLOP + 40.0),
ActivationState::On,
t,
);
let moved = rsc.ui.widgets.get(&bottom_w).unwrap().amt();
assert!(
(moved - 40.0).abs() < 0.01,
"the area actually under the finger should have panned by the 40px \
past the slop, got {moved}"
);
assert_eq!(
rsc.ui.widgets.get(&top_w).unwrap().amt(),
flung_to,
"the area the pointer had left must not have seen the press at all -- \
a catch would have stopped its fling on the touch-down"
);
assert_eq!(
pointer_input(&mut rsc).holder(),
Some(bottom_w.id()),
"the gesture belongs to the widget under the finger",
);
}