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

+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""AOSP's fling spline, transcribed independently of the Rust port.
This exists so the numbers in `sense.rs`'s `the_spline_matches_aosps_own_table`
and `a_flick_decelerates_the_way_aosp_says_it_does` are not the Rust code
grading its own homework. Every test iris's fling had before 2026-09-07
compared the curve with itself -- monotonic, signed, integrates to the closed
form -- and all of them passed while `distance_fraction(t)` was returning
exactly `t` (see `android_fling_spline`'s doc comment). Numbers checked into a
test have to come from somewhere else, and this is the somewhere else.
Transcribed by hand from, and only from:
* frameworks/base `core/java/android/widget/OverScroller.java`,
`SplineOverScroller`'s static initialiser, `getSplineDeceleration`,
`getSplineFlingDistance`, `getSplineFlingDuration` and `update`.
* androidx.compose.animation:animation:1.12.0 `SplineBasedDecay.kt`
(`computeSplineInfo`, `AndroidFlingSpline.flingPosition`) and
`FlingCalculator.kt` (`computeDeceleration`, `flingDistance`,
`flingDuration`, `FlingInfo.position`/`velocity`). The two agree line for
line, which is why iris ports one curve rather than two.
Run it with no arguments; it prints the table entries and the (velocity,
density, t) points the Rust tests assert on.
"""
NB_SAMPLES = 100
INFLEXION = 0.35
START_TENSION = 0.5
END_TENSION = 1.0
P1 = START_TENSION * INFLEXION
P2 = 1.0 - END_TENSION * (1.0 - INFLEXION)
# ViewConfiguration.getScrollFriction(), and SplineOverScroller's own
# "look and feel tuning" constant -- a different number in a different place
# of the same formula, which is the pair iris got the wrong way round once.
SCROLL_FRICTION = 0.015
TUNING = 0.84
GRAVITY_EARTH = 9.80665
INCHES_PER_METER = 39.37
import math
DECELERATION_RATE = math.log(0.78) / math.log(0.9)
def spline_positions():
"""SPLINE_POSITION: distance fraction at each of 101 even time steps."""
position = [0.0] * (NB_SAMPLES + 1)
x_min = 0.0
for i in range(NB_SAMPLES):
alpha = i / NB_SAMPLES
x_max = 1.0
while True:
x = x_min + (x_max - x_min) / 2.0
coef = 3.0 * x * (1.0 - x)
# Solved on the P1/P2 curve...
tx = coef * ((1.0 - x) * P1 + x * P2) + x * x * x
if abs(tx - alpha) < 1e-5:
break
if tx > alpha:
x_max = x
else:
x_min = x
# ...and sampled on the tension curve.
position[i] = coef * ((1.0 - x) * START_TENSION + x * END_TENSION) + x * x * x
position[NB_SAMPLES] = 1.0
return position
POSITION = spline_positions()
def fling_sample(t):
"""(distance fraction, velocity fraction) at time fraction `t`."""
t = min(max(t, 0.0), 1.0)
index = int(t * NB_SAMPLES)
if index >= NB_SAMPLES:
return 1.0, 0.0
t_inf = index / NB_SAMPLES
t_sup = (index + 1) / NB_SAMPLES
velocity_coef = (POSITION[index + 1] - POSITION[index]) / (t_sup - t_inf)
return POSITION[index] + (t - t_inf) * velocity_coef, velocity_coef
def physical_coefficient(density):
return GRAVITY_EARTH * INCHES_PER_METER * density * 160.0 * TUNING
def deceleration(velocity, density):
return math.log(
INFLEXION * abs(velocity) / (SCROLL_FRICTION * physical_coefficient(density))
)
def fling_distance(velocity, density):
l = deceleration(velocity, density)
return (
SCROLL_FRICTION
* physical_coefficient(density)
* math.exp(DECELERATION_RATE / (DECELERATION_RATE - 1.0) * l)
)
def fling_duration_s(velocity, density):
l = deceleration(velocity, density)
return math.exp(l / (DECELERATION_RATE - 1.0))
def position_at(velocity, density, t_seconds):
d = fling_duration_s(velocity, density)
return fling_distance(velocity, density) * fling_sample(t_seconds / d)[0]
def velocity_at(velocity, density, t_seconds):
d = fling_duration_s(velocity, density)
return fling_sample(t_seconds / d)[1] * fling_distance(velocity, density) / d
if __name__ == "__main__":
print("SPLINE_POSITION at a few indices (index: value)")
for i in (0, 1, 10, 25, 50, 75, 99, 100):
print(f" {i:3}: {POSITION[i]:.6f}")
print()
print("distance/velocity fraction at time fractions")
for t in (0.0, 0.1, 0.25, 0.5, 0.75, 0.9, 1.0):
d, v = fling_sample(t)
print(f" t={t:<5} distance={d:.6f} velocity={v:.6f}")
print()
# 2.55 is Iris's Pixel 9 Pro XL (docs/bench/iris-phone-v2-2026-09-06.md);
# 2.75 is this checkout's emulator.
for density in (2.55, 2.75):
# 15250 is `transcript-fixture/touch/flick-120hz.touch`'s own
# release velocity (velocity_reference.py), so `phone_screen.rs`
# can bound the fling it produces from *here* rather than from the
# `FlingCalculator` under test (docs/REVIEW-2026-09-07.md's T1).
for velocity in (5000.0, 11064.0, 15250.0):
dur = fling_duration_s(velocity, density)
print(
f"density={density} v={velocity}: "
f"distance={fling_distance(velocity, density):.3f}px "
f"duration={dur:.4f}s"
)
# Deliberately not round fractions. The velocity coefficient is
# piecewise *constant* across each of the 100 samples, so it
# steps at t = k/100 and a test asserting on 0.75 is asserting
# on which side of a discontinuity the last float landed --
# which is genuinely different between Python and Rust and says
# nothing about the curve.
for frac in (0.125, 0.335, 0.505, 0.755):
t = frac * dur
print(
f" t={frac:>4} of duration ({t:.4f}s): "
f"pos={position_at(velocity, density, t):.3f}px "
f"vel={velocity_at(velocity, density, t):.3f}px/s"
)
+493
View File
@@ -0,0 +1,493 @@
//! On-demand benchmarks for iris's message-list scenario -- IRIS_TODO.md's
//! "Benchmarks" item, and RUST.md's I3. Never run by `cargo test`; run
//! explicitly with `cargo bench --bench message_list --release` or
//! `./run-bench.sh`.
//!
//! **Why a plain `Instant`-timed binary, not criterion.** Every scenario
//! here is really "how many `Widget::draw` calls and primitive rewrites did
//! this frame cost," which `UiRenderState::take_counters` already answers
//! exactly (see `iris/src/layout_tests.rs`, which this file's harness
//! mirrors). A short loop that times itself and prints the counters
//! alongside the wall time says everything criterion's warm-up/sampling/
//! outlier-removal machinery would add on top, for scenarios that are
//! fundamentally about a *count*, not a noisy microbenchmark distribution
//! -- and it avoids a new dependency this crate does not otherwise need.
//! Per the code rules, the plain option is also the one shorter to explain.
//!
//! **The list under test is `iris::widget::LazySpan` (RUST.md's I3), not a
//! `ScrollArea` over a `Span` of pre-built rows.** Earlier versions of this
//! file built their own giant `Span` and wrapped it in `ScrollArea`, which
//! meant (a)/(b)/(c) below were measuring "move one big child," never the
//! virtualised widget the app's transcript screen actually needs. `LazySpan`
//! still needs every row's *widget* built up front by the caller (its
//! module doc explains why: it only ever sees `&dyn Widget` through
//! `Painter`, so it cannot construct a row lazily on its own) -- what
//! virtualisation buys is that only the rows currently on screen are ever
//! *drawn*, which is what the draw/rewrite/move counters below are
//! measuring, not construction time.
//!
//! Scenarios (LAYOUT.md's O(1) move chain, lazy_span.rs's module doc, and
//! IRIS_TODO.md's "Benchmarks" wording):
//!
//! - (a) first-frame cost of a message list of N wrapped-text rows, some
//! with an image, for N = 100 / 1,000 / 10,000. With a virtualised list
//! this is expected to stop scaling with N once N exceeds a screenful --
//! the draw/rewrite counters below are the number that used to grow 10x
//! per 10x N and should not any more.
//! - (b) per-frame cost of scrolling that list -- must be O(1) moves, not
//! re-layout.
//! - (c) the input-box case: growing a fixed-height field at the bottom of
//! the screen must move the message list above it, not re-lay its rows.
//! Reports frame time *and* the draw/rewrite/move counters LAYOUT.md
//! section 8 defines.
//! - (d) insert-above-anchor: paging older history onto the front of an
//! already-scrolled list. `LazySpan::push_front` is an O(1) index update
//! (lazy_span.rs's module doc); this measures that none of the rows already
//! on screen are touched by it.
//! - (e) expand-a-row-holding-its-edge: growing one row's height with a
//! tap recorded near one of its edges (lazy_span.rs's `note_tap`) must move
//! only the rows on the far side of it, never redraw the ones already
//! correctly placed.
//!
//! - (g) redraw-one-big-text: a single text widget of N glyphs redrawn in
//! place, which is what a tool card rebuilt on a tap costs. Every one of
//! its primitives is freed and rewritten, and so renumbered in the
//! layer's draw order -- the pass that used to be O(N^2) there
//! (`UiRenderState::apply_free`, fixed 2026-09-08). The number to watch
//! is per-glyph: it must stay flat as N grows, not grow with it.
//!
//! (f), many images with zero steady-state bind-group creation, needs a
//! real `wgpu` device and lives in `iris/examples/bench_images.rs` instead,
//! driven through `run-headless.sh` -- see that file's header.
//!
//! `UiRenderState`/`Widgets` touch no GPU or window (as `layout_tests.rs`
//! notes), so everything here runs as an ordinary `--release` binary with
//! no compositor. Numbers are recorded in RUST.md's I3 box, not here --
//! this file is the rig, not the result.
use iris::prelude::*;
use std::time::Instant;
/// The minimal `UiRsc` a benchmark needs -- identical in shape to
/// `layout_tests.rs`'s `TestRsc`.
struct BenchRsc {
ui: UiData,
}
impl UiRsc for BenchRsc {
fn ui(&self) -> &UiData {
&self.ui
}
fn ui_mut(&mut self) -> &mut UiData {
&mut self.ui
}
}
/// Long enough to force real wrapping at a phone-plausible column width, and
/// varied enough (no two rows byte-identical) that nothing can special-case
/// on repeated content.
const BODY: &str = "The quick brown fox jumps over the lazy dog. Iris lays \
out wrapped text by shaping once per width and caching the result, so a \
row that is offered the same width twice does not reshape. This sentence \
exists only to give a row enough text to wrap across several lines at a \
typical phone column width.";
/// One message row: a wrapped `Text`, and every `image_every`th row also an
/// `Image` beneath it -- a small in-memory RGBA square rather than a file,
/// so N=10,000 rows costs no disk I/O.
fn build_row(rsc: &mut BenchRsc, i: usize, image_every: usize) -> StrongWidget {
let mut text = Text::new(format!("Message {i}: {BODY}"));
text.wrap = true;
let text = rsc.ui.widgets.add_strong(text).any();
if image_every > 0 && i.is_multiple_of(image_every) {
let img = image::DynamicImage::new_rgba8(64, 64);
let image_widget = image::<BenchRsc>(img)(rsc);
let image_widget = rsc.ui.widgets.add_strong(image_widget).any();
let mut row = Span::empty(Dir::DOWN);
row.push(text);
row.push(image_widget);
rsc.ui.widgets.add_strong(row).any()
} else {
text
}
}
/// A virtualised `LazySpan` of `n` message rows, one in `image_every` of them
/// carrying an image (0 disables images entirely). Returns the list widget
/// (weak, so the caller can drive it) and the erased root to render.
fn build_message_list(
rsc: &mut BenchRsc,
n: usize,
image_every: usize,
) -> (WeakWidget<LazySpan>, StrongWidget) {
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
for i in 0..n {
let row = build_row(rsc, i, image_every);
list.push_back(LazyItem::new(i as u64, row));
}
let list = rsc.ui.widgets.add_strong(list);
// Driven through the span's own `ScrollController`, like every other
// scroll area in iris: what this measures has to be the path the app
// actually takes.
(list.weak(), list.any())
}
fn report(label: &str, elapsed: std::time::Duration, draws: u64, rewrites: u64, moves: u64) {
println!(
"{label}: {:.2}ms draws={draws} rewrites={rewrites} moves={moves}",
elapsed.as_secs_f64() * 1000.0
);
}
/// (a) First-frame cost of a message list of N rows.
fn bench_first_frame(n: usize) {
let mut rsc = BenchRsc {
ui: UiData::default(),
};
let (_list, root) = build_message_list(&mut rsc, n, 20);
let mut render = UiRenderState::new();
render.resize((1080.0, 2000.0));
let start = Instant::now();
render.update(&root, &mut rsc);
let elapsed = start.elapsed();
let (draws, rewrites, moves, _shapes) = render.take_counters();
report(
&format!("(a) first frame, N={n}"),
elapsed,
draws,
rewrites,
moves,
);
}
/// (b) Per-frame cost of scrolling an already-laid-out list of N rows.
/// Warms up (one no-op tick, matching `ScrollArea`'s own need for it before an
/// ordinary Rust `layout_tests.rs` scrolling test becomes a same-size move
/// rather than a resize), then times a run of individual scroll ticks.
fn bench_scroll(n: usize, ticks: usize) {
let mut rsc = BenchRsc {
ui: UiData::default(),
};
let (scroll, root) = build_message_list(&mut rsc, n, 20);
let mut render = UiRenderState::new();
render.resize((1080.0, 2000.0));
render.update(&root, &mut rsc);
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
render.update(&root, &mut rsc);
render.take_counters();
let mut total = std::time::Duration::ZERO;
let mut total_draws = 0u64;
let mut total_rewrites = 0u64;
let mut total_moves = 0u64;
for _ in 0..ticks {
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-8.0);
let start = Instant::now();
render.update(&root, &mut rsc);
total += start.elapsed();
let (draws, rewrites, moves, _shapes) = render.take_counters();
total_draws += draws;
total_rewrites += rewrites;
total_moves += moves;
}
report(
&format!("(b) scroll, N={n}, {ticks} ticks (totals; expect draws/moves independent of N)"),
total,
total_draws,
total_rewrites,
total_moves,
);
println!(
" per-tick average: {:.4}ms",
total.as_secs_f64() * 1000.0 / ticks as f64
);
}
/// (c) The input-box case: a fixed-height field at the bottom of the screen
/// growing by a line at a time, with a message list of N rows filling the
/// rest of the screen above it. Growing the input shrinks the *offered*
/// height of the list container (a single widget, from the outer `Span`'s
/// point of view) without changing the width it offers its content -- so
/// the rows underneath, which only care about width, must not redraw; the
/// list's own re-registration of where its content sits is the one O(1)
/// move this is checking for.
fn bench_input_grows(n: usize, lines: usize) {
let mut rsc = BenchRsc {
ui: UiData::default(),
};
let (scroll, list_root) = build_message_list(&mut rsc, n, 20);
let list_area = rsc.ui.widgets.add_strong(Sized {
inner: list_root,
x: None,
y: Some(rest(1.0)),
});
let line_height = 24.0;
let input_rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let input_area = rsc.ui.widgets.add_strong(Sized {
inner: input_rect.any(),
x: None,
y: Some(abs(line_height)),
});
let input_area_weak = input_area.weak();
let mut root_span = Span::empty(Dir::DOWN);
root_span.push(list_area.any());
root_span.push(input_area.any());
let root = rsc.ui.widgets.add_strong(root_span).any();
let mut render = UiRenderState::new();
render.resize((1080.0, 2000.0));
render.update(&root, &mut rsc);
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
render.update(&root, &mut rsc);
render.take_counters();
let mut total = std::time::Duration::ZERO;
let mut total_draws = 0u64;
let mut total_rewrites = 0u64;
let mut total_moves = 0u64;
for line in 1..=lines {
rsc.ui.widgets.get_mut(&input_area_weak).unwrap().y =
Some(abs(line_height * (line + 1) as f32));
let start = Instant::now();
render.update(&root, &mut rsc);
total += start.elapsed();
let (draws, rewrites, moves, _shapes) = render.take_counters();
total_draws += draws;
total_rewrites += rewrites;
total_moves += moves;
}
report(
&format!(
"(c) input grows by {lines} lines above N={n} rows (totals; \
draws/rewrites must not scale with N)"
),
total,
total_draws,
total_rewrites,
total_moves,
);
println!(
" per-line average: {:.4}ms",
total.as_secs_f64() * 1000.0 / lines as f64
);
}
/// (d) Insert-above-anchor: the list is scrolled to its very first loaded
/// row (`jump_to_start`, an O(1) re-anchor) rather than left at the default
/// bottom, so a row prepended above it is genuinely "inserted above the
/// anchor" rather than merely far off-screen at the far end. Each
/// `push_front` is O(1) (lazy_span.rs's module doc: the anchor's slot is an
/// index, bumped by one) and, since the prepended rows never enter the
/// viewport, none of them should cost a draw either.
fn bench_insert_above_anchor(n: usize, inserts: usize) {
let mut rsc = BenchRsc {
ui: UiData::default(),
};
let (list, root) = build_message_list(&mut rsc, n, 20);
let mut render = UiRenderState::new();
render.resize((1080.0, 2000.0));
render.update(&root, &mut rsc);
rsc.ui.widgets.get_mut(&list).unwrap().jump_to_start();
render.update(&root, &mut rsc);
render.take_counters();
let mut total = std::time::Duration::ZERO;
let mut total_draws = 0u64;
let mut total_rewrites = 0u64;
let mut total_moves = 0u64;
for i in 0..inserts {
// Older-history rows: distinct keys below every existing one, so a
// real caller's paging code (prepending an older page) is exactly
// what this loop does.
let row = build_row(&mut rsc, usize::MAX - i, 20);
rsc.ui
.widgets
.get_mut(&list)
.unwrap()
.push_front(LazyItem::new(i as u64, row));
let start = Instant::now();
render.update(&root, &mut rsc);
total += start.elapsed();
let (draws, rewrites, moves, _shapes) = render.take_counters();
total_draws += draws;
total_rewrites += rewrites;
total_moves += moves;
}
report(
&format!(
"(d) insert-above-anchor, N={n}, {inserts} pushes (totals; \
must not scale with N)"
),
total,
total_draws,
total_rewrites,
total_moves,
);
println!(
" per-push average: {:.4}ms",
total.as_secs_f64() * 1000.0 / inserts as f64
);
}
/// (e) Expand-a-row-holding-its-edge: one row (fixed-height, so its size is
/// directly controllable) is grown a little at a time, each time preceded
/// by `note_tap` aimed at its own top edge -- the exact mechanism lazy_span.rs's
/// module doc describes and its unit tests check for correctness. This
/// measures its *cost*: only the rows on the far side of the grown one
/// (below it, since the top edge is held) should ever move, and nothing
/// should be redrawn purely because the list overall got taller.
fn bench_expand_holds_edge(n: usize, growths: usize) {
let mut rsc = BenchRsc {
ui: UiData::default(),
};
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
// Near the end (not the very last row) so it is already on screen
// under the list's default bottom-anchored placement, for every N --
// no scrolling needed to bring it into view before measuring.
let growable_index = n.saturating_sub(3);
let mut growable = None;
for i in 0..n {
if i == growable_index {
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let sized = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(),
x: None,
y: Some(abs(40.0)),
});
growable = Some(sized.weak());
list.push_back(LazyItem::new(i as u64, sized.any()));
} else {
let row = build_row(&mut rsc, i, 20);
list.push_back(LazyItem::new(i as u64, row));
}
}
let list = rsc.ui.widgets.add_strong(list);
let list_weak = list.weak();
let root = list.any();
let growable = growable.unwrap();
let mut render = UiRenderState::new();
render.resize((1080.0, 2000.0));
render.update(&root, &mut rsc);
render.take_counters();
let mut total = std::time::Duration::ZERO;
let mut total_draws = 0u64;
let mut total_rewrites = 0u64;
let mut total_moves = 0u64;
let mut height = 40.0f32;
let key = growable_index as u64;
for _ in 0..growths {
height += 10.0;
if let Some((top, _bottom)) = rsc.ui.widgets.get(&list_weak).unwrap().extent(key) {
rsc.ui
.widgets
.get_mut(&list_weak)
.unwrap()
.note_tap(top + 1.0);
}
rsc.ui.widgets.get_mut(&growable).unwrap().y = Some(abs(height));
let start = Instant::now();
render.update(&root, &mut rsc);
total += start.elapsed();
let (draws, rewrites, moves, _shapes) = render.take_counters();
total_draws += draws;
total_rewrites += rewrites;
total_moves += moves;
}
report(
&format!(
"(e) expand-hold, N={n}, {growths} growths (totals; \
must not scale with N)"
),
total,
total_draws,
total_rewrites,
total_moves,
);
println!(
" per-growth average: {:.4}ms",
total.as_secs_f64() * 1000.0 / growths as f64
);
}
/// (g) One text widget of `chars` characters, redrawn in place `redraws`
/// times -- an open tool card whose content is rebuilt, or any widget
/// holding a lot of text that a tap changes.
///
/// A redraw frees every primitive the widget owned and writes fresh ones,
/// so every glyph is renumbered in its layer's draw order. Finding the
/// handle to renumber used to be a scan of everything the same widget
/// drew, which made one redraw quadratic in its own glyph count: 1.37s for
/// 51,200 glyphs on this machine, against 20ms to shape and rasterise the
/// same text. Print per-glyph rather than per-redraw, since flat is the
/// pass condition and a total says nothing without dividing it.
fn bench_redraw_big_text(chars: usize, redraws: usize) {
let mut rsc = BenchRsc {
ui: UiData::default(),
};
// One character per glyph, and varied so nothing can collapse the
// string into a repeat.
let content: String = (0..chars)
.map(|i| char::from(b'a' + (i % 26) as u8))
.collect();
let mut text = Text::new(content);
text.wrap = true;
let text = rsc.ui.widgets.add_strong(text);
let handle = text.weak();
let root = text.any();
let mut render = UiRenderState::new();
render.resize((1080.0, 2000.0));
render.update(&root, &mut rsc);
render.take_counters();
let mut total = std::time::Duration::ZERO;
for _ in 0..redraws {
// Asking for the widget mutably is what marks it for redraw --
// the same path a caller changing its content takes.
rsc.ui.widgets.get_mut(&handle).unwrap();
let start = Instant::now();
render.update(&root, &mut rsc);
total += start.elapsed();
}
let (draws, rewrites, moves, _shapes) = render.take_counters();
report(
&format!("(g) redraw one {chars}-glyph text, {redraws}x (totals)"),
total,
draws,
rewrites,
moves,
);
println!(
" per redraw: {:.3}ms, per glyph: {:.4}us",
total.as_secs_f64() * 1000.0 / redraws as f64,
total.as_secs_f64() * 1_000_000.0 / (redraws * chars) as f64,
);
}
fn main() {
println!("iris message-list benchmark -- release build, this machine's CPU");
for &n in &[100usize, 1_000, 10_000] {
bench_first_frame(n);
}
for &n in &[100usize, 1_000, 10_000] {
bench_scroll(n, 200);
}
for &n in &[100usize, 1_000, 10_000] {
bench_input_grows(n, 40);
}
for &n in &[100usize, 1_000, 10_000] {
bench_insert_above_anchor(n, 200);
}
for &n in &[100usize, 1_000, 10_000] {
bench_expand_holds_edge(n, 40);
}
for &chars in &[1_000usize, 10_000, 50_000] {
bench_redraw_big_text(chars, 10);
}
}
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Turns `iris::input` debug lines -- from a phone's diagnostics report, or
from a report the layer-1 harness produced with tracing on
(`iris::diagnostics::set_trace(true)`) -- back into a `TouchScript` file
`iris::harness::Harness::replay` can play back at layer 1.
Why this exists: `docs/RUST.md`'s "Three test layers" box says the cheapest
layer that can answer a question wins, and a gesture that misbehaves on
Iris's phone is otherwise only describable in words. `iris::sense::
log_input_event`'s one line per platform event (Android's on_touch_event
once per `MotionEvent`, with historical samples inline; winit's once per
pointer `WindowEvent`; the harness's `touch`, once per script line) already
carries everything a `.touch` file's `t_ms action x y` needs -- this just
reads it back out and reconstructs the samples in order, expanding each
event's inline historical samples into their own `move` lines first (they
are always intermediate positions of a move, and Android documents them as
oldest first, which is also the order they appear in the line).
Usage:
report_to_touch.py < report.txt > replay.touch
report_to_touch.py report.txt > replay.touch
Only lines containing "iris input: action=..." are read; everything else in
the report (insets, frame timings, drag-release summaries) is ignored, so
this can be pointed at Copy report's whole clipboard text directly.
"""
import re
import sys
# The message half of `sense::log_input_event`'s format string, prefix-
# agnostic: a real report line also carries the ring's own
# `HH:MM:SS.mmm LEVEL target:` header (`LogLine::format`) or, forwarded
# through `ai_server::client_log`, a `[<source> <clock> #<seq>]` tag ahead
# of that -- neither of which this needs to understand, since `search`
# (not `match`) finds the marker wherever it starts.
LINE_RE = re.compile(
r"iris input: action=(?P<action>\w+) x=(?P<x>-?[0-9.]+) y=(?P<y>-?[0-9.]+) "
r"t=(?P<t>[0-9]+)ms history=(?P<hist>[0-9]+)(?P<rest>.*)$"
)
# One historical sample inside `rest`: `t:x,y`, space-separated, oldest first
# -- see `log_input_event`'s own doc for why order matters.
HIST_RE = re.compile(r"(?P<t>[0-9]+):(?P<x>-?[0-9.]+),(?P<y>-?[0-9.]+)")
def _fmt(value: float) -> str:
"""The number as `TouchScript::parse`'s own `f32::parse` would round-trip
it -- an integer without a trailing `.0` where the source was one
(every coordinate here is a physical pixel), `{:g}` otherwise so a
fractional value from a real device is not silently truncated."""
if value == int(value):
return str(int(value))
return f"{value:g}"
def convert(lines):
"""Every `iris::input` line, oldest first, expanded to one `(t_ms,
action, x, y)` tuple per touch sample -- a historical sample is always
an intermediate `move`, and the event's own sample keeps its real
action (`down`/`move`/`up`/`cancel`)."""
rows = []
for line in lines:
m = LINE_RE.search(line)
if not m:
continue
hist_count = int(m.group("hist"))
hist_matches = list(HIST_RE.finditer(m.group("rest")))
if len(hist_matches) != hist_count:
print(
f"report_to_touch: {line.strip()!r} says history={hist_count} but "
f"holds {len(hist_matches)} samples -- skipped",
file=sys.stderr,
)
continue
for hm in hist_matches:
rows.append(
(int(hm.group("t")), "move", float(hm.group("x")), float(hm.group("y")))
)
rows.append(
(int(m.group("t")), m.group("action"), float(m.group("x")), float(m.group("y")))
)
return rows
def main():
if len(sys.argv) > 2:
print("usage: report_to_touch.py [report.txt] < report.txt", file=sys.stderr)
return 2
text = open(sys.argv[1]) if len(sys.argv) == 2 else sys.stdin
for t_ms, action, x, y in convert(text):
print(f"{t_ms} {action} {_fmt(x)} {_fmt(y)}")
return 0
if __name__ == "__main__":
sys.exit(main())
+298
View File
@@ -0,0 +1,298 @@
#!/usr/bin/env python3
"""Compose's touch velocity tracker, transcribed independently of the Rust port.
Same reason `fling_spline_reference.py` exists: the numbers checked into
`sense.rs`'s velocity tests must not be numbers the Rust produced. The old
estimator -- total motion over the sample span, an average -- passed every test
it had, because every one of those tests asserted the average's own definition
back at it. An average cannot tell an accelerating flick from a steady drag, and
that is exactly what Iris reported from the phone on 2026-09-07: "flinging now
actually works but is slower than Compose's immediately after releasing the
flick".
Transcribed by hand from, and only from, the `-sources.jar` of
**androidx.compose.ui:ui-android:1.12.0** and
**androidx.compose.foundation:foundation-android:1.12.0**
(dl.google.com/dl/android/maven2), read 2026-09-07:
* `androidx/compose/ui/input/pointer/util/VelocityTracker.kt` --
`VelocityTracker1D.calculateVelocity`, `polyFitLeastSquares`,
`calculateImpulseVelocity`, `kineticEnergyToVelocity`, and the constants
`HistorySize = 20`, `HorizonMilliseconds = 100`,
`AssumePointerMoveStoppedMilliseconds = 40`.
* `androidx/compose/ui/input/pointer/util/PlatformVelocityTracker.kt` --
`Lsq2VelocityTracker`, which is what the 2D `VelocityTracker` delegates to.
* `androidx/compose/ui/input/pointer/util/PlatformVelocityTracker.android.kt`
-- the `AndroidComposeUiFlags.isFrameworkVelocityTrackerEnabled` fork.
* `androidx/compose/ui/AndroidComposeUiFlags.android.kt` -- that flag's
default, which is `false`.
* `androidx/compose/foundation/gestures/Draggable.kt` -- `sendDragStart` /
`sendDragEvent` / `sendDragStopped`, i.e. *which* samples a touch drag
feeds the tracker and where the maximum-velocity clamp is applied.
* `androidx/compose/foundation/gestures/DifferentialVelocityTracker.kt` and
`NonTouchScrollingLogic.kt` -- the Impulse strategy's only caller.
* `androidx/compose/foundation/gestures/Scrollable.kt` --
`DefaultFlingBehavior.performFling`, for the minimum-velocity question.
**Which strategy a touch fling actually uses, since this was the surprise.**
`Strategy.Impulse` is *not* it. `scrollable`/`draggable` release through
`DragGestureNode.sendDragStopped`, which calls the 2D `VelocityTracker`; on
Android that is `Lsq2VelocityTracker` (the framework-tracker flag defaults to
false), which is two `VelocityTracker1D(strategy = Lsq2)` -- a degree-2
least-squares fit over **absolute positions**, whose velocity is the fitted
polynomial's derivative at the newest sample. Impulse is reached only through
`DifferentialVelocityTracker`, whose sole caller is `NonTouchScrollingLogic`:
mouse wheel and trackpad, never a finger. So this script transcribes Lsq2 and
iris ports Lsq2. `calculate_impulse_velocity` is here anyway, unused by the
printed points, because ruling it out by reading is cheaper than ruling it out
again next time somebody remembers "Compose uses impulse".
**Which samples a touch drag feeds it.** `sendDragStart` adds the DOWN change;
every subsequent MOVE, historical samples included, is added by `sendDragEvent`.
The **UP position is never added**: `Lsq2VelocityTracker.addPointerInputChange`
wraps its two `addPosition` calls in `if (!event.changedToUpIgnoreConsumed())`,
and all the UP branch does is reset the tracker when more than 40ms have passed
since the last MOVE (b/238654963). So a finger that stops before lifting reads
as a stop, not as a decelerating tail. Positions are the raw event positions,
so the touch slop is inside the motion the tracker sees even though the list
never scrolled by it.
Two of Compose's samples iris does *not* reproduce, both noted rather than
copied: pre-slop MOVEs (iris's `DragArbiter` is `Undecided` then too, so it
feeds none either -- these agree), and the single MOVE that *crosses* the slop,
which Compose drops because `sendDragStart` adds only the DOWN. iris feeds that
one, since it is a real measured position and dropping it would be copying a
quirk of where Compose happens to split its state machine.
**The clamps.** Maximum: `sendDragStopped` passes
`LocalViewConfiguration.maximumFlingVelocity`, which on Android is
`ViewConfiguration.getScaledMaximumFlingVelocity()` -- 8000 dp/s. Minimum:
there is **none** on this path. `ViewConfiguration.minimumFlingVelocity`
exists in Compose's `ViewConfiguration` interface but its only use in either
artifact is `NestedScrollInteropConnection`, for View interop.
`DefaultFlingBehavior.performFling` guards with `abs(initialVelocity) > 1f`
and says why in its own comment: "we need it since spline curve gives us
NaNs". 1 px/s, not 50 dp/s.
Run it with no arguments; it prints the sample sets and the velocities the
Rust tests assert on.
"""
import math
HISTORY_SIZE = 20
HORIZON_MILLISECONDS = 100.0
ASSUME_POINTER_MOVE_STOPPED_MILLISECONDS = 40.0
MIN_SAMPLE_SIZE_LSQ2 = 3
# ViewConfiguration.getScaledMaximumFlingVelocity(), in dp/s.
MAXIMUM_FLING_VELOCITY_DP_S = 8000.0
# DefaultFlingBehavior.performFling's own threshold, in the units of the
# positions fed to the tracker -- pixels per second here.
FLING_MINIMUM_PX_S = 1.0
def poly_fit_least_squares(x, y, sample_count, degree):
"""`polyFitLeastSquares`: Gram-Schmidt QR, coefficients low order first."""
if degree < 1:
raise ValueError("The degree must be at positive integer")
if sample_count == 0:
raise ValueError("At least one point must be provided")
truncated_degree = sample_count - 1 if degree >= sample_count else degree
m = sample_count
n = truncated_degree + 1
# a[i][h] = x[h]**i, pre-multiplied by the (always 1.0) weight.
a = [[0.0] * m for _ in range(n)]
for h in range(m):
a[0][h] = 1.0
for i in range(1, n):
a[i][h] = a[i - 1][h] * x[h]
q = [[0.0] * m for _ in range(n)]
r = [[0.0] * n for _ in range(n)]
for j in range(n):
w = q[j]
w[:] = a[j][:m]
for i in range(j):
z = q[i]
dot = sum(w[h] * z[h] for h in range(m))
for h in range(m):
w[h] -= dot * z[h]
norm = math.sqrt(sum(v * v for v in w))
inverse_norm = 1.0 / max(norm, 1e-6)
for h in range(m):
w[h] *= inverse_norm
for i in range(n):
r[j][i] = 0.0 if i < j else sum(w[h] * a[i][h] for h in range(m))
coefficients = [0.0] * n
for i in range(n - 1, -1, -1):
c = sum(q[i][h] * y[h] for h in range(m))
for j in range(n - 1, i, -1):
c -= r[i][j] * coefficients[j]
coefficients[i] = c / r[i][i]
return coefficients
def kinetic_energy_to_velocity(kinetic_energy):
sign = 0.0 if kinetic_energy == 0.0 else math.copysign(1.0, kinetic_energy)
return sign * math.sqrt(2 * abs(kinetic_energy))
def calculate_impulse_velocity(data_points, time, sample_count, is_data_differential):
"""`calculateImpulseVelocity` -- not on the touch path; see the module doc."""
work = 0.0
start = sample_count - 1
next_time = time[start]
for i in range(start, 0, -1):
current_time = next_time
next_time = time[i - 1]
if current_time == next_time:
continue
if is_data_differential:
delta = -data_points[i - 1]
else:
delta = data_points[i] - data_points[i - 1]
v_curr = delta / (current_time - next_time)
v_prev = kinetic_energy_to_velocity(work)
work += (v_curr - v_prev) * abs(v_curr)
if i == start:
work = work * 0.5
return kinetic_energy_to_velocity(work)
def calculate_velocity(samples):
"""`VelocityTracker1D.calculateVelocity` with `Strategy.Lsq2`.
`samples` is `(time_millis, position)` oldest first, at most the last
`HISTORY_SIZE` of which the circular buffer would still be holding.
Returns units per second.
"""
held = samples[-HISTORY_SIZE:]
if not held:
return 0.0
data_points = []
time = []
newest_time, _ = held[-1]
previous_time = newest_time
for sample_time, sample_position in reversed(held):
age = float(newest_time - sample_time)
delta = abs(float(sample_time - previous_time))
# Lsq2 walks back sample to sample; only the non-differential
# Impulse branch compares every sample against the newest one.
previous_time = sample_time
if age > HORIZON_MILLISECONDS or delta > ASSUME_POINTER_MOVE_STOPPED_MILLISECONDS:
break
data_points.append(sample_position)
time.append(-age)
if len(data_points) == HISTORY_SIZE:
break
if len(data_points) < MIN_SAMPLE_SIZE_LSQ2:
return 0.0
try:
coefficients = poly_fit_least_squares(time, data_points, len(data_points), 2)
except ValueError:
return 0.0
# The 2nd coefficient is the fitted polynomial's derivative at x = 0,
# which is the newest sample's timestamp. units/ms -> units/s.
return coefficients[1] * 1000.0
def clamped(velocity, maximum):
"""`VelocityTracker1D.calculateVelocity(maximumVelocity)`."""
if velocity == 0.0 or math.isnan(velocity):
return 0.0
return min(velocity, maximum) if velocity > 0 else max(velocity, -maximum)
def average(samples):
"""The estimator being replaced: total motion over the span."""
if len(samples) < 2:
return 0.0
span = (samples[-1][0] - samples[0][0]) / 1000.0
if span <= 0.0:
return 0.0
return (samples[-1][1] - samples[0][1]) / span
# --- The three recorded sample sets the Rust tests assert on. ----------------
# 1. `transcript-fixture/touch/flick-120hz.touch`, as `DragGesture` feeds it:
# the DOWN position, then one position per MOVE. The UP at t=20 adds no
# sample (see the module doc), which is why the finger sitting still for its
# last 4ms does not drag the estimate down. y only; the flick is vertical.
FLICK_120HZ = [(0, 1000.0), (4, 1040.0), (8, 1086.0), (12, 1138.0), (16, 1196.0)]
# 2. A steady drag: 5px every 10ms for 100ms. A constant-velocity fit and an
# average must agree here -- this is the case that cannot tell the two
# estimators apart, which is why it is not the only one.
STEADY_DRAG = [(i * 10, float(i * 5)) for i in range(11)]
# 3. A flick that accelerates into the release: 10ms apart, deltas doubling.
# This is the case the average gets wrong, and the negative control for
# the port -- reverting to the average must fail this test and only this
# kind of test.
ACCELERATING_FLICK = [(0, 0.0), (10, 2.0), (20, 6.0), (30, 14.0), (40, 30.0), (50, 54.0)]
# 4. The two edges of the sample walk, checked here so the Rust asserts
# Compose's answer rather than iris's own reading of the rule.
# (a) An old, fast burst outside the 100ms horizon, then a slow steady
# drag: the burst must not leak into the estimate.
OLD_BURST_THEN_STEADY = [(0, 0.0)] + [(10 + i * 10, 1000.0 + i) for i in range(11)]
# (b) The finger stops for 48ms and then lifts. The gap exceeds
# AssumePointerMoveStopped, so the walk breaks after one sample and
# there is no fling -- what stops a "park it and let go" from
# flinging at whatever speed the finger arrived with.
STOPPED_BEFORE_RELEASE = [(0, 0.0), (4, 40.0), (8, 90.0), (12, 150.0), (60, 152.0)]
# 5. `sense.rs`'s own `drag_gesture_tests`: what `DragGesture` feeds for a
# press and two move frames, which is the fewest a fit can use.
TWO_MOVE_FRAMES = [(0, 0.0), (8, 100.0), (16, 220.0)]
# ... and one move frame, which Compose cannot fit either.
ONE_MOVE_FRAME = [(0, 0.0), (8, 100.0)]
# The phone: 1080x2424 at content_scale 2.55.
PHONE_DENSITY = 2.55
def report(name, samples):
v = calculate_velocity(samples)
print(f"{name}:")
print(f" samples (t_ms, position): {samples}")
print(f" Lsq2 (Compose's touch path): {v:.4f} px/s")
print(f" average (the old estimator): {average(samples):.4f} px/s")
print(f" impulse (non-touch, for ref): ", end="")
held = list(reversed(samples[-HISTORY_SIZE:]))
newest = held[0][0]
print(
f"{calculate_impulse_velocity([p for _, p in held], [-(newest - t) for t, _ in held], len(held), False) * 1000.0:.4f} px/s"
)
print()
if __name__ == "__main__":
print("Compose 1.12.0 touch velocity: VelocityTracker1D, Strategy.Lsq2,")
print("non-differential (positions), HistorySize=20, Horizon=100ms,")
print("AssumePointerMoveStopped=40ms, minSampleSize=3.\n")
report("flick-120hz.touch", FLICK_120HZ)
report("steady drag (5px/10ms)", STEADY_DRAG)
report("accelerating flick (deltas 2,4,8,16,24 per 10ms)", ACCELERATING_FLICK)
report("old burst then steady 1px/10ms", OLD_BURST_THEN_STEADY)
report("stopped 48ms before release", STOPPED_BEFORE_RELEASE)
report("press and two move frames", TWO_MOVE_FRAMES)
report("press and one move frame", ONE_MOVE_FRAME)
print("Clamps:")
print(f" maximum: {MAXIMUM_FLING_VELOCITY_DP_S} dp/s")
print(
f" = {MAXIMUM_FLING_VELOCITY_DP_S * PHONE_DENSITY:.1f} px/s at the phone's density {PHONE_DENSITY}"
)
print(f" minimum: none on the fling path; DefaultFlingBehavior skips |v| <= {FLING_MINIMUM_PX_S} px/s")
print()
print("Two samples only (a press and one move, the phone's 120Hz worst case):")
print(f" Lsq2 needs 3 and answers {calculate_velocity(FLICK_120HZ[:2]):.4f} px/s")