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:
1 parent
e9a6562dc6
commit
6d5a231f5c
100 files changed
+924
-3295
No files matched your search
@@ -0,0 +1,283 @@
|
||||
//! Layer 1 of docs/RUST.md's "Three test layers": the real transcript
|
||||
//! screen, over the real bench fixture, at the phone's size and density,
|
||||
//! driven by `iris::harness` with no window, no compositor and no GPU.
|
||||
//!
|
||||
//! Every gesture here is a file under `touch/` -- see
|
||||
//! `flick-120hz.touch` for why the *shape* of the delivery is the whole
|
||||
//! point, and why the emulator cannot produce it (a `ui-trace` swipe is
|
||||
//! many evenly-spaced events; a finger at 120Hz is five samples in
|
||||
//! 20ms).
|
||||
|
||||
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
||||
use iris::harness::{Harness, TouchScript};
|
||||
use iris::prelude::*;
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
fn script(name: &str, text: &str) -> TouchScript {
|
||||
TouchScript::parse(text).unwrap_or_else(|e| panic!("{name}: {e}"))
|
||||
}
|
||||
|
||||
fn offset(h: &mut Harness, screen: &ai_app::ui::TranscriptScreen) -> String {
|
||||
(screen.list)(&mut h.rsc).anchor_position_display()
|
||||
}
|
||||
|
||||
/// (a) and (b) together, because the second is only meaningful if the
|
||||
/// first happened: the recorded flick must release with a real velocity
|
||||
/// (`GestureOutcome::Released(Some(v))`, which is the only thing that
|
||||
/// puts a value in `Scroll::fling_velocity`), and the list must then
|
||||
/// actually travel and stop on the spline's own schedule.
|
||||
#[test]
|
||||
fn a_recorded_flick_releases_with_a_velocity_and_flings_the_list() {
|
||||
let (mut h, screen) = opened();
|
||||
let before = offset(&mut h, &screen);
|
||||
|
||||
let flick = script("flick-120hz", include_str!("../touch/flick-120hz.touch"));
|
||||
h.replay(&flick);
|
||||
|
||||
let velocity = (screen.list)(&mut h.rsc)
|
||||
.fling_velocity()
|
||||
.expect("the flick must release as a pan with a velocity, not a tap");
|
||||
// Compose's own answer for this recording's five samples, printed by
|
||||
// `iris/benches/velocity_reference.py` -- not a number read off this
|
||||
// code. **Positive** because the flick runs *down* the screen and a
|
||||
// delta now carries the finger's own direction the whole way, from the
|
||||
// gesture through `Selection::drag` (which passes it straight to
|
||||
// `Scroll::fling`) to the anchor. It read -15250 while the transcript
|
||||
// negated the velocity on its way into a `LazySpan` whose anchor
|
||||
// offset ran the other way; the magnitude is the number that came from
|
||||
// `velocity_reference.py` and it has not changed.
|
||||
// The 2026-09-07 before/after: the old average estimator read
|
||||
// 12250px/s here, which is the fling Iris reported as too slow.
|
||||
assert!(
|
||||
(velocity - 15_250.0).abs() < 20.0,
|
||||
"expected ~15250px/s from velocity_reference.py, got {velocity}"
|
||||
);
|
||||
|
||||
// `iris/benches/fling_spline_reference.py`'s own line for this exact
|
||||
// case -- `density=2.55 v=15250.0: distance=11057.424px
|
||||
// duration=2.0716s`. **Not** `FlingCalculator::new(PHONE_SCALE)`,
|
||||
// which is the calculator under test: bounding a fling with the thing
|
||||
// being measured is the "compared the code with itself" shape 73f956f
|
||||
// found in the spline's own tests, and it left this one able to fail
|
||||
// in the "ran too long" direction only -- never in the "stopped dead"
|
||||
// direction, which is what Iris actually reported
|
||||
// (docs/REVIEW-2026-09-07.md's T1).
|
||||
const REFERENCE_MS: u64 = 2071;
|
||||
const REFERENCE_PX: f32 = 11057.0;
|
||||
let end = flick.end_ms() + REFERENCE_MS * 2;
|
||||
let mut settled_at = None;
|
||||
let mut t = flick.end_ms();
|
||||
// Travel in pixels, measured from a row's own on-screen extent, since
|
||||
// `LazySpan` has no travel accessor and this needs none: follow whatever
|
||||
// row is under the viewport's middle until it leaves, then pick
|
||||
// another. Deliberately an *under*-count -- the frame a row leaves on
|
||||
// contributes nothing -- which is why it is only ever a lower bound.
|
||||
let middle = phone_size().y / 2.0;
|
||||
let mut travelled = 0.0f32;
|
||||
let mut tracked: Option<(RowKey, f32)> = None;
|
||||
while t <= end {
|
||||
h.frame(t);
|
||||
let list = (screen.list)(&mut h.rsc);
|
||||
tracked =
|
||||
match tracked.and_then(|(key, was)| list.extent(key).map(|(now, _)| (key, was, now))) {
|
||||
Some((key, was, now)) => {
|
||||
travelled += (now - was).abs();
|
||||
Some((key, now))
|
||||
}
|
||||
None => list
|
||||
.key_at(middle)
|
||||
.and_then(|key| list.extent(key).map(|(top, _)| (key, top))),
|
||||
};
|
||||
if settled_at.is_none() && !(screen.list)(&mut h.rsc).is_scrolling() {
|
||||
settled_at = Some(t);
|
||||
}
|
||||
t += PHONE_FRAME_MS;
|
||||
}
|
||||
|
||||
let after = offset(&mut h, &screen);
|
||||
assert_ne!(
|
||||
before, after,
|
||||
"the fling ticks must have moved the list off where the flick left it"
|
||||
);
|
||||
let settled_at = settled_at.expect("the fling must stop on its own, not run forever");
|
||||
let ran_for = settled_at - flick.end_ms();
|
||||
// Both directions. The lower bound is the one that fails when a fling
|
||||
// settles on its first tick; the upper is the one that was here.
|
||||
assert!(
|
||||
ran_for >= REFERENCE_MS - PHONE_FRAME_MS * 2,
|
||||
"the fling stopped after {ran_for}ms against the spline reference's {REFERENCE_MS}ms"
|
||||
);
|
||||
assert!(
|
||||
ran_for <= REFERENCE_MS + PHONE_FRAME_MS * 2,
|
||||
"the fling ran {ran_for}ms against the spline reference's {REFERENCE_MS}ms"
|
||||
);
|
||||
// 80% of the reference, against 10527px measured today -- the 5%
|
||||
// shortfall is the frames a tracked row leaves the screen on. A fling
|
||||
// that moves one row's worth fails this; scaling `tick_fling`'s delta
|
||||
// by 0.01 reports 111px, which is how it was confirmed to fail in the
|
||||
// direction the bug goes.
|
||||
assert!(
|
||||
travelled >= REFERENCE_PX * 0.8,
|
||||
"the fling travelled {travelled:.0}px against the spline reference's {REFERENCE_PX:.0}px"
|
||||
);
|
||||
}
|
||||
|
||||
/// The half the flick fix had no reason to touch: a tap must decide
|
||||
/// `Tapped`, which means no velocity anywhere and nothing moved.
|
||||
#[test]
|
||||
fn a_tap_on_a_row_moves_nothing() {
|
||||
let (mut h, screen) = opened();
|
||||
let before = offset(&mut h, &screen);
|
||||
|
||||
h.replay(&script("tap", include_str!("../touch/tap.touch")));
|
||||
|
||||
assert_eq!(
|
||||
(screen.list)(&mut h.rsc).fling_velocity(),
|
||||
None,
|
||||
"a tap must not fling"
|
||||
);
|
||||
// Frames it would have moved in, had anything been moving.
|
||||
h.frames_until(100, 400, PHONE_FRAME_MS);
|
||||
assert_eq!(before, offset(&mut h, &screen), "a tap must scroll nothing");
|
||||
assert_eq!(
|
||||
h.state.opened_urls,
|
||||
Vec::<String>::new(),
|
||||
"no link was under this tap"
|
||||
);
|
||||
}
|
||||
|
||||
/// A press held past `LONG_PRESS` and then dragged selects text rather
|
||||
/// than panning -- the other branch of the same arbiter the flick goes
|
||||
/// through.
|
||||
#[test]
|
||||
fn a_long_press_and_drag_selects_text() {
|
||||
let (mut h, screen) = opened();
|
||||
let before = offset(&mut h, &screen);
|
||||
|
||||
h.replay(&script(
|
||||
"long-press",
|
||||
include_str!("../touch/long-press.touch"),
|
||||
));
|
||||
|
||||
let selected = screen
|
||||
.selected_text(&mut h.rsc)
|
||||
.expect("a long-press then drag must leave text selected");
|
||||
assert!(
|
||||
!selected.trim().is_empty(),
|
||||
"the selection covered no characters: {selected:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
before,
|
||||
offset(&mut h, &screen),
|
||||
"a selection must not also pan the list"
|
||||
);
|
||||
}
|
||||
|
||||
/// The composer sits on whatever the platform says the bottom of usable
|
||||
/// space is -- the keyboard's inset while it is open
|
||||
/// (`Composer::set_bottom_inset`, the path Android's
|
||||
/// `on_insets_changed` feeds). Checked here rather than on the emulator
|
||||
/// because it is a layout fact, and the emulator costs minutes.
|
||||
#[test]
|
||||
fn the_composer_sits_above_a_simulated_ime_inset() {
|
||||
let (mut h, screen) = opened();
|
||||
let height = h.size().y;
|
||||
let field_bottom = |h: &mut Harness| {
|
||||
h.render
|
||||
.window_region(&screen.composer.field, &h.rsc)
|
||||
.expect("the composer field is on screen")
|
||||
.bot_right
|
||||
.y
|
||||
};
|
||||
|
||||
let closed = field_bottom(&mut h);
|
||||
assert!(
|
||||
closed <= height,
|
||||
"the composer is off the bottom of the window even with no keyboard: {closed} > {height}"
|
||||
);
|
||||
|
||||
// A Gboard-sized keyboard on this surface. Any real number would do;
|
||||
// what matters is that the bar clears it.
|
||||
let ime = 1000.0;
|
||||
screen.composer.set_bottom_inset(&mut h.rsc, ime);
|
||||
h.frame(PHONE_FRAME_MS * 2);
|
||||
|
||||
let open = field_bottom(&mut h);
|
||||
assert!(
|
||||
open <= height - ime,
|
||||
"the keyboard covers the composer: its bottom is at {open}, the IME starts at {}",
|
||||
height - ime
|
||||
);
|
||||
assert!(
|
||||
(closed - open - ime).abs() < 1.0,
|
||||
"the composer moved {} for a {ime}px inset",
|
||||
closed - open
|
||||
);
|
||||
}
|
||||
|
||||
/// A newline typed into the composer must leave the caret inside the
|
||||
/// bar's own padding, not flush against its bottom edge.
|
||||
///
|
||||
/// Iris's phone, 2026-09-08: "when typing with the keyboard up and
|
||||
/// entering enough newlines ... the text drops down close to the bottom
|
||||
/// and seems to ignore the padding. If I close (and optionally reopen)
|
||||
/// the keyboard it seems to fix itself." The cause was `Scroll::draw`
|
||||
/// placing its child against *last* frame's content length and stopping
|
||||
/// there: each newline drew the field in a box one line short of its
|
||||
/// text, and since the text is centred in its box it hung half a line
|
||||
/// past each end, putting the caret's line box a full padding below the
|
||||
/// bar's inside edge. Nothing dirtied that subtree again, so the stale
|
||||
/// placement was simply the last one drawn -- until the keyboard closed
|
||||
/// and the inset rewrite forced a redraw, which is the "fixes itself"
|
||||
/// half of the report. No settling frame here on purpose: the placement
|
||||
/// is corrected within the frame that typed, so the first frame drawn
|
||||
/// after a keystroke is already right.
|
||||
#[test]
|
||||
fn a_newline_leaves_the_caret_inside_the_composers_padding() {
|
||||
let (mut h, screen) = opened();
|
||||
let height = h.size().y;
|
||||
let ime = 1000.0;
|
||||
screen.composer.set_bottom_inset(&mut h.rsc, ime);
|
||||
h.frame(PHONE_FRAME_MS * 2);
|
||||
|
||||
h.state.set_focus(Some(screen.composer.field));
|
||||
screen.composer.field.edit(&mut h.rsc).set_cursor_byte(0);
|
||||
// Past `composer::MAX_LINES`, so the bar is capped and scrolling
|
||||
// rather than still growing -- the state the report is about.
|
||||
for _ in 0..12 {
|
||||
screen.composer.field.edit(&mut h.rsc).insert("a\n");
|
||||
h.frame(PHONE_FRAME_MS);
|
||||
}
|
||||
// The caret is the last primitive `TextEdit::draw` emits.
|
||||
let caret = {
|
||||
let slot = *h
|
||||
.render
|
||||
.debug(h.rsc.widgets(), "Message")
|
||||
.flat_map(|a| a.primitives.iter().map(|p| p.slot))
|
||||
.collect::<Vec<_>>()
|
||||
.last()
|
||||
.expect("the focused field draws a caret");
|
||||
h.render.primitive_corners(slot, &h.rsc)
|
||||
};
|
||||
// The bar sits directly on the IME, so its inside edge is one
|
||||
// `FIELD_PAD_DP` above `height - ime`. Stated in pixels rather than
|
||||
// read back from the composer, which is the thing under test.
|
||||
let bar_bottom = height - ime;
|
||||
let padding = 12.0 * PHONE_SCALE;
|
||||
assert!(
|
||||
caret.bot_right.y < bar_bottom - padding / 2.0,
|
||||
"the caret is in the bar's bottom padding: it ends at {}, the bar's edge is {bar_bottom} \
|
||||
and its padding is {padding}px",
|
||||
caret.bot_right.y,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user