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
7b54aaf3c4
commit
a9312e9431
113 files changed
+23221
-2992
No files matched your search
@@ -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"
|
||||
)
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Executable
+96
@@ -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())
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user