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

+428
View File
@@ -0,0 +1,428 @@
//! Layer 1 of docs/RUST.md's "Three test layers": a whole screen driven
//! in-process with **no window, no compositor and no GPU**, on an
//! explicit clock and a replayed touch stream.
//!
//! `layout_tests.rs` and `sense_tests.rs` already build trees over
//! `UiRenderState` with a hand-rolled `Rsc` each; this is the same idea
//! carried far enough to open a real app screen (`transcript-ui`'s, over
//! the bench fixture -- see the `transcript-fixture` crate) at the
//! phone's size and density, feed it a recorded flick, and assert on
//! where the list ended up. What it answers that the emulator cannot:
//! Android batches a 120Hz flick into one or two `MotionEvent`s
//! (`CursorState::time`), and a `ui-trace` swipe is many evenly-spaced
//! ones -- so the gesture shape a finger actually makes is only
//! reproducible from a *file* of timestamped samples.
//!
//! It is a third backend in the sense `default/` and `android/` are, and
//! deliberately the smallest one: the platform half of each of those
//! (a surface, an IME, a URL opener) becomes a recorded fact here --
//! [`HarnessState::keyboard_shown`], [`HarnessState::opened_urls`] --
//! so a test can assert the platform *was asked*, which is the only
//! thing either backend does with those calls anyway.
//!
//! ```ignore
//! let mut h = Harness::new(phone_size(), PHONE_SCALE);
//! let screen = transcript_ui::build(&mut h.rsc, &mut h.state, rows);
//! h.frame(0);
//! h.replay(&TouchScript::parse(include_str!("flick.touch"))?);
//! h.frames_until(20, 2_000, 8);
//! ```
use crate::prelude::*;
use std::marker::PhantomData;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
/// One replayed pointer sample: what Android's `MotionEvent` carries, cut
/// down to the part iris reads (`IrisViewPeer::on_touch_event`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TouchAction {
Down,
Move,
Up,
/// The gesture taken away by the system (a parent view claiming it, a
/// call arriving, the swipe up from the bottom edge to leave the
/// app). It ends the press, because a release that never arrives
/// leaves pointer capture held forever -- but it is not a release,
/// and nothing follows from it: no tap, no selection, no fling. See
/// `CursorState::cancelled`, which is what it sets.
Cancel,
}
impl TouchAction {
fn parse(word: &str) -> Option<Self> {
match word {
"down" => Some(Self::Down),
"move" => Some(Self::Move),
"up" => Some(Self::Up),
"cancel" => Some(Self::Cancel),
_ => None,
}
}
/// The inverse of [`Self::parse`] -- what [`Harness::touch`] hands
/// [`crate::sense::log_input_event`], so an `iris::input` line and a
/// `.touch` file agree on one spelling of each action.
pub fn word(self) -> &'static str {
match self {
Self::Down => "down",
Self::Move => "move",
Self::Up => "up",
Self::Cancel => "cancel",
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct TouchSample {
/// Milliseconds since the start of the recording -- the sample's own
/// time, which becomes `CursorState::time`. See that field's doc for
/// why a replay may not date its samples by when the loop got to
/// them.
pub t_ms: u64,
pub action: TouchAction,
pub pos: Vec2,
}
/// A recorded gesture: one `t_ms action x y` line per sample, `#` and
/// blank lines ignored. Deliberately a plain text file rather than a
/// serialisation format -- it is written by hand as often as it is
/// recorded, and a diff of one has to be readable.
pub struct TouchScript {
pub samples: Vec<TouchSample>,
}
impl TouchScript {
/// Parses a script, naming the line and what was wrong with it: these
/// are hand-written files, so a typo is the ordinary case and
/// "expected 4 fields" without a line number is not enough to fix it.
pub fn parse(text: &str) -> Result<Self, String> {
let mut samples: Vec<TouchSample> = Vec::new();
for (i, line) in text.lines().enumerate() {
let line = line.split('#').next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
let at = |what: &str| format!("touch script line {}: {what}: {line:?}", i + 1);
let mut words = line.split_whitespace();
let (Some(t), Some(action), Some(x), Some(y), None) = (
words.next(),
words.next(),
words.next(),
words.next(),
words.next(),
) else {
return Err(at("expected `t_ms action x y`"));
};
let t_ms: u64 = t.parse().map_err(|_| at("t_ms is not a whole number"))?;
let action = TouchAction::parse(action)
.ok_or_else(|| at("action is not down/move/up/cancel"))?;
let x: f32 = x.parse().map_err(|_| at("x is not a number"))?;
let y: f32 = y.parse().map_err(|_| at("y is not a number"))?;
if let Some(last) = samples.last()
&& t_ms < last.t_ms
{
return Err(at("samples must be in time order"));
}
samples.push(TouchSample {
t_ms,
action,
pos: Vec2::new(x, y),
});
}
Ok(Self { samples })
}
/// The last sample's time, i.e. how long the recording runs.
pub fn end_ms(&self) -> u64 {
self.samples.last().map(|s| s.t_ms).unwrap_or(0)
}
}
/// Counts the frames something asked for without drawing any -- the
/// harness's `RequestRedraw`. A `LazySpan` coasting through a fling asks for
/// the next frame through this (`UiData::animate` and `Widget::tick`), so a test can
/// tell "nothing moved" from "nothing was even asked to move".
#[derive(Default)]
pub struct RedrawCounter(AtomicUsize);
impl RedrawCounter {
pub fn count(&self) -> usize {
self.0.load(Ordering::Relaxed)
}
}
impl RequestRedraw for RedrawCounter {
fn request_redraw(&self) {
self.0.fetch_add(1, Ordering::Relaxed);
}
}
/// The harness's app state: what each real backend keeps for the platform
/// half, recorded instead of performed.
pub struct HarnessState {
pub root: Option<StrongWidget>,
pub focus: Option<WeakWidget<TextEdit>>,
last_click: Instant,
/// How many times a tap asked for the keyboard (`FocusHost::
/// focus_gained` with a region -- `showSoftInput` on Android,
/// `set_ime_cursor_area` on winit). The platform's own answer is not
/// available here, so this says what was *asked*, and a test must not
/// read it as "the IME is up".
pub keyboard_shown: usize,
/// Every URL a tapped link asked the platform to open, in order.
pub opened_urls: Vec<String>,
}
impl HarnessState {
fn new() -> Self {
Self {
root: None,
focus: None,
last_click: Instant::now(),
keyboard_shown: 0,
opened_urls: Vec::new(),
}
}
}
impl HasRoot for HarnessState {
fn set_root(&mut self, root: StrongWidget) {
self.root = Some(root);
}
}
impl FocusHost for HarnessState {
fn recent_click(&mut self) -> bool {
crate::attr::recent_click(&mut self.last_click)
}
fn set_focus(&mut self, id: Option<WeakWidget<TextEdit>>) {
self.focus = id;
}
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool {
self.focus == Some(id)
}
fn focus_gained(&mut self, region: Option<PixelRegion>) {
if region.is_some() {
self.keyboard_shown += 1;
}
}
}
impl OpenUrl for HarnessState {
fn open_url(&mut self, url: &str) {
self.opened_urls.push(url.to_string());
}
}
/// The harness's `Rsc` -- identical in substance to `DefaultRsc`/
/// `AndroidRsc` minus the windowing, for the same reason those two are
/// separate types (`AndroidRsc`'s own doc).
pub struct HarnessRsc {
pub ui: UiData,
pub events: EventManager<Self>,
pub tasks: Tasks<Self>,
pub state: WidgetState,
_state: PhantomData<HarnessState>,
}
impl UiRsc for HarnessRsc {
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);
self.state.remove(id);
}
}
impl HasState for HarnessRsc {
type State = HarnessState;
}
impl HasEvents for HarnessRsc {
fn events(&self) -> &EventManager<Self> {
&self.events
}
fn events_mut(&mut self) -> &mut EventManager<Self> {
&mut self.events
}
}
impl HasTasks for HarnessRsc {
fn tasks_mut(&mut self) -> &mut Tasks<Self> {
&mut self.tasks
}
}
impl HasWidgetState for HarnessRsc {
fn widget_state(&self) -> &WidgetState {
&self.state
}
fn widget_state_mut(&mut self) -> &mut WidgetState {
&mut self.state
}
}
impl<I: RscIdx<HarnessRsc>> std::ops::Index<I> for HarnessRsc {
type Output = I::Output;
fn index(&self, index: I) -> &Self::Output {
index.get(self)
}
}
impl<I: RscIdx<HarnessRsc>> std::ops::IndexMut<I> for HarnessRsc {
fn index_mut(&mut self, index: I) -> &mut Self::Output {
index.get_mut(self)
}
}
/// A screen running with no window: the widget tree, the frame loop and
/// the pointer, all advanced by the caller. See the module doc.
pub struct Harness {
pub rsc: HarnessRsc,
pub render: UiRenderState,
pub state: HarnessState,
task_recv: TaskMsgReceiver<HarnessRsc>,
redraws: Arc<RedrawCounter>,
cursor: CursorState,
/// Time zero. Every `t_ms` in this harness is an offset from here, so
/// nothing reads the wall clock -- see [`Self::at`].
base: Instant,
size: Vec2,
}
impl Harness {
/// `size` is in physical pixels and `density` is physical pixels per
/// dp, the pair Android reads from the surface and
/// `DisplayMetrics.density` (`AndroidUiState::content_scale`). The
/// phone's own numbers are `transcript_fixture::PHONE_SIZE`/
/// `PHONE_SCALE`.
pub fn new(size: Vec2, density: f32) -> Self {
let redraws = Arc::new(RedrawCounter::default());
let (tasks, task_recv) = Tasks::init(redraws.clone());
let mut rsc = HarnessRsc {
ui: UiData::default(),
events: EventManager::default(),
tasks,
state: WidgetState::default(),
_state: PhantomData,
};
rsc.ui.text.density = density;
let mut render = UiRenderState::new();
render.set_density(density);
render.resize(size);
Self {
rsc,
render,
state: HarnessState::new(),
task_recv,
redraws,
cursor: CursorState::default(),
base: Instant::now(),
size,
}
}
/// The `Instant` this harness means by `t_ms`. Public because a
/// caller driving `ScrollController::tick` or `DragGesture` by hand needs
/// to date those calls on the same clock the touch samples use.
pub fn at(&self, t_ms: u64) -> Instant {
self.base + Duration::from_millis(t_ms)
}
pub fn size(&self) -> Vec2 {
self.size
}
/// How many frames were asked for so far -- see [`RedrawCounter`].
pub fn redraws(&self) -> usize {
self.redraws.count()
}
/// One frame at `t_ms`: drain finished tasks, advance anything
/// animating, lay out and "draw". The same three steps
/// `DefaultApp::window_event`'s `RedrawRequested` arm and
/// `IrisViewPeer::render` take, minus handing primitives to a GPU.
pub fn frame(&mut self, t_ms: u64) {
while let Ok(update) = self.task_recv.try_recv() {
update(&mut self.state, &mut self.rsc);
}
let now = self.at(t_ms);
let animating = self.rsc.ui.tick_animations(now);
self.render.update(&self.state.root, &mut self.rsc);
// No GPU here, so there is no draw phase to time -- `draw` is
// always zero. `layout`/`redraw`/`primitives` are still real,
// because `render.update` just ran; see
// `iris::diagnostics::log_frame`'s own doc for why this reads
// those back rather than timing anything itself.
crate::diagnostics::log_frame(&self.render, now, Duration::ZERO, animating);
}
/// Frames every `step_ms` up to and including `end_ms` -- what a
/// fling needs, since it moves only while something ticks it
/// (`ScrollController::fling`'s doc). Returns the time of the last frame run.
pub fn frames_until(&mut self, from_ms: u64, end_ms: u64, step_ms: u64) -> u64 {
debug_assert!(step_ms > 0, "a frame loop with no step never ends");
let mut t = from_ms;
while t <= end_ms {
self.frame(t);
t += step_ms;
}
t - step_ms
}
/// One pointer sample through the sensors, then the frame it belongs
/// to -- `IrisViewPeer::on_touch_event` and `after_input`, in one
/// call. Each sample is its own input frame, dated by the sample
/// rather than by when this ran.
pub fn touch(&mut self, action: TouchAction, pos: Vec2, t_ms: u64) {
self.cursor.time = self.at(t_ms);
self.cursor.pos = pos;
match action {
TouchAction::Down => {
self.cursor.exists = true;
self.cursor.buttons.left.update(true);
}
TouchAction::Move => {}
TouchAction::Up => self.cursor.buttons.left.update(false),
// The platform taking the gesture away, not the finger
// lifting -- see `CursorState::cancelled`.
TouchAction::Cancel => {
self.cursor.buttons.left.update(false);
self.cursor.cancelled = true;
}
}
// Layer 1's half of `iris::input` (`sense::log_input_event`'s own
// doc): no batching happens here, so `historical` is always empty
// and `t_ms` is the script's own column, which is what makes this
// round-trip through `report_to_touch.py` back into an identical
// `TouchScript`.
crate::sense::log_input_event(action.word(), pos.x, pos.y, t_ms, &[]);
let cursor = self.cursor.clone();
self.render
.run_sensors(&mut self.rsc, &mut self.state, cursor, self.size);
self.frame(t_ms);
self.cursor.end_frame();
}
/// Replays a whole recorded gesture. Nothing is inserted between the
/// samples: a file with three lines produces three input frames, so
/// the batched shape a real flick arrives in is preserved exactly as
/// recorded rather than smoothed into evenly-spaced motion.
pub fn replay(&mut self, script: &TouchScript) {
for sample in &script.samples {
self.touch(sample.action, sample.pos, sample.t_ms);
}
}
}