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>
212 lines
8.0 KiB
Rust
212 lines
8.0 KiB
Rust
//! Layer 1 of docs/RUST.md's "Three test layers", for catching a fling:
|
|
//! the real transcript screen over the real bench fixture, at the phone's
|
|
//! size and density, with no window, no compositor and no GPU.
|
|
//!
|
|
//! docs/IRIS_TODO.md's 2026-09-07 night report -- "sometimes when I try to
|
|
//! catch it while it's still moving (particularly if I drag) then it fails
|
|
//! to stop & snap to where finger is". The finger goes down on content
|
|
//! that is still travelling and the content does not follow it until
|
|
//! `DRAG_SLOP` has been crossed, which at a fling's speed is several
|
|
//! frames of the content sliding *away* from a finger that is already
|
|
//! down. Compose does not do that: a down while `isScrollInProgress`
|
|
//! starts the drag immediately (`scrollable`'s `startDragImmediately`).
|
|
|
|
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
|
use iris::harness::{Harness, TouchAction, TouchScript};
|
|
use iris::prelude::*;
|
|
use iris::sense::DRAG_SLOP;
|
|
|
|
/// The screen open on the fixture, framed twice -- once to draw, once for
|
|
/// `LazySpan::repair_anchor` to resolve the opening `snap_end` into a real
|
|
/// anchor, which is what every assertion about scroll position reads.
|
|
fn opened() -> (Harness, ai_app::ui::TranscriptScreen) {
|
|
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");
|
|
h.frame(0);
|
|
h.frame(PHONE_FRAME_MS);
|
|
(h, opened.screen)
|
|
}
|
|
|
|
/// Where the content is, in window pixels: the top of whichever row is
|
|
/// under the middle of the viewport. `LazySpan` has no travel accessor and
|
|
/// this needs none -- a row's own extent moves exactly as far as the
|
|
/// content does, and the row is picked once so the two readings compare.
|
|
fn tracked_row(h: &mut Harness, screen: &ai_app::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: &ai_app::ui::TranscriptScreen, key: RowKey) -> f32 {
|
|
(screen.list)(&mut h.rsc)
|
|
.extent(key)
|
|
.expect("the tracked row is still loaded")
|
|
.0
|
|
}
|
|
|
|
/// Three finger samples 8ms apart, each moving `STEP` further down the
|
|
/// screen. `STEP * 3` is deliberately **under** `DRAG_SLOP`: a gesture
|
|
/// this small moves nothing at all on a settled list (the control below),
|
|
/// so anything it moves here is the catch and not the slop being crossed.
|
|
const STEP: f32 = 2.0;
|
|
const SAMPLES: usize = 3;
|
|
const CATCH_X: f32 = 540.0;
|
|
|
|
/// Feeds the down and its `SAMPLES` moves from `y0` at `t0`, asserting
|
|
/// after each one that the content moved by exactly the finger's own
|
|
/// delta. Returns the release time.
|
|
fn drag_from(
|
|
h: &mut Harness,
|
|
screen: &ai_app::ui::TranscriptScreen,
|
|
key: RowKey,
|
|
y0: f32,
|
|
t0: u64,
|
|
expect_tracking: bool,
|
|
) -> u64 {
|
|
let before = row_top(h, screen, key);
|
|
h.touch(TouchAction::Down, Vec2::new(CATCH_X, y0), t0);
|
|
assert_eq!(
|
|
row_top(h, screen, key),
|
|
before,
|
|
"the down itself must not move the content, only stop it"
|
|
);
|
|
let mut t = t0;
|
|
for i in 1..=SAMPLES {
|
|
let moved = STEP * i as f32;
|
|
t = t0 + 8 * i as u64;
|
|
h.touch(TouchAction::Move, Vec2::new(CATCH_X, y0 + moved), t);
|
|
let travelled = row_top(h, screen, key) - before;
|
|
if expect_tracking {
|
|
assert!(
|
|
(travelled - moved).abs() < 0.5,
|
|
"sample {i}: the finger has moved {moved}px since the down and the content \
|
|
{travelled:.1}px -- it is not pinned to the finger"
|
|
);
|
|
} else {
|
|
assert!(
|
|
travelled.abs() < 0.5,
|
|
"sample {i}: a {moved}px drag is inside DRAG_SLOP ({DRAG_SLOP}px) and must move \
|
|
nothing, but the content moved {travelled:.1}px"
|
|
);
|
|
}
|
|
}
|
|
t += 8;
|
|
h.touch(
|
|
TouchAction::Up,
|
|
Vec2::new(CATCH_X, y0 + STEP * SAMPLES as f32),
|
|
t,
|
|
);
|
|
t
|
|
}
|
|
|
|
/// The report itself: flick, let the fling run for 150ms, then put a
|
|
/// finger down and drag it a little. From the down onwards the content is
|
|
/// pinned to the finger, sample for sample -- no slop, and no coasting
|
|
/// past the place the finger stopped it.
|
|
#[test]
|
|
fn a_press_on_a_flinging_list_pins_the_content_to_the_finger() {
|
|
let (mut h, screen) = opened();
|
|
|
|
let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch"))
|
|
.unwrap_or_else(|e| panic!("flick-120hz.touch: {e}"));
|
|
h.replay(&flick);
|
|
|
|
// Frames, not a file: the second half of this gesture has to arrive
|
|
// *while* the fling is ticking, and a `.touch` replay inserts no
|
|
// frames between its samples, so a fling recorded that way would be
|
|
// running on paper and stationary in fact.
|
|
let catch_at = flick.end_ms() + 150;
|
|
h.frames_until(
|
|
flick.end_ms() + PHONE_FRAME_MS,
|
|
catch_at - PHONE_FRAME_MS,
|
|
PHONE_FRAME_MS,
|
|
);
|
|
assert!(
|
|
(screen.list)(&mut h.rsc).is_scrolling(),
|
|
"the fling must still be running 150ms in, or this test catches nothing"
|
|
);
|
|
|
|
let (key, _) = tracked_row(&mut h, &screen);
|
|
drag_from(&mut h, &screen, key, 1200.0, catch_at, true);
|
|
}
|
|
|
|
/// The other half of the same rule: a catch that never moved at all is a
|
|
/// `Released(None)`, not a tap. Compose's scrollable consumes that DOWN,
|
|
/// so no click detector under it ever sees the gesture -- stopping a
|
|
/// fling with a finger must not also follow the link it landed on, and
|
|
/// must not hand the list a velocity to start again with.
|
|
#[test]
|
|
fn a_catch_that_never_moved_is_not_a_tap_and_does_not_fling() {
|
|
let (mut h, screen) = opened();
|
|
|
|
let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch"))
|
|
.unwrap_or_else(|e| panic!("flick-120hz.touch: {e}"));
|
|
h.replay(&flick);
|
|
let catch_at = flick.end_ms() + 150;
|
|
h.frames_until(
|
|
flick.end_ms() + PHONE_FRAME_MS,
|
|
catch_at - PHONE_FRAME_MS,
|
|
PHONE_FRAME_MS,
|
|
);
|
|
assert!(
|
|
(screen.list)(&mut h.rsc).is_scrolling(),
|
|
"the fling must still be running 150ms in, or this test catches nothing"
|
|
);
|
|
|
|
let (key, _) = tracked_row(&mut h, &screen);
|
|
h.touch(TouchAction::Down, Vec2::new(CATCH_X, 1200.0), catch_at);
|
|
let stopped_at = row_top(&mut h, &screen, key);
|
|
h.touch(TouchAction::Up, Vec2::new(CATCH_X, 1200.0), catch_at + 8);
|
|
|
|
assert_eq!(
|
|
(screen.list)(&mut h.rsc).fling_velocity(),
|
|
None,
|
|
"a press that stopped a fling and moved nothing must not start another"
|
|
);
|
|
h.frames_until(catch_at + 16, catch_at + 500, PHONE_FRAME_MS);
|
|
assert!(
|
|
(row_top(&mut h, &screen, key) - stopped_at).abs() < 0.5,
|
|
"the content moved after a catch was released without moving"
|
|
);
|
|
assert_eq!(
|
|
h.state.opened_urls,
|
|
Vec::<String>::new(),
|
|
"a catch is not a tap: nothing under it may be followed"
|
|
);
|
|
}
|
|
|
|
/// The half this change had no reason to touch: on a list that is *not*
|
|
/// moving, the same tiny drag is still inside `DRAG_SLOP` and still moves
|
|
/// nothing. Without this, making every press pin the content would pass
|
|
/// the test above and take the slop away from every ordinary press.
|
|
#[test]
|
|
fn the_same_small_drag_on_a_settled_list_moves_nothing() {
|
|
let (mut h, screen) = opened();
|
|
|
|
let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch"))
|
|
.unwrap_or_else(|e| panic!("flick-120hz.touch: {e}"));
|
|
h.replay(&flick);
|
|
// Long past the spline's own 2071ms for this recording.
|
|
let settled = h.frames_until(
|
|
flick.end_ms() + PHONE_FRAME_MS,
|
|
flick.end_ms() + 4000,
|
|
PHONE_FRAME_MS,
|
|
);
|
|
assert!(
|
|
!(screen.list)(&mut h.rsc).is_scrolling(),
|
|
"the fling must have stopped, or this is the same case as the test above"
|
|
);
|
|
|
|
let (key, _) = tracked_row(&mut h, &screen);
|
|
drag_from(
|
|
&mut h,
|
|
&screen,
|
|
key,
|
|
1200.0,
|
|
settled + PHONE_FRAME_MS,
|
|
false,
|
|
);
|
|
}
|