Author SHA1 Message Date
iris 4274b8b8d0 Merge remote-tracking branch 'origin/rustify' into worktree-agent-ace98b0bdaf33ffff
# Conflicts:
#	docs/IRIS.md
#	docs/RUST.md
2026-09-07 15:33:25 -04:00
irisandClaude Fable 5.1 73f956f8e0 iris: the fling curve was the identity function, and the keyboard was a targetSdk
Iris's 2026-09-07 phone report on ed04d4c: the resume glyph corruption is
fixed (item 4 closed with her evidence), flinging "seems to just be linear
velocity with an abrupt stop", and the keyboard still does not push
anything up. docs/RUST.md's new "The 2026-09-07 phone report" section has
the derivation and every number.

**The fling was arithmetically linear.** `android_fling_spline::
distance_fraction(t)` returned `t` for every `t`. Two halves of AOSP's
`SplineOverScroller` static initialiser had been transposed -- the
bisection solved the tension curve and the sample evaluated the P1/P2 one,
where AOSP does the opposite -- which made SPLINE_POSITION and SPLINE_TIME
identical; the lookup then bracketed `t` between SPLINE_TIME entries
instead of between even time steps, and the two cancelled to the identity.
Ported exactly now from OverScroller.java and androidx.compose.animation
1.12.0's SplineBasedDecay.kt, which agree line for line, as one table
indexed by even steps of time (AOSP's second table serves only
`adjustDuration`, which nothing here has, so it is deliberately not built
-- one array, one indexing rule). `FlingCalculator::velocity_at` is new
beside `position_at`, and `List::tick_fling` logs `iris fling tick:` with
the per-frame delta and speed.

Every existing test compared the calculator with itself -- monotonic,
signed, integrates to the closed form, deltas non-increasing -- and all of
them pass on a straight line. iris/benches/fling_spline_reference.py is an
independent hand transcription of both sources and supplies the numbers
now checked into `the_spline_matches_aosps_own_table` and
`a_flick_decelerates_the_way_aosp_says_it_does`;
`tick_fling_applies_shrinking_incremental_deltas` went from
"non-increasing" to "the last delta is under 80% of the first". Negative
control: with `sample` forced back to `t`, exactly those three fail.

Emulator (API 36, debug, force-gles): a released v=3750 decelerates
3746 -> 2624 -> 1834 -> 1144 -> 752 -> 449 -> 243 -> 83px/s over 32 frames
to t=0.664s; a flick into the end of the list stops there in one tick with
no overshoot; a tap 200ms into a fling ends it at 11 ticks.

**The keyboard: `targetSdk = 34`** in iris/android-app/app/build.gradle,
against compileSdk 37 and the Compose app's 37 -- and that app's keyboard
does push up on her phone. Below target 35 a window keeps the legacy
behaviour where adjustResize shrinks it for the IME, so
getInsets(ime()).bottom measures an already-shrunk window and is zero;
setDecorFitsSystemWindows(false) opts out of that and still takes on the
API 36 emulator here, which is why every test run passed. Now targetSdk 37.

That is a reading and not a measurement, so the other half is making the
phone able to answer it. MainActivity also registers a
WindowInsetsAnimation.Callback (onEnd re-reads getRootWindowInsets, so an
interrupted animation cannot freeze a value), which delivers the height
where only the animation path carries it and makes the push-up animate:
ime_bottom now arrives 509, 663, 833, 881, 883 instead of one jump.
`insets::Shared::updates` counts every dispatch and
`AndroidUiState::insets_report()` puts it in the Diagnostics pane --
screenshot-verified, `insets: dispatches=27 left=0 top=142 right=0
bottom=63 ime_bottom=0 ime_visible=false`. Iris has no logcat, and "the
listener never fired" and "it fired with a zero height" are otherwise the
same picture; dispatches=0 says so in words rather than showing defaults.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:44:01 -04:00
irisandClaude Fable 5.1 038f6a3832 docs: the test rig's layers 1 and 2, with their commands and their limits
RUST.md's "Three test layers" section rewritten in place with what was
built: the `cargo test -p transcript-fixture` command and the five
assertions with the mutation that fails each, the `run-headless.sh
--phone [--replay …]` commands and the 15s/18s they take, and a
paragraph on what still cannot be answered below layer 3 (anything about
pixels, any frame time, anything JNI). Also the two traps that cost time
-- `swaymsg seat - cursor` reaching nothing on a compositor with no
input devices, and a leftover window tiling beside the new one so a
screenshot looks like a duplicated-primitive bug.

IRIS.md gains the public surface: `iris::harness`, `TouchScript`,
`List::fling_velocity`, the fling's clock, and the desktop backend's
move to physical-pixel layout with `content_scale`/`IRIS_SCALE`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:39:53 -04:00
irisandClaude Fable 5.1 1121d7cc83 docs/LAYOUT.md: masks reference a drawn primitive instead of copying a shape, and hit-testing applies the shape (Iris, 2026-09-07)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:38:55 -04:00
irisandClaude Fable 5.1 232de0ec53 iris: a phone-shaped desktop window, driven by the same touch recordings
Layer 2 of docs/RUST.md's "Three test layers":

    ./run-headless.sh phone --phone --shot /tmp/p.png -- -p transcript-fixture

opens `transcript-fixture`'s screen -- the same fixture and the same
fold the headless tests and the Android bench use -- in a window at the
phone's own 1080x2424 and `content_scale` 2.55, and screenshots it. 15
seconds, warm. `--replay FILE` drives one of the `.touch` recordings
into it and writes `<shot>-before.png` too, so "the list moved" is two
pictures: the flick carries it back about seven turns of the fixture.

Two things this needed.

**The desktop backend now lays out in physical pixels with a density,
exactly as Android does** (`default::content_scale`, overridable with
`IRIS_SCALE`, which is how `--phone` hands it the phone's). It used to
divide winit's coordinates into a separate "logical" space, which left
`UiRenderState::resize` (physical, from `WindowEvent::Resized`) and the
window uniform (logical) disagreeing on any display whose scale factor
is not 1.0, and rasterised glyphs at one resolution to show them at
another. At 1.0 -- every display here -- the numbers are unchanged, and
the `tabs` screenshot is identical.

**`rig-input`'s `replay-touch`** puts a gesture on screen. This
machine's compositor has no pointer to move: sway runs on the headless
backend with no input devices, so `swaymsg seat - cursor press` reports
success and `swaymsg -t get_seats` shows `capabilities: 0`. wlroots 0.19
dropped `WLR_HEADLESS_INPUTS` and ydotool's uinput device would be
ignored by a compositor not reading libinput, so the virtual-pointer
protocol is what is left. It parses the *same* `TouchScript` the
harness does, so one recording drives both layers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:38:19 -04:00
irisandClaude Fable 5.1 e430880cde docs: phone report 2026-09-07, rows at the transcript's top edge culled early or drawn through the header
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:35:20 -04:00
irisandClaude Fable 5.1 a999bd106a docs: masks with a shape (LAYOUT.md, decided 2026-09-07) and the orchestrator queue in RUST.md
Iris: masks should carry a shape, rounded rectangle first, or take a
container widget as the mask, with corner alpha multiplied rather than
cut. Design: the mask evaluates the same SDF draw_rounded_rect uses,
nested masks chain and multiply like moves, and a rounded Rect's
.masked() makes the container the mask with one radius by construction.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:34:19 -04:00
irisandClaude Fable 5.1 6840edf61e iris-android-app: the bench's fixture half comes from transcript-fixture
The fixture bytes, the backlog/tail split and the fold into a screen
were `bench_client.rs`'s alone; they are `transcript-fixture`'s now, so
the Android bench, the headless harness and the phone-shaped desktop
window open one screen from one copy (AGENTS.md: nothing UI-shaped in a
platform crate). What stays here is the JNI half -- clipboard, battery,
IME, the report and the four phases.

Built with `cargo ndk -t arm64-v8a -P 29 build --features
"transcript-screen bench"`; the two warnings it prints (bench_jni's
unused overlay methods, the unused `tabs-ui` dependency under this
feature set) predate this change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:27:04 -04:00
irisandClaude Fable 5.1 333220196e iris: a headless in-process harness, and the bench fixture as a shared crate
Layer 1 of docs/RUST.md's "Three test layers": `iris::harness` opens a
real screen with no window, no compositor and no GPU, on an explicit
clock and a replayed touch stream -- a trivial `t_ms action x y` file,
so the batched 120Hz flick shape from Iris's phone report is
reproducible as a test. The emulator cannot produce that shape at all:
a `ui-trace` swipe is many evenly-spaced events, a finger is five
samples in 20ms.

`transcript-fixture` is the fixture-loading and fold-driving half of
`iris-android-app`'s `bench_client.rs`, moved out of the platform crate
so the harness, a desktop window and the Android bench open the same
screen from the same bytes (AGENTS.md's sharing rule).

Two supporting changes in iris itself, both about reading a clock that
was not handed in: `Fling::started_at` is now set on the first
`tick_fling` rather than at the release, so a driver running frames on
its own clock does not start every fling at the wall clock and advance
it on a different one; and `List::fling_velocity` exposes what the
release measured, which is where `Released(Some(v))` lands.

Four tests, each confirmed to fail without its subject: dropping
`animate(id)` from `Selection::drag` (the phone's own "fling does
nothing" defect) and reverting `started_at` each fail the flick test
alone; flinging on `Tapped` fails only the tap test; a 5s `LONG_PRESS`
fails only the selection test; a `set_bottom_inset` that ignores its
argument fails only the composer/IME test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:24:54 -04:00
irisandClaude Fable 5.1 7f4ea7e8fd docs/TODO.md: Compose app crash from Iris's phone log export, reversed AnnotatedString range in ToolInput.highlighted
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:22:47 -04:00
irisandClaude Fable 5.1 591128eef1 AGENTS.md: the phone app and the planned desktop app share widgets and styling; only screen layout differs
Iris, 2026-09-07. The second central design point beside the driver
rule, so a platform crate growing a widget or a colour reads as a
defect to move. docs/RUST.md carries the detail.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:13:54 -04:00
irisandClaude Fable 5.1 ba0f2ea93f docs: the 22:16 report reconciled with what was actually run
RUST.md's "Shell lost" section and IRIS_TODO.md's matching paragraph both
said item 4's fix was written but never built or tested. It was committed
in ba2afba with its test passing, so both were stale the moment that
landed and read as if nothing had been run at all.

Replaced with one section per item, saying what was fixed, what was
measured on this checkout's emulator and what the phone still has to
settle: items 2 and 3 ticked with their numbers, item 4 ticked on the code
with phone confirmation still owed (no Vulkan adapter here), item 1 left
open with the exact logcat line for Iris to look at. The two pre-existing
faults found on the way -- the 16-deep move chain and the API-29 JNI calls
-- are recorded where the next reader will hit them.

IRIS.md gains the public-surface entry: `Widget::tick`,
`UiData::animate`/`tick_animations`, `FlingCalculator`'s density and
coefficient, and `MOVE_CHAIN_LIMIT`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:12:03 -04:00
irisandClaude Fable 5.1 ed04d4c735 iris: the keyboard reopens, the IME's height reaches the layout, and a fling actually moves
Items 1-3 of Iris's 22:16 phone report, plus the two defects that were
hiding behind item 1 and only became visible once the first one was
fixed. Emulator evidence and the numbers are in docs/RUST.md.

**Keyboard reopen.** `attr.rs`'s already-focused branch calls
`focus_gained` on a tap that stays inside `DRAG_SLOP` -- what Android's
own `EditText` does, `showSoftInput` being idempotent. Dismissing the IME
leaves the field focused, so the only branch that requested it never ran
again. Negative control run: without this one call the second tap leaves
`mInputShown=false`. Swipes across and out of the focused field still
summon nothing.

**IME height.** `MainActivity` sends `getInsets(ime()).bottom` and
`isVisible(ime())` as two values; the height used to be sent *as* the
boolean, so nothing had a number to pad by. `Insets`/`WindowInsets` carry
both, `bench_client` reads the boolean for its state machine and the
height for `Composer::set_bottom_inset`, and the list follows because it
is `rest(1)` in the same `Span`.

**Fling.** Three defects, in the order they were found:

1. `on_touch_event` read only each `MotionEvent`'s final position, so a
   batched 120Hz flick fed the tracker one sample and `velocity()`
   answered 0.0. Historical samples are replayed through the sensor pass
   now, `CursorState::time` carries each sample's own time (so a replay
   loop's speed cannot become the measured velocity -- the winit backend
   sets it too), the press is a sample as AOSP's own tracker does, and
   `iris drag release:` logs the decision for the phone's logcat.
2. Nothing advanced a fling between input events: `tick_fling`'s only
   caller was the benchmark's own loop, so the bench flung and a finger
   never did. iris has one animation mechanism now -- `Widget::tick`,
   `UiData::animate`/`tick_animations`, called by both backends before
   the draw and re-requesting a frame while it answers true.
3. With flings finally animating, one lasted 45 seconds: `List::fling`
   hardcoded density 1.0 against physical-pixel velocities, and
   `FlingCalculator`'s coefficient used the scroll friction where AOSP
   uses its 0.84 tuning constant -- 56x, inside an exponential. Emulator:
   1.62s for v=11064, against AOSP's own 1.586s.

**Two pre-existing faults found on the way.** `MOVE_CHAIN_LIMIT` was 16
and the composer's chain is 17, so every debug build aborted on a tap of
the composer and every release build silently drew and hit-tested that
subtree short; it is 64 in both the CPU walk and shader.wgsl, and the
assert prints the chain so a cycle and a deep tree can be told apart. And
`minSdk` is 29, since `getEventTimeNanos` is API 29 and a missing JNI
method is a crash rather than a degraded fling.

Every new invariant carries its guard: sample times non-decreasing in
`on_touch_event`, and tests confirmed to fail without their fix for the
press-seeded velocity, the animation registration and the AOSP
magnitudes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:11:55 -04:00
47 changed files with 3284 additions and 287 deletions

No files matched your search

+29
View File
@@ -19,6 +19,21 @@ child process, translated into one common event model.** A new session type
is a new driver — never a session-type branch in shared code (routes,
transcript, app screens).
The second one, for the Rust port on the `rustify` branch: **the phone app
and a planned desktop app share almost all of their code.** Screens, widgets,
folding, paging, config and the network client live in the shared crates
(`iris`, `client-core`, `transcript-ui`, `tabs-ui`); `android-app` and
`desktop-app` are thin entry points that own only what the platform forces
(JNI and the IME on one side, winit and argv on the other). The two
*layouts* will differ, to suit a phone's screen and a finger against a
desktop's screen and a mouse -- but the widgets a layout is made of (a
button, a text field, a list, a card) and the styling (colours, spacing,
type) are one implementation with no per-platform copy. Anything that could
work on both goes in a shared crate the first time it is written, and a
platform crate growing a widget or a colour is a defect to move, not a
convenience to keep. Iris said this on 2026-09-07; docs/RUST.md carries the
details.
## Layout
Mirrors `../dev-updater` deliberately: same stack (axum 0.8 +
@@ -268,6 +283,20 @@ Each exists because something was invisible without it.
checkout's own emulator, taps "Run benchmark" by label, and prints the
report -- written so the P0 build/install/tap/read-report cycle stops
being retyped by hand each time (docs/RUST.md's P0 box).
- **iris's three test layers** (docs/RUST.md's "Three test layers" has
the commands and what each cannot answer): test at the cheapest one
that can answer the question. `cargo test -p transcript-fixture` runs
the real transcript screen over the bench fixture with **no window, no
compositor and no GPU** (`iris::harness`), on a clock the test owns and
a gesture replayed from a `t_ms action x y` file under
`iris/transcript-fixture/touch/` -- which is how the batched 120Hz
flick a finger actually makes is testable at all, since a `ui-trace`
swipe is many evenly-spaced events. `iris/run-headless.sh phone --phone
--shot …` opens the same screen in a window at the phone's own size and
density for looking at, and `--replay FILE` drives the same recording
into it. The emulator is for JNI, the IME, insets, the surface
lifecycle and one verification run before a build goes to the phone --
not for iterating on layout.
### Driving the UI
+126
View File
@@ -8,6 +8,132 @@ capability that moved. Small and trivial changes do not go here.
An entry gives the date, what changed, why, and a short before/after where
it helps judge the change without the session that made it. Newest first.
## 2026-09-07: a headless harness, replayed touch, and physical-pixel desktop layout
Layer 1 and 2 of docs/RUST.md's "Three test layers".
**New: `iris::harness`** -- a screen driven in-process with no window, no
compositor and no GPU, on a clock the caller advances. `Harness::new(size,
density)` gives you an `Rsc`, a `UiRenderState` and a state that
implements `FocusHost`/`OpenUrl` by *recording* what the platform was
asked for (`keyboard_shown`, `opened_urls`) rather than doing it;
`frame(t_ms)`/`frames_until(..)` run frames, `touch(action, pos, t_ms)`
feeds one pointer sample the way Android's `on_touch_event` does, and
`replay(&TouchScript)` runs a whole recorded gesture. `TouchScript` parses
a plain `t_ms action x y` file (`down`/`move`/`up`/`cancel`), so the
batched 120Hz flick shape your phone actually delivers is a file that
`cargo test` can replay -- something the emulator cannot produce at all.
**New: `List::fling_velocity() -> Option<f32>`**, what the release
measured, readable where it landed rather than by re-timing the gesture.
**Changed: `List` starts a fling's curve at its first `tick_fling`, not
at the release.** The only clock it reads is now the one its driver hands
it; in a running app the difference is at most a frame.
**Changed: the desktop backend lays out in physical pixels with a
density, exactly as Android does.** `iris::default::content_scale(window)`
is the desktop's `content_scale` -- winit's scale factor, overridable with
the `IRIS_SCALE` environment variable -- and it now feeds
`UiRenderState::set_density`/`TextData::density` instead of dividing
coordinates into a separate "logical" space. That division had
`UiRenderState::resize` (physical) and the window uniform (logical)
disagreeing on any display whose scale factor is not 1.0, and rasterised
glyphs at one resolution to display them at another. `Input::event` lost
its `scale_factor` parameter as a result, and `DefaultUiState::
window_size()` now answers physical pixels. On a 1.0 display nothing
changes. The override is what lets `run-headless.sh --phone` open a window
at your phone's own 1080x2424 and 2.55.
## 2026-09-07: the fling curve was the identity function
You said the fling "seems to just be linear velocity with an abrupt stop."
It was, exactly: `android_fling_spline`'s lookup returned `t` for every
`t`. Two halves of AOSP's spline build loop had been transposed, which made
its two tables identical, and the lookup interpolated one against the
other -- which reduces algebraically to `t`. So a fling coasted at its
release speed for the whole (correctly computed) duration and stopped dead
at the end of it.
Ported exactly now from `OverScroller.java` and Compose's
`SplineBasedDecay.kt`, which agree line for line. One public addition:
**`FlingCalculator::velocity_at(velocity, elapsed) -> f32`**, beside the
existing `position_at` -- AOSP's `mCurrVelocity` and Compose's
`FlingInfo.velocity`. It is what makes "is this decelerating" answerable
rather than inferred, and it is what `List::tick_fling`'s new
`iris fling tick:` debug line reports each frame.
The lesson worth keeping, since it cost two builds on your phone: every
test the calculator had compared it with itself -- monotonic, correctly
signed, integrates to the closed form, per-tick deltas non-increasing --
and **all of them pass on a straight line**. The numbers now come from
`iris/benches/fling_spline_reference.py`, a separate hand transcription of
the two sources, checked in beside the tests.
## 2026-09-07: the Android insets bridge counts its own dispatches
`AndroidUiState::insets_report() -> String` is new, and the bench app's
Diagnostics pane shows it. It carries the last insets plus **how many times
the platform has delivered any**, because "the keyboard did not push
anything up" has two causes that look identical on screen -- the listener
never fired, or it fired with a zero height -- and you have no logcat on
the phone. `dispatches=0` prints a sentence saying so rather than the
numbers, which would be defaults rather than measurements.
## 2026-09-07: widgets can animate, and a fling finally moves
Iris's phone said "fling still doesn't work" twice. The velocity was only
half of it: **nothing in iris advanced an animation between input
events**, so `List::fling` stored a speed that nothing ever applied. Three
public changes come out of fixing that.
**`Widget::tick(&mut self, now: Instant) -> bool`** is a new trait method,
defaulted to `false`, so no existing widget changes. A widget that
overrides it is animating; answering `false` is how it stops.
**`UiData::animate(id)` and `UiData::tick_animations(now) -> bool`** are
the registry and its driver. A gesture that starts an animation registers
the widget; each backend calls `tick_animations` once per frame before the
draw and asks for another frame while it answers `true`. That answer is
the *only* thing in iris that makes a frame happen without an input event,
and an animation's path out is its own `tick` returning false -- nothing
has to remember to unregister it.
// before: the velocity was stored and never applied
list(ui).fling(-v);
// after
list(ui).fling(-v);
let id = list.id();
ui.ui_mut().animate(id);
The two calls are deliberate rather than folded into `fling`: the velocity
is the list's business and whether anything animates at all is the frame
loop's, and a caller driving its own frames (the benchmark, the headless
tests) still calls `tick_fling` directly.
**`FlingCalculator` needs the real display density, and its coefficient
was wrong.** `new(density)` takes physical pixels per `dp` and the
velocity handed to it must be in those same physical pixels -- the
density does *not* cancel out, contrary to what that type's doc used to
claim. Separately, `physical_coefficient` multiplied by the scroll
friction (0.015) where AOSP multiplies by its own tuning constant 0.84, a
factor of 56 inside an exponential. Together they gave an ordinary flick a
**45-second** coast, which nobody could see while flings never animated.
`List` reads its density from the painter now, and
`a_flick_lasts_what_aosps_own_formula_says_it_does` pins the absolute
numbers (0.59s and 621px for 3000px/s at density 2.75) against AOSP's
formula -- the check every previous test could not make, because they all
compared the calculator with itself.
**`MOVE_CHAIN_LIMIT` is 64, not 16**, in `render_state.rs` and
`shader.wgsl` alike. It bounds a walk so a cyclic `parent` cannot hang
either side; it was never meant as a claim about tree depth, and the
transcript screen's composer field sits 17 slots below the root. Past the
bound both walks silently stop summing, so a widget draws and hit-tests
short with nothing to say so; the CPU assert now prints the chain, so a
cycle and a deep tree can be told apart.
## 2026-09-06: tool cards, `ToolState`, and a screen that knows whether its session is working
`transcript_ui::tool` is new: a card per tool call, a group per run
+113 -5
View File
@@ -351,7 +351,27 @@ agent ticks it here with the evidence.
Iris's report, verbatim, with a screenshot. Phone: Mali-G715 (Vulkan),
`content_scale: 2.55`, 120Hz. Open until ticked with phone-side evidence.
- [ ] **"Fling still doesn't work."** Second report; the emulator's
- [ ] **"Fling still doesn't work."** -> on `ed04d4c`, 2026-09-07:
*"flinging now does technically do something, but it seems to just be
linear velocity with an abrupt stop."* **It was exactly that, and the
arithmetic said so.** `distance_fraction(t)` returned `t` for every `t`
-- a constant-speed slide for the whole duration, then a stop at full
distance -- because two halves of AOSP's spline build loop were
transposed, which made `SPLINE_POSITION` and `SPLINE_TIME` identical, and
the lookup bracketed `t` between `SPLINE_TIME` entries rather than
between even time steps. The two cancelled to the identity. Ported
exactly now from `OverScroller.java` and
`androidx.compose.animation:animation:1.12.0`'s `SplineBasedDecay.kt`
(they agree line for line), with `iris/benches/fling_spline_reference.py`
as an independent transcription supplying the numbers the tests assert
on. Emulator, 2026-09-07: a released `v=3750` decelerates
`3746 -> 2624 -> 1834 -> 1144 -> 752 -> 449 -> 243 -> 83px/s` across 32
frames; a flick into the end of the list stops there in one tick with no
overshoot; a tap 200ms into a fling ends it at 11 ticks instead of 32.
**Open until the phone says so** -- a flick should now visibly slow
before it stops. Its earlier three defects (the velocity, the missing
animation registration, the 56x coefficient) are all still fixed and were
never the linear part.* Second report; the emulator's
`ui-trace` swipe flings (verified 2026-09-06 with `render()` counts),
a finger on the phone does not. What differs: a real flick at 120Hz is
batched by Android into few `MotionEvent`s with *historical* samples
@@ -364,14 +384,42 @@ Iris's report, verbatim, with a screenshot. Phone: Mali-G715 (Vulkan),
last sample is treated as a tap; `ACTION_CANCEL`/pointer-capture
delivering no `Drop`. Log the release decision (samples, span,
velocity, outcome) at `info` so the next logcat settles it.
- [ ] **"I can't reopen keyboard by tapping on message box after it
already happened once."** The field stays focused after the keyboard
- [x] **"I can't reopen keyboard by tapping on message box after it
already happened once."** *(Fixed 2026-09-07: `attr.rs`'s already-
focused branch calls `focus_gained` on a tap inside `DRAG_SLOP`.
Emulator: first tap `mInputShown=true`, back gesture, second tap
`mInputShown=true`. Negative control with that one call removed leaves
the second tap at `false`; a horizontal and a vertical swipe over the
focused field both leave it at `false`, so the earlier "swiping over
the input bar brings up the keyboard" has not returned.)* The field stays focused after the keyboard
is dismissed (back gesture, or the IME's own hide), so `on_press`'s
already-focused branch never requests the IME again. Android's
`EditText` shows the IME on every tap of a focused field; do the same
(`FocusHost`: a tap on a focused field requests the IME, idempotent
when it is already shown).
- [ ] **"Message box does not push up the scroll area."** Since
- [ ] **"Message box does not push up the scroll area."**
**Reopened by the phone on 2026-09-07** -- *"similarly, the keyboard
raising up does not push things upwards"* -- after being ticked on
emulator evidence the day before (`ime_bottom=883`, composer box
`31,2277..1048,2329` -> `31,1457..1048,1509`). The JNI half was right;
what was wrong is one line of `iris/android-app/app/build.gradle`:
**`targetSdk = 34`** against `compileSdk = 37`, while the Compose app in
`app/` targets 37 and *does* push up on her phone. Below target 35 the
window keeps the legacy behaviour, where `adjustResize` shrinks it for
the IME and `getInsets(ime()).bottom` therefore measures zero;
`setDecorFitsSystemWindows(false)` opts out of that and still takes on
the API 36 emulator here, which is why every test run passed. Now
`targetSdk = 37`, plus a `WindowInsetsAnimation.Callback` for the devices
where only the animation path carries the height -- which also makes the
push-up animate (`ime_bottom=509, 663, 833, 881, 883` instead of one
jump). **This is a reading, not a measurement**: no Android 17 device is
reachable from here. So the Diagnostics pane now prints
`insets: dispatches=N left=… ime_bottom=… ime_visible=…` --
**screenshot that line with the keyboard open.** `ime_bottom` in the
hundreds and the composer risen means fixed; `dispatches` climbing with
`ime_bottom=0` means the reading was wrong and the window is still being
resized; `dispatches=0` means the listener is not firing at all, which is
a third thing again.* Since
`MainActivity` went edge-to-edge (`e12c708`), `adjustResize` no
longer resizes the window, so the app owns the IME inset -- but
`ime_bottom` is passed through JNI as the boolean `1`/`0` (the
@@ -381,7 +429,7 @@ Iris's report, verbatim, with a screenshot. Phone: Mali-G715 (Vulkan),
composer's position follow the height, the visibility drives the
boolean the `imePadding` rule in AGENTS.md's "Things that have bitten"
describes.
- [ ] **"Picture is what happens if I leave the app and come back,
- [x] **"Picture is what happens if I leave the app and come back,
which completely removes text, and then I tap on the debug info. The
textures are definitely getting cooked for some reason after leaving
the app and resuming."** Screenshot: every glyph drawn *before* the
@@ -402,6 +450,42 @@ Iris's report, verbatim, with a screenshot. Phone: Mali-G715 (Vulkan),
screenshotted the emulator's GLES path, where a resume may not
destroy the surface at all.
**Fixed in `ba2afba` and confirmed on the phone (Iris, 2026-09-07:
"the resume glyph corruption is fixed").** Closed. The emulator could
never have settled it -- no Vulkan adapter here, and the GLES path may
not destroy the surface at all -- so the phone was the only place this
could be answered, and it has been. `clearing_the_atlas_re_renders_
cached_text_instead_of_reusing_it` is what keeps it.
The reading above is right and the mechanism is one step narrower than
"cached text primitives". `IrisViewPeer::surface_changed`
(`iris/src/android/view.rs`) *does* already force a full-tree redraw
after a rebuild: it calls `render.resize(...)` unconditionally, which
sets `UiRenderState::resized`, which makes the next `update` take
`redraw_all` rather than `redraw_updates`. So every widget's `draw`
really does run again after the resume. What survives it is one cache
further in: `TextView::render` (`iris/src/widget/text/mod.rs`) returns
its cached `RenderedText` whenever the wrap width, buffer and attrs are
unchanged -- true of every pre-resume row -- so `TextData::place` is
never reached, nothing is re-rasterised into the fresh atlas, and the
*old* atlas's `uv_min`/`uv_max`/`layer` are re-submitted verbatim. Only
text whose content changed after the resume (the diagnostics pane Iris
tapped) re-shapes, which is exactly the split in her screenshot.
`Painter::glyphs` has one call site in the whole workspace, that one,
so there is no second holder of a `RenderedText` to fix.
The fix, in `ba2afba`: `GlyphAtlas::generation`, bumped by
`GlyphAtlas::clear`; `RenderedText::generation` recording which atlas
its glyphs were placed against; `Painter::atlas_generation()`;
`TextView::render`'s cache key gains it; and a `debug_assert_eq!` in
`Painter::glyphs` that a submitted quad's generation is the live one.
Headless test
`clearing_the_atlas_re_renders_cached_text_instead_of_reusing_it`
(`iris/src/widget/text/mod.rs`): draw, `atlas.clear()`, `resize`, draw
again, and assert the atlas holds the same glyph count again -- it
stays at 0 without the fix, because the cache short-circuits before
`place`.
## Build
- [x] **Benchmarks**, not unit tests, run on demand (2026-09-05; a
@@ -847,3 +931,27 @@ do not duplicate it there.
p50 dropping below Compose's on the phone. Do this after the four bench
v2 defects (stale primitives, finger fling, decay curve, IME show) are
closed, since they are what make the run unrepresentative today.
## From the phone, 2026-09-07 (build from ed04d4c)
- [ ] **"Some transcript blocks will be hidden until I uncover enough of
them."** Two screenshots of the bench app's transcript at the top
edge, both wrong in opposite directions: in one, rows scrolled above
the viewport are still drawn and bleed *through* the header bar
(`version = "0.1.0"` and a paragraph visible behind "Run benchmark /
Copy report / Diagnostics"), so the list's mask is not clipping at
the header's bottom edge; in the other, scrolled a little further,
the row that straddles the top edge is not drawn at all -- black from
the header down to "You", where the previous shot showed a paragraph
-- so a row is culled as soon as its *top* leaves the viewport rather
than when its *bottom* does. Suspects: the list's visible-range test
(`iris/src/widget/list.rs`) comparing a row's top against the
viewport top; the mask region for the transcript set from the
window rather than from the area under the header; and the two-phase
provisional/real draw noted in `03c6be8`'s header-duplicate
investigation, which was never root-caused and has the same shape.
Reproduce at layer 1 of the test rig: a headless screen with a row
straddling the top edge must place that row, and a primitive above
the header's bottom must be masked. Fix both with one rule: a row is
drawn if any part of it intersects the viewport, and the viewport is
the list's own region.
+92
View File
@@ -947,3 +947,95 @@ When this lands, copy this entry into `IRIS.md` (newest first):
> `SizeCtx` and `Cache` are gone with it — see `LAYOUT.md` for the full
> design, the move-offset mechanism this shipped alongside, and the file
> list.
## Masks with a shape (decided 2026-09-07, not yet built)
Iris, on the code block's scrolling: "the code block scrolling currently
masks in an inner rectangle. Ideally masks should have a shape
associated with them, rounded rectangle being one of them, and/or
another widget you can select, so that the mask becomes the parent
container with rounded edges. Make sure alpha works properly with it,
eg. on the corners where alpha should be decreased / multiplied."
**What exists.** `Mask` in `shader.wgsl`/`data.rs` is two `UiSpan`s and
a `move_idx`; `fs_main` resolves it and does `color *= 0.0` outside the
rectangle -- a hard cut on a pixel boundary. `Masked` (`widget/mask.rs`)
sets the painter's mask to its own region. Separately, `draw_rounded_rect`
already produces an anti-aliased rounded edge from
`distance_from_rect(pos, center, corner, radius)` with a half-pixel
`smoothstep`, and the border variant multiplies a second coverage in.
**Design** (revised the same day on Iris's two corrections: hit-testing
applies the shape too, and a mask should reference a primitive rather
than carry a copy of its shape).
1. **A mask is a reference to a primitive already drawn, plus how to
use it.** `Mask { kind, idx, flags, parent }`: the primitive's
binding (`RECT`, `TEXTURE`, `GLYPH`) and slot, flags (today one:
*alpha only* -- take the primitive's coverage and ignore its colour,
which is the default and the only mode until a need for another
appears), and the enclosing mask's slot for nesting. The fragment
stage evaluates the referenced primitive *at the masked pixel* --
for a `Rect`, the same `draw_rounded_rect` coverage from the same
SDF; for a texture or glyph, the sampled alpha -- and does
`color.a *= coverage`. Nothing about the shape is copied: a rounded
container's corner and its children's clipped corner are the same
primitive's arithmetic, and a texture mask (an alpha image as the
clip) works with no new shader path.
What this needs from the data layout: evaluating a primitive at an
arbitrary pixel means its placement (its spans and `move_idx`, today
vertex attributes) has to be readable from a storage buffer in the
fragment stage. If it is not already there, put it there once, for
every primitive, rather than keeping a second copy for masks -- the
vertex stage can read the same buffer. Textures: the shader binds one
image at a time (see `masks_layout`'s comment on why an image's own
bind group must not name the masks buffer), so a texture mask is
limited to what the fragment can sample without a bind-group switch:
the atlas, and the primitive's own bound image when the masked
primitive is drawn in the same image's batch. Say so at the flag.
2. **Nested masks chain and multiply, like moves.** `parent` walks up
the chain, bounded like `resolve_move` (`MOVE_CHAIN_LIMIT`'s sibling;
debug-assert on overflow and print the chain); coverages multiply,
so a pixel inside two feathered corners is dimmed by both, which is
what a compositor does and what "alpha should be multiplied" asks.
3. **`.masked()` points the mask at the current widget's own
primitives.** `Masked` stops describing a region: it records which
primitive(s) the wrapping widget drew this frame (the painter knows
-- it just allocated the slots) and sets the mask to reference them.
So a rounded `Rect` widget's `.masked()` clips its children to
itself by pointing at the rect it already draws; an image widget's
`.masked()` clips to its alpha. No radius or shape argument exists to
fall out of sync. When a widget draws more than one primitive (a
bordered rect is one primitive; a card with a stripe is two), the
mask references the *first* and the doc says so; a widget that wants
another names it.
4. **Hit-testing applies the shape.** A press is inside a masked
subtree only if the mask's coverage at that point is above one half.
For a `Rect` that is the same rounded-rect SDF evaluated on the CPU
-- one function in the shared crate, with the WGSL a transliteration
of it and a test that compares the two at a grid of points
(`headless` renders to a buffer and reads back, or the Rust version
is checked against the values the shader produced once and recorded).
For a texture, the CPU needs the alpha: keep the alpha channel of an
image used as a mask readable on the CPU (it was uploaded from CPU
memory; keeping the alpha plane is a quarter of the image), and read
it at the point. A masked corner that cannot be tapped and a masked
corner that is not drawn are then the same corner.
**Rejected.** A stencil buffer (a second pass per mask level and no
anti-aliasing); the scissor rectangle (rectangles only, no alpha);
rendering a masked subtree to an offscreen texture and compositing
(a texture allocation per mask, every frame it scrolls, on the phone).
**Pass conditions.** A headless test draws a rounded container with a
masked child that overhangs all four sides and asserts the child's
coverage at a corner pixel equals the container's own coverage there
(same primitive evaluated, so exactly equal, not approximately); a
nested-mask test asserts the product at a pixel inside both feathers; a
texture-mask test clips a rect to an alpha image and asserts a
transparent texel masks fully; a hit-test asserts a press in a
container's clipped corner misses and one just inside the curve hits,
and that the CPU SDF and the shader agree at a grid of points; a
`run-headless.sh --phone` screenshot of a scrolled code block shows
rounded corners with no square pixels poking out at the top and bottom
of the scrolled content. Record the commands in RUST.md when it lands.
+391
View File
@@ -43,6 +43,397 @@ gated on her verdict**, so this pass works the P0 defects and the pure
prerequisites in this order. Each item is ticked here by the agent that
closes it.
### Queue, 2026-09-07 (orchestrator)
In order; two builders at a time. Each is ticked here by the agent that
closes it.
- [x] Test rig, layers 1 and 2 ("Three test layers" below), landed 2026-09-07.
- [ ] Fling parity with Compose, and the phone's keyboard push-up, with
insets shown in the diagnostics overlay. Running, in a worktree.
- [ ] Rows at the transcript's top edge: culled too early in one state,
drawn through the header in the other (docs/IRIS_TODO.md, 2026-09-07).
First after the rig lands, using its layer-1 harness.
- [ ] Phone logging through Dev Updater (Iris has no logcat; see
docs/TODO.md and the memory note): research how Dev Updater shows an
app's runtime log, design the smallest route (the app keeps its own
recent log; a debug button copies it; Dev Updater reads it), write
the decision in docs/DECISIONS.md, build it.
- [ ] Masks with a shape -- docs/LAYOUT.md "Masks with a shape (decided
2026-09-07)". A mask references a primitive already drawn
(rect SDF, texture or glyph alpha), chained and multiplied; `.masked()`
points at the widget's own primitives; hit-testing applies the shape.
- [ ] Compose app: the `Reversed range` crash in `ToolInput.highlighted`
(docs/TODO.md). Main branch, not rustify.
### Desktop and phone share the code (Iris, 2026-09-07)
Iris plans to develop a desktop app as well, and asked that most code be
sharable between desktop and phone. The workspace already has that
shape -- `iris`, `client-core`, `transcript-ui` and `tabs-ui` are
platform-free, and `android-app`/`desktop-app` are the entry points --
so the rule is about keeping it: **a platform crate holds only what the
platform forces.** Today that is JNI, the IME and insets bridge, the
surface lifecycle and the bench JNI on Android; winit, argv and the
config file on the desktop. **What differs is the screen layout**, since a phone
screen with a finger and a desktop screen with a mouse want different
arrangements -- a session list beside the transcript rather than a
screen behind it, hover states, keyboard shortcuts. **What does not
differ is everything a layout is built from**: the widgets (a tap
button, a text field, a list, a card, a tool-call row), gestures,
folding, paging, selection, and the styling -- colours, spacing, type,
the surface ladder -- which is the exact same code on both, never a
desktop palette beside a phone one. Those are written once in a shared
crate, with a platform trait underneath when a behaviour genuinely
differs (`FocusHost`, `OpenUrl`, and the insets/`ime_visible`
feed are the existing examples). Two checks before finishing a change
under `iris/`: does `desktop-app` still build and run with it, and is
any UI logic newly in `android-app` that a desktop would also need?
The bench client (`android-app/src/bench_client.rs`, ~1000 lines) is
the first thing to look at moving, since a desktop bench on the same
fixture is layer 2 of the test rig below.
### Three test layers, cheapest first (decided 2026-09-07; layers 1 and 2 built the same day)
Iris's suggestion, adopted and layered: test at the cheapest layer that
can answer the question, and go up only when it cannot. The emulator
costs minutes a cycle; the desktop window seconds; the headless harness
runs inside `cargo test`.
1. **Headless, in-process, no compositor and no GPU -- the default.**
`iris::harness` (`iris/src/harness.rs`), plus the fixture crate it
opens. `Harness::new(size, density)` builds an `Rsc`, a
`UiRenderState` and a state whose `FocusHost`/`OpenUrl` *record* what
the platform was asked for; `frame(t_ms)`/`frames_until(..)` run
frames on a clock the test owns, and `replay(&TouchScript)` feeds a
recorded gesture one sample at a time exactly as
`IrisViewPeer::on_touch_event` replays Android's historical samples.
The recordings are plain `t_ms action x y` files under
`iris/transcript-fixture/touch/`, and `flick-120hz.touch` is the
phone's own shape: DOWN, four samples 4ms apart, UP, 20ms in total.
cd iris && cargo test -p transcript-fixture
runs in about a second and asserts (a) the flick releases with a real
velocity (`List::fling_velocity`, which only `Released(Some(v))`
fills), (b) the list travels and settles inside the AOSP spline's own
`FlingCalculator::duration`, (c) a tap moves nothing and opens no
link, (d) a long-press-then-drag leaves selected text and does not
pan, and (e) the composer clears a simulated 1000px IME inset
(`Composer::set_bottom_inset`). Each was confirmed to fail without
its subject rather than assumed: dropping `animate(id)` from
`Selection::drag` -- the phone's own "fling does nothing" defect --
and starting the fling curve at the wall clock each fail only the
flick test; flinging on `Tapped` fails only the tap test; a 5s
`LONG_PRESS` fails only the selection test; a `set_bottom_inset` that
ignores its argument fails only the composer test.
**What still cannot be answered below layer 3**: nothing renders
here, so anything about pixels -- glyph rasterisation, the atlas,
stale or duplicated primitives, colour, the surface lifecycle, the
renderer rebuild -- is invisible to layer 1 and only *looked at* in
layer 2. Frame *times* are not measurable at either: layer 1 does no
GPU work at all and layer 2 runs a debug build on this VM's virtio
GPU, so a number from either is not the phone's. Anything JNI (the
IME, real insets, the clipboard, battery) is layer 3 by construction:
layer 1 records that the platform was asked and layer 2 has no
Android platform to ask.
2. **A phone-shaped desktop window under headless sway -- for looking.**
cd iris && ./run-headless.sh phone --phone --shot /tmp/p.png -- -p transcript-fixture
About 15 seconds warm. `--phone` sets the private sway output to
1080x2424@120Hz and exports `IRIS_SCALE=2.55`, which reaches iris the
way `DisplayMetrics.density` does on Android
(`iris::default::content_scale`) -- the desktop backend now lays out
in physical pixels with a density instead of dividing into a separate
logical space, so both platforms run one path. `transcript-fixture`'s
`phone` example opens the same screen from the same bytes as layer 1
and the Android bench.
A gesture on screen uses the *same recordings*:
./run-headless.sh phone --phone --replay transcript-fixture/touch/flick-120hz.touch \
--shot /tmp/p.png -- -p transcript-fixture
writes `/tmp/p-before.png` and `/tmp/p.png` either side of the flick;
looked at 2026-09-07, the list moved back about seven turns of the
fixture and settled.
**`swaymsg seat - cursor` cannot drive it, and that cost an hour.**
This compositor runs the headless backend with no input devices
(`WLR_LIBINPUT_NO_DEVICES=1`, `LIBSEAT_BACKEND=noop`): the cursor
commands all report `success` and nothing whatever reaches the
client, with `swaymsg -t get_seats` showing `capabilities: 0` as the
only sign. wlroots 0.19 dropped `WLR_HEADLESS_INPUTS`, and ydotool's
uinput device would be ignored by a compositor that is not reading
libinput. `iris/rig-input`'s `replay-touch` uses the
**virtual-pointer protocol** instead, which is a client protocol and
needs neither devices nor root, and it parses `iris::harness`'s own
`TouchScript`. Two traps inside it, both found by printing winit's
events: a button sent in the same frame as the motion that first puts
the pointer over the window is dropped (the client sees the enter,
the moves and the *release*, never the press), so the pointer is
positioned and left to settle 200ms first; and a leftover window from
an earlier manual run **tiles beside the new one**, halving the width
and producing a screenshot that looks exactly like a duplicated-
primitive rendering bug -- `swaymsg -t get_tree` and `pgrep -af
examples/phone` are the check.
3. **The Android emulator -- platform plumbing and the final pass.**
JNI, IME, insets, surface lifecycle, the renderer rebuild, and one
verification run before a build goes to the phone. Not for iterating
on layout.
### The 2026-09-07 phone report on `ed04d4c`: the fling was linear, and the keyboard is a targetSdk
Iris's three lines on the `ed04d4c` build (Pixel 9 Pro XL, GrapheneOS
Android 17, Mali-G715, `content_scale` 2.55, 120Hz): item 4 (the resume
glyph corruption) **is fixed**, confirmed on the phone; "flinging now does
technically do something, but it seems to just be linear velocity with an
abrupt stop"; and "similarly, the keyboard raising up does not push things
upwards."
**The fling was exactly, arithmetically linear.** Not approximately.
`android_fling_spline::distance_fraction(t)` returned `t` for every `t`,
which is a constant-speed slide for the full `duration()` and then a stop
at full distance -- Iris's sentence, read straight off the code. Two
transposed halves of one AOSP loop did it, and they compounded:
1. AOSP's `SplineOverScroller` static initialiser **solves** the bisection
on the `P1`/`P2` curve and **samples** `SPLINE_POSITION[i]` from the
tension curve (`coef * ((1-x) * START_TENSION + x) + x³`); the second
half of the loop does the reverse to build `SPLINE_TIME`. iris had both
halves solving on the tension curve and sampling `P1`/`P2` -- so its two
loops were the *same computation*, and `SPLINE_POSITION == SPLINE_TIME`
element for element.
2. The lookup then bracketed `t` between **`SPLINE_TIME` entries** and
interpolated `SPLINE_POSITION`. AOSP brackets between the even time
steps `index / N` and `(index + 1) / N` (`SPLINE_TIME` is used only by
`adjustDuration`, which iris has no analogue of). With the two arrays
identical, `d_inf + (d_sup - d_inf)(t - t_inf)/(t_sup - t_inf)` reduces
to `t_inf + (t - t_inf)` = `t`.
Every test the calculator had compared it with itself -- monotonic, signed,
integrates to the closed form, per-tick deltas non-increasing -- and all of
them pass on a linear curve. That is the shape to distrust: `<=` is not
deceleration.
**Sources, read rather than remembered.** `frameworks/base`
`core/java/android/widget/OverScroller.java` from
`android.googlesource.com` (`?format=TEXT`, base64), and
`androidx.compose.animation:animation:1.12.0`'s `SplineBasedDecay.kt` and
`FlingCalculator.kt` out of the `-sources.jar` on
`dl.google.com/dl/android/maven2` (there is no androidx checkout here and
`cs.android.com` is JS-only; `androidx.tech` is now a parked domain serving
an unrelated site). The two agree line for line, which is why iris ports
one curve rather than two. The formulas, for the record:
P1 = START_TENSION * INFLEXION = 0.5 * 0.35
P2 = 1 - END_TENSION * (1 - INFLEXION) = 1 - 1 * 0.65
SPLINE_POSITION[i]: solve coef*((1-x)P1 + xP2) + x³ = i/100 for x,
then take coef*((1-x)ST + x) + x³
physical_coeff = 9.80665 * 39.37 * density * 160 * 0.84
l = ln(0.35 * |v| / (0.015 * physical_coeff))
distance = 0.015 * physical_coeff * exp(rate/(rate-1) * l)
duration = exp(l / (rate - 1)), rate = ln(0.78)/ln(0.9)
at time t: index = floor(100 * t/duration)
vcoef = (POS[index+1] - POS[index]) * 100
position = distance * (POS[index] + (t/duration - index/100) * vcoef)
speed = vcoef * distance / duration
**What changed.** `iris/src/sense.rs`'s `android_fling_spline` builds one
table, indexed by even time steps, and `sample(t)` answers AOSP's
`distanceCoef`/`velocityCoef` pair; `FlingCalculator` gained `velocity_at`
beside `position_at`. `iris/benches/fling_spline_reference.py` is an
independent hand transcription of both sources and prints the numbers the
tests assert on -- checked in because "numbers computed by the code under
test" is exactly how the last three tests passed through this defect.
Tests: `the_spline_matches_aosps_own_table` (the curve is not the
identity: 27.4% of the distance at a tenth of the time, 85.8% at half),
`a_flick_decelerates_the_way_aosp_says_it_does` (11064px/s at density
2.55: 6334px over 1.636s, speed 9202 -> 4733 -> 2650 -> 951px/s), and
`tick_fling_applies_shrinking_incremental_deltas` strengthened from
"non-increasing" to "the last delta is under 80% of the first". **Negative
control run**: with `sample` forced back to returning `t`, exactly those
three fail and the other eleven pass.
**Emulator evidence (API 36 AVD, debug, `force-gles`, 2026-09-07).** A
`ui-trace` swipe of 900px in 120ms releases at `v=3750` and the new
`iris fling tick:` debug line reports, frame by frame,
`speed=-3746 -> -2624 -> -1834 -> -1144 -> -752 -> -449 -> -243 -> -83px/s`
over 32 frames ending at `t=0.664s`, with the per-frame `dy` falling
`94 -> 34 -> 20 -> 13 -> 8 -> 4.4px`. **The end**: the same flick in the
other direction, from a list already at its newest end, produces exactly
one tick and stops -- no overshoot. **A finger during a fling**: swipe,
then a tap 200ms later, ends the fling at `t=0.248s` and 11 ticks instead
of running its full 0.55s.
**The keyboard: the bench app targeted SDK 34.** `app/build.gradle` said
`targetSdk = 34` while `compileSdk` was 37 and the Compose app in `app/`
targets 37 -- and that Compose app's keyboard *does* push its transcript up
on Iris's phone. Below target 35 a window keeps the legacy behaviour, where
`adjustResize` shrinks the window for the IME so `getInsets(ime()).bottom`
measures the overlap with an already-shrunk window and is zero;
`MainActivity`'s `setDecorFitsSystemWindows(false)` opts out of that and
still takes on the API 36 emulator here, which is why every test run showed
the push-up working. It is deprecated as of API 35 and Android 17 is where
it appears no longer to. Fixed by `targetSdk = 37`, where edge-to-edge is
not opt-in.
That is a *reading*, not a measurement -- no Android 17 device is reachable
from here -- so the second half of the change is making the phone able to
answer it. `MainActivity` now also registers a
`WindowInsetsAnimation.Callback` (`DISPATCH_MODE_CONTINUE_ON_SUBTREE`,
`onProgress` forwarding, `onEnd` re-reading `getRootWindowInsets` so an
interrupted animation cannot leave a frozen value), which delivers the IME
height on devices where only the animation path carries it and, on every
device, makes the push-up *animate* with the keyboard: the emulator log now
shows `ime_bottom=509, 663, 833, 881, 883` instead of one jump to 883. And
`insets::Shared::updates` counts every dispatch, which
`AndroidUiState::insets_report()` puts in the **Diagnostics pane**:
insets: dispatches=27 left=0 top=142 right=0 bottom=63 ime_bottom=0 ime_visible=false
Screenshot-verified on the emulator. Iris has no logcat, and "the listener
never fired" and "it fired with a zero height" look identical on screen;
`dispatches=0` prints its own sentence instead of the numbers, since those
would be defaults rather than measurements.
**What to look at on the next build.** Open the keyboard, press
`Diagnostics`, screenshot the `insets:` line. `ime_bottom` in the hundreds
with the composer risen: fixed. `dispatches` climbing but `ime_bottom=0`:
the targetSdk reading was wrong and the window is still being resized.
`dispatches=0`: the listener is not being called at all, which is a
different fault from either. For the fling, a flick should now visibly
slow before it stops rather than running out at speed.
### The 22:16 phone report, worked 2026-09-06/07
Iris's four items are listed in docs/IRIS_TODO.md's "From the phone,
2026-09-06, 22:16"; this is what was found and what was run. Item 4 was
committed on its own (`ba2afba`); items 1-3 and everything below landed
together after the emulator evidence.
**Item 4, text cooked after a resume -- root cause, fixed in `ba2afba`.**
Not "the cached text primitives are never redrawn": they *are*.
`IrisViewPeer::surface_changed` (`iris/src/android/view.rs`) calls
`render.resize(...)` on every surface event including the new-renderer
branch, which sets `UiRenderState::resized`, which makes the next
`update` take `redraw_all` rather than `redraw_updates` -- so after a
resume every widget's `draw` runs again. The stale coordinates come from
one cache further in: `TextView::render` (`iris/src/widget/text/mod.rs`)
returns its cached `RenderedText` whenever the wrap width, buffer and
attrs are unchanged, so `TextData::place` is never reached, no glyph is
re-rasterised into the fresh atlas, and the *previous* atlas's
`uv_min`/`uv_max`/`layer` go straight back to the GPU. Text whose content
changed after the resume -- the diagnostics pane Iris tapped -- re-shapes
and is therefore perfect, which is exactly the split in her screenshot.
The fix is one mechanism: a `generation` counter on `GlyphAtlas`, bumped
by `clear`, recorded on each `RenderedText`, added to `TextView::render`'s
cache key, with a `debug_assert_eq!` in `Painter::glyphs` that a submitted
quad's generation is the live one. Test
`clearing_the_atlas_re_renders_cached_text_instead_of_reusing_it`, run and
passing; **still needs phone-side confirmation**, since no emulator here
has a Vulkan adapter and the GLES path may not destroy the surface at all.
**Item 2, the keyboard would not reopen -- fixed and confirmed.**
`attr.rs`'s `on_press`, already-focused branch, now calls `focus_gained`
on a tap that stays inside `DRAG_SLOP`, which is what Android's own
`EditText` does (`showSoftInput` is idempotent). Emulator, 2026-09-07:
first tap `mInputShown=true`; back gesture; second tap `mInputShown=true`
and the composer rises again. **Negative control run**: with that one call
removed and nothing else changed, the second tap leaves
`mInputShown=false` -- Iris's report exactly. **The case the fix had no
reason to touch**, also run: a horizontal swipe across the focused
composer and a vertical swipe out of it both leave `mInputShown=false`, so
her earlier "if I swipe over the input bar it brings up the keyboard" has
not come back.
**Item 3, the IME height -- fixed and confirmed.** `MainActivity.java`
sends `getInsets(ime()).bottom` *and* `isVisible(ime())` as two separate
values (the height used to be sent as the boolean 1/0, which is why
nothing could pad by it); `Insets`/`WindowInsets` carry both, and
`bench_client.rs` reads the boolean for its state machine and the height
for `Composer::set_bottom_inset`. The list follows for free -- it is
`.height(rest(1))` in the same `Span` as the composer bar, so the bar
growing shrinks the list. Emulator, 2026-09-07:
`iris insets: ... bottom=883 ime_bottom=883 ime_visible=true`, and the
composer's box moves from `31,2277..1048,2329` to `31,1457..1048,1509` --
820px, which is 883 less the 63px navigation bar it was already clearing.
Screenshot checked: the transcript ends above the composer, which sits on
the keyboard.
**Item 1, the fling -- two more defects behind the first, all three
fixed here; the phone is what settles it.** The velocity half is what the
report predicted: `on_touch_event` read only each `MotionEvent`'s final
position, so a batched 120Hz flick fed the tracker one sample and
`velocity()` answered 0.0. It now replays every historical sample
(`getHistoricalAxisValue`/`getHistoricalEventTimeNanos`) through the
sensor pass, `CursorState` carries the sample's *own* time (so a replay
loop's speed cannot become the measured velocity), and the press itself is
a sample, as Android's own `VelocityTracker` does with `ACTION_DOWN`.
`iris drag release: samples=… span=…ms v=… outcome=…` logs the decision.
Then the emulator showed the two the report could not have known about:
1. **Nothing ever advanced a fling.** `List::fling` sets the state;
`tick_fling` moves it; and `tick_fling`'s only caller in the workspace
was `bench_client.rs`'s own fling phase, which drives it in a loop.
So the benchmark flung and a finger never did -- and the earlier
"verified flinging on the emulator with `render()` counts" was that
benchmark measuring itself. Measured before the fix: frames stop on
the same millisecond as `iris drag release`. iris now has one
animation mechanism -- `Widget::tick(now) -> bool`, ids registered
with `UiData::animate`, drained each frame by
`UiData::tick_animations`, which both backends call before the draw
and re-request a frame from while it answers true. `List::tick` is
`tick_fling`; `Selection::drag` registers on `Released(Some(v))`.
Test: `a_registered_fling_is_driven_by_tick_animations_and_then_
unregisters`, confirmed to fail without the registration.
2. **The fling lasted 45 seconds.** Visible only once flings animated at
all. Two causes, both in `FlingCalculator`: `List::fling` hardcoded
`FlingCalculator::new(1.0)` while the velocity it is fed is in
physical pixels (`List` reads `painter.density()` now), and
`physical_coefficient` multiplied by `FLING_FRICTION` (0.015) where
AOSP multiplies by its own tuning constant **0.84** -- a coefficient
56x too small, put through `exp(ln(…)/(rate-1))`. Every existing test
compared the calculator with itself (monotonic, signed, integrates to
the closed form) and so passed throughout;
`a_flick_lasts_what_aosps_own_formula_says_it_does` pins the absolute
numbers against AOSP's formula worked by hand. Emulator after both:
release at `v=11064`, frames for **1.62s**, then none -- against
AOSP's own 1.586s for that velocity at density 2.75.
**What Iris should look for on the phone**: `adb logcat | grep "iris
drag release"`. `samples=1` or `span=0.0ms` means the historical
replay is not reaching the tracker on her device; a sensible
`samples`/`span` with `v=` in the thousands and `outcome=Released(Some
(…))` means the gesture is measured correctly and anything still wrong
is downstream of it. `outcome=Tapped` means the flick never crossed
the slop.
**Two things found on the way, both pre-existing at `ba2afba`.**
- **`MOVE_CHAIN_LIMIT` was 16 and the composer's chain is 17.** Tapping
the composer in any debug build aborted on `resolve_move_chain`'s
assert; in a release build (what Iris runs) the walk simply stops
summing, on the CPU *and* in shader.wgsl, so a widget past the bound
draws and hit-tests short by whatever the outer slots held, with
nothing on screen to say so. Both constants are 64 now, and the assert
prints the chain (`64(0, 0) -> 63(0, 0) -> … -> 0(0, 0)`) so a cycle
and an honestly-deep tree can be told apart -- which is how this one
was: 17 distinct slots.
- **`minSdk` is 29**, up from 26. `getEventTimeNanos` and
`getHistoricalEventTimeNanos` are API 29, and a missing JNI method
there is a hard crash on the first touch rather than a degraded fling.
`build-apk.sh`'s `cargo ndk -P` matches.
Still open and **pre-existing**: the composer bar's grey background is not
drawn on the `transcript-screen bench` build, so the transcript shows
through where the bar should be (`Stack{StackSize::Child(1)}` is the thing
to look at). Unchanged by any of the above.
### Task A, closed 2026-09-06: the composer scrolls on a finger
`iris/transcript-ui/src/composer.rs` is `field.scrollable().masked()` now.
+11
View File
@@ -33,3 +33,14 @@ one in place when it turns out to need a decision.
that would work today, for Claude sessions, and it is the option that was
not chosen.
## From Iris's phone log export, 2026-09-07 (Compose app)
- [ ] **Crash on 2026-09-03 11:40, `IllegalArgumentException: Reversed
range is not supported`** at `ToolInput.kt:200` (`highlighted`, inside
`ToolInputView` -> `RawBlock` -> `ToolCard`). An `AnnotatedString`
range was built with end before start while highlighting a tool
input. Found in the per-package system log she exported; the tool
input that triggered it is not in the log. Reproduce by fuzzing
`highlighted` with inputs whose token boundaries collapse, and guard
the range construction.
+37 -16
View File
@@ -965,9 +965,9 @@ dependencies = [
[[package]]
name = "dlib"
version = "0.5.2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
dependencies = [
"libloading",
]
@@ -2875,9 +2875,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.38.4"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
"memchr",
]
@@ -3058,6 +3058,15 @@ version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce"
[[package]]
name = "rig-input"
version = "0.1.0"
dependencies = [
"iris",
"wayland-client",
"wayland-protocols-wlr",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -3646,6 +3655,18 @@ dependencies = [
"once_cell",
]
[[package]]
name = "transcript-fixture"
version = "0.1.0"
dependencies = [
"client-core",
"event-model",
"iris",
"serde_json",
"transcript-ui",
"winit",
]
[[package]]
name = "transcript-ui"
version = "0.1.0"
@@ -3894,9 +3915,9 @@ dependencies = [
[[package]]
name = "wayland-backend"
version = "0.3.12"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9"
checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078"
dependencies = [
"cc",
"downcast-rs",
@@ -3908,9 +3929,9 @@ dependencies = [
[[package]]
name = "wayland-client"
version = "0.31.12"
version = "0.31.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec"
checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
dependencies = [
"bitflags 2.10.0",
"rustix 1.1.3",
@@ -3942,9 +3963,9 @@ dependencies = [
[[package]]
name = "wayland-protocols"
version = "0.32.10"
version = "0.32.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3"
checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
dependencies = [
"bitflags 2.10.0",
"wayland-backend",
@@ -3967,9 +3988,9 @@ dependencies = [
[[package]]
name = "wayland-protocols-wlr"
version = "0.3.10"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3"
checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
dependencies = [
"bitflags 2.10.0",
"wayland-backend",
@@ -3980,9 +4001,9 @@ dependencies = [
[[package]]
name = "wayland-scanner"
version = "0.31.8"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3"
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [
"proc-macro2",
"quick-xml",
@@ -3991,9 +4012,9 @@ dependencies = [
[[package]]
name = "wayland-sys"
version = "0.31.8"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd"
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
dependencies = [
"dlib",
"log",
+10 -2
View File
@@ -89,13 +89,21 @@ name = "message_list"
harness = false
[workspace]
members = ["core", "macro", "tabs-ui", "transcript-ui", "desktop-app"]
members = [
"core",
"macro",
"tabs-ui",
"transcript-ui",
"transcript-fixture",
"rig-input",
"desktop-app",
]
# android-app pulls in android-view, which needs the NDK sysroot to link
# -- excluded so `cargo build --workspace --all-targets` on the host stays
# buildable. Cross-compile it from its own directory (its own single-crate
# workspace, since it has no `[workspace]` table of its own and this
# exclusion stops it inheriting this one): `cd android-app && cargo ndk
# -t x86_64 -P 26 build`.
# -t x86_64 -P 29 build`.
exclude = ["android-app"]
[workspace.package]
+12
View File
@@ -1774,6 +1774,7 @@ dependencies = [
"serde_json",
"tabs-ui",
"tokio",
"transcript-fixture",
"transcript-ui",
]
@@ -3864,6 +3865,17 @@ dependencies = [
"once_cell",
]
[[package]]
name = "transcript-fixture"
version = "0.1.0"
dependencies = [
"client-core",
"event-model",
"iris",
"serde_json",
"transcript-ui",
]
[[package]]
name = "transcript-ui"
version = "0.1.0"
+5 -1
View File
@@ -29,6 +29,10 @@ log = "0.4.28"
# which Cargo's `unused_dependencies` lint (on by default) correctly flags.
tabs-ui = { path = "../tabs-ui", optional = true }
transcript-ui = { path = "../transcript-ui", optional = true }
# P0's bench build only: the fixture and the folded screen both bench
# clients open, shared with the headless harness and the desktop window
# (docs/RUST.md's "Three test layers").
transcript-fixture = { path = "../transcript-fixture", optional = true }
client-core = { path = "../../client-core", optional = true }
event-model = { path = "../../event-model", optional = true }
serde_json = { version = "1", features = ["float_roundtrip"], optional = true }
@@ -65,7 +69,7 @@ force-gles = ["iris/force-gles"]
# `event-model` -- `lib.rs`'s `ActiveClient` selection gives this feature
# priority over `transcript-screen`'s own `TranscriptClient` when both are
# listed, which is how this crate's build command names both explicitly.
bench = ["transcript-screen", "dep:libc", "dep:tokio"]
bench = ["transcript-screen", "dep:transcript-fixture", "dep:libc", "dep:tokio"]
[profile.release]
panic = "abort"
+31 -2
View File
@@ -13,8 +13,37 @@ android {
defaultConfig {
applicationId = "dev.iris.android.demo"
minSdk = 26
targetSdk = 34
// 29, not 26: `iris::android::view`'s touch handler dates each
// sample with `MotionEvent.getEventTimeNanos` and
// `getHistoricalEventTimeNanos`, both API 29, and a missing JNI
// method there is a hard crash on the first touch rather than a
// degraded fling. Raised deliberately rather than guarded at
// runtime: nothing this app is built for runs below 29, and an
// untested fallback path is its own defect. `build-apk.sh`'s
// `cargo ndk -P` is kept at the same number.
minSdk = 29
// 37, matching `compileSdk` and the Compose app in `app/` -- which
// is the one part of this that is measured rather than reasoned:
// that app targets 37 and its keyboard does push the transcript up
// on Iris's phone, and this one targeted 34 and does not
// (2026-09-07). The emulator here is API 36 and the push-up works
// there at either target, so the target is the only difference the
// two devices do not share.
//
// The mechanism, stated as the reading it is: below targetSdk 35
// a window keeps the legacy behaviour, where `adjustResize` shrinks
// the window for the IME and `getInsets(ime()).bottom` therefore
// measures the overlap with an already-shrunk window -- zero, with
// nothing left to push up. `MainActivity`'s
// `setDecorFitsSystemWindows(false)` opts out of that, and on API
// 36 it still takes; Android 16 deprecated it and Android 17 is
// where it appears not to. At 35+ edge-to-edge is not opt-in, so
// the app is handed the real overlap without relying on a
// deprecated call. If the phone still reports `ime_bottom=0` with
// a nonzero `dispatches` in the Diagnostics pane, this reading was
// wrong and the `WindowInsetsAnimation.Callback` in
// `MainActivity` is the other half to look at.
targetSdk = 37
versionCode = 1
versionName = "1.0"
}
@@ -27,7 +27,7 @@ public final class IrisView extends RustView {
protected native long newViewPeer(Context context);
native void applyWindowInsetsNative(
long peer, int left, int top, int right, int bottom, int imeBottom);
long peer, int left, int top, int right, int bottom, int imeBottom, int imeVisible);
native void unregisterInsetsNative(long peer);
@@ -35,8 +35,9 @@ public final class IrisView extends RustView {
super(context);
}
void applyWindowInsets(int left, int top, int right, int bottom, int imeBottom) {
applyWindowInsetsNative(mViewPeer, left, top, right, bottom, imeBottom);
void applyWindowInsets(
int left, int top, int right, int bottom, int imeBottom, int imeVisible) {
applyWindowInsetsNative(mViewPeer, left, top, right, bottom, imeBottom, imeVisible);
}
@Override
@@ -4,7 +4,9 @@ import android.app.Activity;
import android.os.Build;
import android.os.Bundle;
import android.view.WindowInsets;
import android.view.WindowInsetsAnimation;
import android.widget.FrameLayout;
import java.util.List;
/**
* The android-view backend's demo activity (RUST.md's I2): one IrisView
@@ -50,36 +52,88 @@ public final class MainActivity extends Activity {
getWindow().setDecorFitsSystemWindows(false);
}
// **The keyboard's height arrives twice, over two different
// paths, and the phone needs the second one** (Iris, 2026-09-07:
// the emulator pushed the composer up and her Pixel did not).
// `setOnApplyWindowInsetsListener` is the platform's *settled*
// answer; `WindowInsetsAnimation.Callback` is the running one, and
// an IME that animates in delivers every intermediate height
// through the callback with the static dispatch arriving only at
// the ends -- on some devices only at `onEnd`. Registering both
// means neither device depends on the other's timing, and it is
// also what makes the push-up *animate* with the keyboard rather
// than jump when it lands.
//
// The two do not disagree, because they are the same call with the
// same numbers read out of whichever `WindowInsets` is current.
// `DISPATCH_MODE_CONTINUE_ON_SUBTREE` so this view consuming
// nothing keeps the ordinary dispatch running underneath.
// `onEnd` re-reads the root's insets rather than trusting the last
// `onProgress`: an animation interrupted mid-flight never delivers
// its final frame, which is exactly the fault the Compose app hit
// (AGENTS.md, "the composer can get stuck floating above the
// bottom of the screen").
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
view.setWindowInsetsAnimationCallback(new WindowInsetsAnimation.Callback(
WindowInsetsAnimation.Callback.DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
@Override
public WindowInsets onProgress(
WindowInsets insets, List<WindowInsetsAnimation> running) {
sendInsets(view, insets);
return insets;
}
@Override
public void onEnd(WindowInsetsAnimation animation) {
WindowInsets settled = view.getRootWindowInsets();
if (settled != null) {
sendInsets(view, settled);
}
}
});
}
view.setOnApplyWindowInsetsListener((v, insets) -> {
int left = insets.getSystemWindowInsetLeft();
int top = insets.getSystemWindowInsetTop();
int right = insets.getSystemWindowInsetRight();
int bottom = insets.getSystemWindowInsetBottom();
// The manifest declares adjustResize (AGENTS.md: without it the
// keyboard pans the whole window instead of resizing it), and
// under adjustResize the window itself shrinks to make room for
// the keyboard -- which is exactly the condition under which
// WindowInsets.Type.ime()'s own *inset amount* reports zero: it
// measures how much of the window the keyboard overlaps, and
// resize already made that overlap zero by construction. That
// numeric inset is not a usable "is the keyboard open" signal
// here (found while root-causing why bench_client.rs's keyboard
// phase and auto-diagnostics never fired on the emulator despite
// the keyboard visibly opening -- RUST.md's P0 box). What does
// survive adjustResize is the boolean isVisible() answer, set
// from the platform's own start/end of the transition over a
// different path than the inset amount -- the same fact
// AGENTS.md's "Things that have bitten" already names for the
// Compose side's identical trap. Passed through as a 0/1 stand-
// in for the ime_bottom pixel amount, since nothing on the Rust
// side reads it as a real pixel value -- only `> 0.0`.
int imeBottom = 0;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
&& insets.isVisible(WindowInsets.Type.ime())) {
imeBottom = 1;
}
((IrisView) v).applyWindowInsets(left, top, right, bottom, imeBottom);
sendInsets((IrisView) v, insets);
return insets;
});
}
/** Read one `WindowInsets` and hand it to the Rust side. The only
* place that reads these fields, so the static dispatch and the
* animation callback above cannot come to report different things. */
private static void sendInsets(IrisView view, WindowInsets insets) {
int left = insets.getSystemWindowInsetLeft();
int top = insets.getSystemWindowInsetTop();
int right = insets.getSystemWindowInsetRight();
int bottom = insets.getSystemWindowInsetBottom();
// **Two separate answers, because they are separate questions**
// (Iris's phone, 2026-09-06: "message box does not push up the
// scroll area"). `isVisible(ime())` says whether the keyboard is
// up; `getInsets(ime()).bottom` says how tall it is. An earlier
// pass sent the boolean *as* the height (0 or 1) because under
// plain `adjustResize` the window shrinks to make room and the ime
// inset therefore measures a zero overlap by construction -- true
// then, and no longer true now that this is an edge-to-edge window
// (`targetSdk` 35+, plus the `setDecorFitsSystemWindows` call
// above for the devices below that), which is exactly the case
// where the system stops resizing and hands the app the real
// overlap instead. Sending 1 for it left the Rust side padding the
// composer by one physical pixel, so the keyboard covered the bar
// and the transcript alike.
//
// The visibility is still sent in its own right rather than
// inferred from `height > 0`: the two disagree during the
// keyboard's slide-in and -out (visible, height still climbing),
// and "is the IME up" drives the bench's own state machine
// (`bench_client.rs`'s `ime_state`) where a half-open frame
// reading as "closed" is a miscount.
int imeBottom = 0;
int imeVisible = 0;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
imeBottom = insets.getInsets(WindowInsets.Type.ime()).bottom;
imeVisible = insets.isVisible(WindowInsets.Type.ime()) ? 1 : 0;
}
view.applyWindowInsets(left, top, right, bottom, imeBottom, imeVisible);
}
}
+2 -2
View File
@@ -59,9 +59,9 @@ export ANDROID_NDK_HOME="$NDK_DIR"
rm -rf app/src/main/jniLibs
echo "build-apk.sh: cargo ndk -t $ABI build ${BUILD_TYPE:+(${BUILD_TYPE})} --features \"$FEATURES\""
if [ "$BUILD_TYPE" = "release" ]; then
cargo ndk -t "$ABI" -P 26 -o app/src/main/jniLibs/ build --release --features "$FEATURES"
cargo ndk -t "$ABI" -P 29 -o app/src/main/jniLibs/ build --release --features "$FEATURES"
else
cargo ndk -t "$ABI" -P 26 -o app/src/main/jniLibs/ build --features "$FEATURES"
cargo ndk -t "$ABI" -P 29 -o app/src/main/jniLibs/ build --features "$FEATURES"
fi
GRADLE_TASK="assembleDebug"
+26 -54
View File
@@ -6,14 +6,11 @@
//!
//! **Reuses `transcript_client.rs`'s shape** (folded items, the same
//! `TranscriptScreen::apply` incremental update on every event) with the
//! network half replaced by the checked-in fixture, embedded with
//! `include_str!` -- `app/bench-fixture/assets/transcript.jsonl`,
//! 1,915,760 bytes, generated by `app/bench-fixture/generate.py` and never
//! a real transcript (that file's own README). The first 3,200 lines are
//! the opening backlog, folded once through
//! `client_core::transcript_fold::fold_page` exactly as a real
//! `/transcript` page would be (then a full `transcript_ui::build_tree`,
//! same as any first load); the remaining ~400 are the streaming tail,
//! network half replaced by the checked-in fixture. Reading that fixture
//! and folding it into a screen is **`transcript-fixture`'s** job, not
//! this file's -- the same crate the headless harness and the
//! phone-shaped desktop window open, so all three measure one screen
//! (AGENTS.md's sharing rule; moved out of here 2026-09-07). The tail is
//! replayed one at a time through `fold_event` -- the same fold path a
//! live SSE reply arrives on -- by the "Run benchmark" control below.
//! Streaming through `apply` rather than a full rebuild per event is what
@@ -22,7 +19,7 @@
use crate::bench_jni::PlatformHandle;
use android_view::jni::{JavaVM, objects::GlobalRef};
use client_core::transcript_fold::{TranscriptItem, fold_event, fold_page, group_tool_runs};
use client_core::transcript_fold::{TranscriptItem, fold_event};
use event_model::SeqEvent;
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
use iris::prelude::*;
@@ -30,13 +27,6 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
/// bench-fixture/README.md: the first `BACKLOG_COUNT` non-blank lines are
/// the opening window; the rest are the streaming tail. Kept in sync with
/// `BenchFixture.kt`'s identical constant by hand -- both read the same
/// checked-in file, so a mismatch would only mean the two apps' bench
/// builds open a different split of it, not a wrong-vs-right answer.
const BACKLOG_COUNT: usize = 3200;
/// RUST.md's "Benchmark v2" spec, written once so both apps' bench clients
/// implement the identical four phases -- see that box before changing any
/// constant here, since a mismatch would make the two reports stop
@@ -90,8 +80,6 @@ const KEYBOARD_WAIT_MS: u64 = 1_000;
/// when a later step in the same phase needs to read state back.
const ANIM_STEP_MS: u64 = 16;
const FIXTURE_JSONL: &str = include_str!("../../../app/bench-fixture/assets/transcript.jsonl");
/// How much of the screen a *filled* benchmark report may take before it
/// scrolls instead of growing -- roughly a third of a phone screen, the
/// share the pane used to reserve unconditionally. An empty report takes
@@ -158,31 +146,6 @@ impl HasAndroidUiState for BenchClient {
}
}
/// Parses the fixture once: `serde_json::Value`s for the backlog
/// (`fold_page` takes a page of raw wire JSON, same as a real
/// `/transcript` response) and folded `SeqEvent`s for the tail (`fold_event`
/// takes one live wire event at a time, same as a real SSE frame).
fn parse_fixture() -> (Vec<serde_json::Value>, Vec<SeqEvent>) {
let lines: Vec<&str> = FIXTURE_JSONL
.lines()
.filter(|line| !line.trim().is_empty())
.collect();
let mut backlog = Vec::with_capacity(BACKLOG_COUNT.min(lines.len()));
let mut stream_tail = Vec::new();
for (i, line) in lines.iter().enumerate() {
let value: serde_json::Value =
serde_json::from_str(line).expect("bench fixture is generated JSON, always valid");
if i < BACKLOG_COUNT {
backlog.push(value);
} else {
let event: SeqEvent = serde_json::from_value(value)
.expect("bench fixture event matches event-model's SeqEvent");
stream_tail.push(event);
}
}
(backlog, stream_tail)
}
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
wtext(message.to_string())
.color(Color::WHITE)
@@ -322,12 +285,12 @@ impl AndroidAppState for BenchClient {
last_top_pad: 0.0,
};
let (backlog, stream_tail) = parse_fixture();
client.stream_tail = stream_tail;
match fold_page(&backlog) {
Ok(items) => {
client.items = items;
client.rebuild_transcript(rsc);
match transcript_fixture::build_screen(rsc) {
Ok((opened, tree)) => {
client.items = opened.items;
client.stream_tail = opened.stream_tail;
(client.content)(rsc).set(tree);
client.screen = Some(opened.screen);
}
Err(message) => {
client.show_message(rsc, &format!("Couldn't fold the bench fixture: {message}"))
@@ -408,7 +371,12 @@ impl AndroidAppState for BenchClient {
.set_bottom_inset(rsc, insets.bottom.max(insets.ime_bottom));
}
let ime_visible = insets.ime_bottom > 0.0;
// The platform's own answer, not `ime_bottom > 0.0` -- see
// `iris::android::WindowInsets::ime_bottom`. The height is still
// climbing while the keyboard slides in, so a frame or two of a
// real opening reads as "closed" when the boolean is inferred from
// it, and `shown_events`/`hidden_events` below count transitions.
let ime_visible = insets.ime_visible;
let mut ime = self.ime_state.lock().unwrap();
if ime_visible && !ime.visible {
@@ -541,8 +509,7 @@ impl BenchClient {
}
fn rebuild_transcript(&mut self, rsc: &mut Rsc) {
let rows = group_tool_runs(&self.items);
let (screen, tree) = transcript_ui::build_tree(rsc, rows);
let (screen, tree) = transcript_ui::build_tree(rsc, transcript_fixture::rows(&self.items));
(self.content)(rsc).set(tree);
self.screen = Some(screen);
}
@@ -569,10 +536,15 @@ impl BenchClient {
Some(stats) => format!("{stats}"),
None => "no frames recorded yet".to_string(),
};
match &self.android_state().renderer {
let renderer = match &self.android_state().renderer {
Some(renderer) => renderer.diagnostics_report(&font, &frame_report),
None => "iris diagnostics: no renderer yet (no surface)".to_string(),
}
};
// The insets line goes in the pane, not just the log: Iris has no
// logcat on her phone, and "the keyboard does not push the
// composer up" cannot be told from "the listener never fired"
// without it (`AndroidUiState::insets_report`).
format!("{renderer}\n{}", self.android_state().insets_report())
}
/// The keyboard's own diagnostics capture -- see `on_insets_changed`'s
+152
View File
@@ -0,0 +1,152 @@
#!/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):
for velocity in (5000.0, 11064.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"
)
+11 -5
View File
@@ -80,11 +80,17 @@ var<storage> masks: array<Mask>;
@group(3) @binding(1)
var<storage> move_offsets: array<MoveOffset>;
// A move chain more than this deep means something else is wrong (an
// accidental cycle) -- kept in step with `MOVE_CHAIN_LIMIT` in
// render_state.rs, which walks the identical bound on the CPU side for
// hit-testing. Bounded so a malformed chain cannot hang the GPU.
const MOVE_CHAIN_LIMIT: u32 = 16u;
// The bound on the parent walk, kept in step with `MOVE_CHAIN_LIMIT` in
// render_state.rs, which walks the identical chain on the CPU side for
// hit-testing. Bounded so a malformed chain (a cyclic `parent`) cannot
// hang the GPU -- not a claim about how deep a real tree gets. It was 16
// and that was too small: the transcript screen's composer field sits 17
// slots below the root, measured 2026-09-07 on this checkout's emulator
// by tapping it (the CPU walk's own debug assert names the chain now).
// Past the bound both walks simply stop summing, so the widget draws and
// hit-tests short by whatever the outer slots held, with nothing on
// screen to say so.
const MOVE_CHAIN_LIMIT: u32 = 64u;
/// Sums the pixel delta along the parent chain starting at `idx`, shared by
/// the vertex stage (a primitive's own corners) and the fragment stage (its
+40
View File
@@ -24,6 +24,46 @@ pub struct UiData {
/// id (never reallocated), so a retained descendant's `parent` index
/// never goes stale -- see LAYOUT.md section 2.
pub move_offsets: TrackedArena<MoveOffset, u32>,
/// Every widget whose [`crate::Widget::tick`] should run before the
/// next frame -- today, a `List` coasting through a fling. Added by
/// [`Self::animate`] when the animation starts and removed by
/// [`Self::tick_animations`] the frame its `tick` answers `false`, so
/// a stopped animation costs nothing and a dropped widget cannot be
/// ticked (`get_dyn_mut` answers `None` and it is dropped the same
/// way).
animating: Vec<WidgetId>,
}
impl UiData {
/// Ask for `id`'s [`crate::Widget::tick`] to run every frame until it
/// says it is done. Idempotent -- registering an already-animating
/// widget is the ordinary case (a second fling before the first
/// settled) and must not tick it twice per frame.
pub fn animate(&mut self, id: WidgetId) {
if !self.animating.contains(&id) {
self.animating.push(id);
}
}
/// Tick every registered widget to `now`, drop the ones that finished,
/// and say whether any is still going -- which is a backend's cue to
/// ask for another frame. Called once per frame *before* the draw, so
/// what the frame draws is this instant's position rather than the
/// previous one's.
pub fn tick_animations(&mut self, now: std::time::Instant) -> bool {
// Taken out and put back rather than iterated in place: `tick`
// needs `&mut` on the widget arena this list lives beside, and a
// widget is free to register another one while ticking.
let mut registered = std::mem::take(&mut self.animating);
registered.retain(|&id| match self.widgets.get_dyn_mut(id) {
Some(widget) => widget.tick(now),
None => false,
});
for id in registered {
self.animate(id);
}
!self.animating.is_empty()
}
}
pub trait UiRsc {
+46 -9
View File
@@ -60,10 +60,17 @@ pub struct UiRenderState {
pub(super) shape_count: u64,
}
/// A move chain more than this deep would mean something else is wrong
/// (an accidental cycle) -- see `resolve_move` in shader.wgsl, which walks
/// the identical bound and must be kept in step with this constant.
pub const MOVE_CHAIN_LIMIT: usize = 16;
/// The bound on the parent walk -- see `resolve_move` in shader.wgsl,
/// which walks the identical chain and must be kept in step with this
/// constant. It exists so a cyclic `parent` link cannot hang either walk,
/// not as a statement about how deep a real tree gets: it was 16, and the
/// transcript screen's composer field turned out to sit **17** slots below
/// the root (measured 2026-09-07 on this checkout's emulator, by tapping
/// the composer in a debug build -- the assert in `resolve_move_chain`
/// prints the chain). A chain past the bound is not reported anywhere at
/// run time; both walks just stop summing, so the widget is drawn and hit
/// tested short by whatever the outer slots held.
pub const MOVE_CHAIN_LIMIT: usize = 64;
impl UiRenderState {
pub fn new() -> Self {
@@ -708,26 +715,56 @@ impl UiRenderState {
/// pixel delta along the parent chain starting at `slot`. Both walks
/// share `MOVE_CHAIN_LIMIT` as their bound so the two cannot disagree
/// about where the chain ends.
fn resolve_move_chain(&self, mut slot: MoveIdx, rsc: &dyn UiRsc) -> Vec2 {
fn resolve_move_chain(&self, slot: MoveIdx, rsc: &dyn UiRsc) -> Vec2 {
let offsets = &rsc.ui().move_offsets;
let mut delta = Vec2::ZERO;
let mut at = slot;
for i in 0..MOVE_CHAIN_LIMIT {
let entry = &offsets[slot.idx()];
let entry = &offsets[at.idx()];
delta.x += entry.delta[0];
delta.y += entry.delta[1];
if entry.parent == MoveOffset::NONE_PARENT {
return delta;
}
slot = Id::preset(entry.parent);
at = Id::preset(entry.parent);
// The chain itself, not just the fact that it was too long: a
// cycle and a tree genuinely nested deeper than the shader can
// follow are different faults with different fixes, and the
// slot numbers are the only thing that tells them apart.
debug_assert!(
i + 1 < MOVE_CHAIN_LIMIT,
"move offset chain exceeded MOVE_CHAIN_LIMIT; a widget's `parent` link is \
probably cyclic"
"move offset chain exceeded MOVE_CHAIN_LIMIT ({MOVE_CHAIN_LIMIT}): {chain} -- a \
repeated slot means a `parent` link is cyclic, all-distinct slots mean the tree \
nests deeper than shader.wgsl's own walk of the same bound",
chain = Self::move_chain_debug(slot, offsets)
);
}
delta
}
/// The parent chain from `slot`, as `slot(dx, dy) -> ...`, walked twice
/// `MOVE_CHAIN_LIMIT` so a cycle shows up as a repeated slot number
/// rather than as a chain that merely stops. Only ever called from the
/// failed assertion above.
fn move_chain_debug(slot: MoveIdx, offsets: &[MoveOffset]) -> String {
let mut parts = Vec::new();
let mut at = slot;
for _ in 0..MOVE_CHAIN_LIMIT * 2 {
let entry = &offsets[at.idx()];
parts.push(format!(
"{}({}, {})",
at.idx(),
entry.delta[0],
entry.delta[1]
));
if entry.parent == MoveOffset::NONE_PARENT {
break;
}
at = Id::preset(entry.parent);
}
parts.join(" -> ")
}
pub fn window_region(&self, id: &impl IdLike, rsc: &dyn UiRsc) -> Option<PixelRegion> {
let region = self.resolved_region(id, rsc)?;
Some(region.to_px(self.output_size))
+19
View File
@@ -41,6 +41,25 @@ pub trait Widget: Any {
fn access_role(&self) -> accesskit::Role {
accesskit::Role::Unknown
}
/// Advance whatever this widget is animating to `now`, and say whether
/// it is still animating afterwards. Default: nothing is, so a widget
/// opts in by overriding this *and* by something calling
/// [`crate::UiData::animate`] with its id when the animation starts --
/// which is that animation's path out, since the driver
/// ([`crate::UiData::tick_animations`]) drops every id whose `tick`
/// answers `false`.
///
/// Called once per frame, before the frame's draw, by whichever
/// backend owns the surface; a `true` answer is what makes that
/// backend ask for another frame. So this is the only thing in iris
/// that moves without an input event, and a widget that animates
/// without registering simply never moves -- which is exactly how a
/// finger fling looked on Iris's phone before this existed.
#[allow(unused_variables)]
fn tick(&mut self, now: std::time::Instant) -> bool {
false
}
}
impl Widget for () {
+32
View File
@@ -0,0 +1,32 @@
[package]
name = "rig-input"
version.workspace = true
edition.workspace = true
# Layer 2's input half (docs/RUST.md's "Three test layers"): replays one
# of the `.touch` files the headless tests use into whatever window is
# under a Wayland compositor, so the *same recording* drives the
# assertion layer and the layer a person looks at.
#
# It exists because this machine's compositor has no pointer to move.
# `run-headless.sh` starts sway on the headless backend with no input
# devices at all (`WLR_LIBINPUT_NO_DEVICES=1`, `LIBSEAT_BACKEND=noop`),
# so `swaymsg seat - cursor press` reports success and nothing reaches
# the client -- `swaymsg -t get_seats` shows `capabilities: 0`. wlroots
# 0.19 dropped `WLR_HEADLESS_INPUTS`, and ydotool's uinput device would
# be ignored by a compositor that is not reading libinput. The
# virtual-pointer protocol is what is left, and it is a client protocol,
# so it needs no devices and no root.
# Named for what it does rather than for the crate, since the crate may
# grow a keyboard replay beside it.
[[bin]]
name = "replay-touch"
path = "src/main.rs"
[dependencies]
# `TouchScript` -- the same parser the harness uses, so a file that
# replays here and one that replays headless can never disagree.
iris = { path = ".." }
wayland-client = "0.31.15"
wayland-protocols-wlr = { version = "0.3.12", features = ["client"] }
+164
View File
@@ -0,0 +1,164 @@
//! Replays a `.touch` file into the compositor as a left-button drag --
//! see this crate's `Cargo.toml` for why it exists rather than
//! `swaymsg seat - cursor`.
//!
//! WAYLAND_DISPLAY=… replay-touch WIDTH HEIGHT FILE
//!
//! `WIDTH`/`HEIGHT` are the output's own size, because the virtual
//! pointer protocol positions absolutely against an extent rather than
//! in pixels; passing the output size makes a script's coordinates mean
//! the same pixels they mean in the headless tests.
//!
//! Replayed in real time (the sleeps between samples are the gaps in the
//! file), because winit has no timestamp on a pointer event and dates
//! each one when it arrives -- so a 20ms flick has to actually take
//! 20ms here, unlike layer 1 where the sample carries its own time.
use iris::harness::{TouchAction, TouchScript};
use std::time::Duration;
use wayland_client::protocol::wl_pointer::ButtonState;
use wayland_client::protocol::{wl_registry, wl_seat};
use wayland_client::{Connection, Dispatch, QueueHandle, delegate_noop};
use wayland_protocols_wlr::virtual_pointer::v1::client::{
zwlr_virtual_pointer_manager_v1::ZwlrVirtualPointerManagerV1,
zwlr_virtual_pointer_v1::ZwlrVirtualPointerV1,
};
/// `linux/input-event-codes.h`. The protocol takes the kernel's own
/// button code, not a wayland enum.
const BTN_LEFT: u32 = 0x110;
/// How long the pointer sits at the gesture's first position before the
/// script starts -- see the comment at the pre-step in `main`.
const SETTLE: Duration = Duration::from_millis(200);
#[derive(Default)]
struct Globals {
seat: Option<wl_seat::WlSeat>,
manager: Option<ZwlrVirtualPointerManagerV1>,
}
impl Dispatch<wl_registry::WlRegistry, ()> for Globals {
fn event(
state: &mut Self,
registry: &wl_registry::WlRegistry,
event: wl_registry::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
let wl_registry::Event::Global {
name,
interface,
version,
} = event
else {
return;
};
match interface.as_str() {
"wl_seat" => {
state.seat = Some(registry.bind(name, version.min(7), qh, ()));
}
"zwlr_virtual_pointer_manager_v1" => {
state.manager = Some(registry.bind(name, version.min(2), qh, ()));
}
_ => {}
}
}
}
delegate_noop!(Globals: ignore wl_seat::WlSeat);
delegate_noop!(Globals: ZwlrVirtualPointerManagerV1);
delegate_noop!(Globals: ZwlrVirtualPointerV1);
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let [width, height, path] = args.as_slice() else {
eprintln!("usage: replay-touch WIDTH HEIGHT FILE");
std::process::exit(2);
};
let (width, height) = (parse(width, "WIDTH"), parse(height, "HEIGHT"));
let text = std::fs::read_to_string(path)
.unwrap_or_else(|e| fail(&format!("could not read {path}: {e}")));
let script = TouchScript::parse(&text).unwrap_or_else(|e| fail(&e));
let conn = Connection::connect_to_env().unwrap_or_else(|e| {
fail(&format!(
"no wayland display ({e}); is WAYLAND_DISPLAY set?"
))
});
let mut queue = conn.new_event_queue();
let qh = queue.handle();
let display = conn.display();
display.get_registry(&qh, ());
let mut globals = Globals::default();
queue
.roundtrip(&mut globals)
.unwrap_or_else(|e| fail(&format!("wayland roundtrip failed: {e}")));
let manager = globals.manager.as_ref().unwrap_or_else(|| {
fail(
"this compositor does not offer zwlr_virtual_pointer_manager_v1, so a pointer cannot \
be synthesised; sway and every wlroots compositor do",
)
});
let pointer = manager.create_virtual_pointer(globals.seat.as_ref(), &qh, ());
// Put the pointer where the gesture starts and let the compositor
// settle before anything is pressed. Without this the press is
// dropped: sway has just learned about this pointer, and a button
// sent in the same breath as the motion that first puts it over a
// window arrives before there is a focused surface to send it to --
// winit sees `CursorEntered`, the moves and the *release*, never the
// press, so the gesture reads as a hover and nothing scrolls. Found
// by printing winit's own events; the settle is what fixed it.
if let Some(first) = script.samples.first() {
pointer.motion_absolute(0, first.pos.x as u32, first.pos.y as u32, width, height);
pointer.frame();
conn.flush()
.unwrap_or_else(|e| fail(&format!("flush: {e}")));
std::thread::sleep(SETTLE);
}
let mut previous = 0;
for sample in &script.samples {
std::thread::sleep(Duration::from_millis(sample.t_ms - previous));
previous = sample.t_ms;
let t = sample.t_ms as u32;
pointer.motion_absolute(t, sample.pos.x as u32, sample.pos.y as u32, width, height);
// One frame per sample, so the compositor delivers them as
// separate pointer frames rather than coalescing the whole
// gesture -- the shape the file recorded is the point.
pointer.frame();
// The button goes in a frame of its own, *after* the motion has
// been committed. Sent in the same frame as the motion that
// first puts the pointer over the window, sway drops it: the
// client sees `CursorEntered` and the moves but never a
// `MouseInput { state: Pressed }`, so the whole gesture reads as
// a hover and nothing scrolls. Found exactly that way, by
// printing winit's events.
let state = match sample.action {
TouchAction::Down => Some(ButtonState::Pressed),
TouchAction::Up | TouchAction::Cancel => Some(ButtonState::Released),
TouchAction::Move => None,
};
if let Some(state) = state {
pointer.button(t, BTN_LEFT, state);
pointer.frame();
}
conn.flush()
.unwrap_or_else(|e| fail(&format!("flush: {e}")));
}
pointer.destroy();
conn.flush().ok();
}
fn parse(text: &str, what: &str) -> u32 {
text.parse()
.unwrap_or_else(|_| fail(&format!("{what} is not a whole number: {text:?}")))
}
fn fail(message: &str) -> ! {
eprintln!("replay-touch: {message}");
std::process::exit(1);
}
+65 -1
View File
@@ -3,6 +3,25 @@
#
# ./run-headless.sh tabs [-- cargo args]
# ./run-headless.sh tabs --shot /tmp/tabs.png --seconds 4
# ./run-headless.sh phone --phone --shot /tmp/p.png -- -p transcript-fixture
# ./run-headless.sh phone --phone --replay transcript-fixture/touch/flick-120hz.touch \
# --shot /tmp/p.png -- -p transcript-fixture
#
# `--phone` is layer 2 of docs/RUST.md's "Three test layers": the output
# and the window take Iris's phone's own size and density (1080x2424 at
# `content_scale` 2.55, from docs/bench/iris-phone-v2-2026-09-06.md,
# carried in `transcript_fixture::PHONE_*`), and `IRIS_SCALE` hands that
# density to iris the way `DisplayMetrics.density` does on Android
# (`iris::default::content_scale`). So a screenshot from here and one
# from the phone are the same layout at the same density, and what
# differs is only the renderer. Without it the output stays desktop-
# shaped, which is what every other example wants.
#
# `--replay FILE` drives one of the `.touch` recordings the headless
# tests use (`iris/transcript-fixture/touch/`) into the window through
# `rig-input`'s `replay-touch` -- one recording, both layers. With
# `--shot` it also writes `<shot>-before.png` from just before the
# gesture, since "the list moved" is a claim about two pictures.
#
# `--bin` runs a real crate binary instead of an example (E4's
# `desktop-app`, which is a window a person runs, not a demo) --
@@ -29,19 +48,31 @@ here=$(cd "$(dirname "$0")" && pwd)
run="${XDG_RUNTIME_DIR:-/tmp}/iris-headless"
seconds=3
shot=""
replay=""
example=""
kind=example
phone=no
# The phone Iris runs the bench on. Not typed from memory: these are
# `transcript_fixture::PHONE_WIDTH`/`PHONE_HEIGHT`/`PHONE_SCALE`, which
# in turn come from her own reports -- keep the three in step.
PHONE_MODE=1080x2424@120Hz
PHONE_SCALE=2.55
DESKTOP_MODE=1920x1200@60Hz
while [ $# -gt 0 ]; do
case "$1" in
--shot) shot=$2; shift 2 ;;
--seconds) seconds=$2; shift 2 ;;
--bin) kind=bin; shift ;;
--phone) phone=yes; shift ;;
--replay) replay=$2; shift 2 ;;
--) shift; break ;;
*) example=$1; shift ;;
esac
done
[ -n "$example" ] || { echo "usage: $0 NAME [--bin] [--shot PNG] [--seconds N] [-- cargo args]" >&2; exit 2; }
[ -n "$example" ] || { echo "usage: $0 NAME [--bin] [--phone] [--replay TOUCH] [--shot PNG] [--seconds N] [-- cargo args]" >&2; exit 2; }
[ -z "$replay" ] || [ -f "$replay" ] || { echo "run-headless: no touch script at $replay" >&2; exit 2; }
mkdir -p "$run"
export SWAYSOCK="$run/sway.sock"
@@ -78,6 +109,27 @@ export WAYLAND_DISPLAY
echo "run-headless: $WAYLAND_DISPLAY (sway $(swaymsg -t get_version --raw | sed -n 's/.*"human_readable":"\([^"]*\)".*/\1/p'))" >&2
# Set every run rather than only when it changes: this compositor is
# reused across runs (see the socket comment above), so a desktop-shaped
# run after a phone-shaped one would otherwise inherit the phone's output
# and silently screenshot the wrong size.
if [ "$phone" = yes ]; then
mode=$PHONE_MODE
export IRIS_SCALE="$PHONE_SCALE"
echo "run-headless: phone-shaped output $PHONE_MODE at IRIS_SCALE=$PHONE_SCALE" >&2
else
mode=$DESKTOP_MODE
fi
swaymsg output HEADLESS-1 mode "$mode" >/dev/null
# The extent `replay-touch` positions against, so a script's coordinates
# are the output's own pixels.
out_w=${mode%x*}
out_h=${mode#*x}; out_h=${out_h%@*}
# Built before the app starts, so a compile error is not reported as a
# window that failed to move.
[ -z "$replay" ] || cargo build --bin replay-touch -p rig-input >&2
cd "$here"
if [ "$kind" = bin ]; then
cargo build --bin "$example" "$@" >&2
@@ -111,6 +163,18 @@ while [ $i -lt "$((seconds * 2))" ]; do
i=$((i + 1)); sleep 0.5
done
if [ -n "$replay" ] && kill -0 "$pid" 2>/dev/null; then
if [ -n "$shot" ]; then
grim "${shot%.png}-before.png"
echo "run-headless: wrote ${shot%.png}-before.png (before the gesture)" >&2
fi
"$here/target/debug/replay-touch" "$out_w" "$out_h" "$replay"
# A fling outlives the finger: the gesture's own last sample is not
# when the list stops. Long enough for Android's spline to settle
# (`FlingCalculator::duration` tops out around a second and a half).
sleep 2
fi
if kill -0 "$pid" 2>/dev/null; then
[ -n "$shot" ] && grim "$shot" && echo "run-headless: wrote $shot" >&2
kill "$pid" 2>/dev/null || true
+32 -6
View File
@@ -45,16 +45,38 @@ pub struct Insets {
pub top: i32,
pub right: i32,
pub bottom: i32,
/// The keyboard's own inset (`WindowInsetsCompat.Type.ime()`), separate
/// from `bottom` (the system bars): a layout wants to know about the
/// keyboard specifically, since it usually means "make room" rather
/// than "stay clear of a corner".
/// The keyboard's own inset (`WindowInsets.Type.ime()`), in physical
/// pixels, separate from `bottom` (the system bars): a layout wants to
/// know about the keyboard specifically, since it usually means "make
/// room" rather than "stay clear of a corner".
pub ime_bottom: i32,
/// `WindowInsets.isVisible(ime())` -- whether the keyboard is up, which
/// is **not** the same question as `ime_bottom > 0` and is why the two
/// are carried separately. They disagree for the frames the keyboard
/// spends sliding: visible, with a height still on its way to the full
/// one. Anything asking "make how much room" reads `ime_bottom`;
/// anything asking "is the keyboard up" reads this. See
/// `MainActivity.java`'s comment for the history -- the height used to
/// be sent *as* this boolean, which is what left the composer padded by
/// one pixel on Iris's phone.
pub ime_visible: bool,
}
#[derive(Default)]
pub struct Shared {
pub insets: Insets,
/// How many times Java has called `applyWindowInsetsNative` for this
/// peer, whether or not the numbers changed. Deliberately **not** a
/// field of `Insets`, which is compared for equality each frame to
/// decide whether to re-run `on_insets_changed`; a counter in there
/// would make every dispatch look like a change.
///
/// It exists because "the keyboard does not push anything up" has two
/// completely different causes that look identical on screen -- the
/// listener never fired, or it fired with a zero `ime_bottom` -- and
/// Iris has no logcat on her phone (docs/IRIS_TODO.md). This number is
/// in the `Diagnostics` overlay, so one screenshot separates them.
pub updates: u64,
}
type SharedMap = HashMap<jlong, SendWrapper<Rc<RefCell<Shared>>>>;
@@ -89,15 +111,19 @@ extern "system" fn apply_window_insets<'local>(
right: jint,
bottom: jint,
ime_bottom: jint,
ime_visible: jint,
) {
if let Some(shared) = map().lock().unwrap().get(&peer) {
shared.borrow_mut().insets = Insets {
let mut shared = shared.borrow_mut();
shared.insets = Insets {
left,
top,
right,
bottom,
ime_bottom,
ime_visible: ime_visible != 0,
};
shared.updates += 1;
}
// Insets can change (the keyboard opening) with no resize and no
// touch, so nothing else here would otherwise ask for a frame.
@@ -115,7 +141,7 @@ pub fn register_native_methods<'local, 'other_local>(
&[
NativeMethod {
name: "applyWindowInsetsNative".into(),
sig: "(JIIIII)V".into(),
sig: "(JIIIIII)V".into(),
fn_ptr: apply_window_insets as *mut c_void,
},
NativeMethod {
+136 -10
View File
@@ -7,9 +7,9 @@ use android_view::{
jni::{
JNIEnv, JavaVM,
objects::{GlobalRef, JValue},
sys::jint,
sys::{jint, jlong},
},
ndk::event::{Keycode, MotionAction},
ndk::event::{Axis, Keycode, MotionAction},
};
// `marker::Sized` explicitly: `crate::prelude::*` below also brings in the
// `Sized` *widget* (`widget::position::sized::Sized`), and an unqualified
@@ -20,7 +20,7 @@ use std::{
marker::{PhantomData, Sized},
rc::Rc,
sync::Arc,
time::Instant,
time::{Duration, Instant},
};
use super::{
@@ -130,6 +130,28 @@ impl AndroidUiState {
pub fn insets(&self) -> Insets {
self.shared.borrow().insets
}
/// The insets state as one line for a diagnostics pane, including how
/// many times the platform has delivered any -- see
/// `insets::Shared::updates` for why the count is the load-bearing
/// part. `dispatches=0` says the listener has never run and the
/// numbers beside it are defaults rather than measurements, which is
/// the distinction a screenshot otherwise cannot make (UI_RULES.md,
/// "design the unknown state first").
pub fn insets_report(&self) -> String {
let shared = self.shared.borrow();
let i = shared.insets;
if shared.updates == 0 {
return "insets: dispatches=0 -- the platform has never called \
onApplyWindowInsets, so nothing below was measured"
.to_string();
}
format!(
"insets: dispatches={} left={} top={} right={} bottom={} ime_bottom={} \
ime_visible={}",
shared.updates, i.left, i.top, i.right, i.bottom, i.ime_bottom, i.ime_visible,
)
}
}
impl HasRoot for AndroidUiState {
@@ -195,7 +217,12 @@ pub struct WindowInsets {
pub top: f32,
pub right: f32,
pub bottom: f32,
/// How much of the window the keyboard covers, in physical pixels --
/// what a layout pads by. See `insets::Insets::ime_visible` for why
/// "is the keyboard up" is a separate field rather than this one
/// compared against zero.
pub ime_bottom: f32,
pub ime_visible: bool,
}
impl WindowInsets {
@@ -206,6 +233,7 @@ impl WindowInsets {
right: insets.right as f32,
bottom: insets.bottom as f32,
ime_bottom: insets.ime_bottom as f32,
ime_visible: insets.ime_visible,
}
}
}
@@ -284,6 +312,12 @@ pub struct IrisViewPeer<State: AndroidAppState> {
pub(super) render: UiRenderState,
pub(super) state: State,
task_recv: TaskMsgReceiver<AndroidRsc<State>>,
/// `(an Instant, the input-event nanosecond stamp it was taken at)`,
/// captured from the first `MotionEvent` this view receives and never
/// changed after -- how `on_touch_event` dates every touch sample. Its
/// path out is the peer's own drop: it holds nothing but two numbers
/// and is meaningless to any other view.
input_clock: Option<(Instant, jlong)>,
}
impl<State: 'static, I: RscIdx<AndroidRsc<State>>> std::ops::Index<I> for AndroidRsc<State> {
@@ -307,12 +341,13 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
}
}
/// Common tail for every callback that might have changed the cursor,
/// the text focus, or the widget tree: run the sensors that touch
/// input feeds, then ask for a frame if the result needs drawing.
/// Mirrors `default::DefaultApp::window_event`'s tail, split across
/// android-view's several entry points instead of winit's one.
pub(super) fn after_input(&mut self, ctx: &mut CallbackCtx) {
/// One pointer sample through the sensors, plus the platform calls a
/// handler can only ask for by raising a flag. Split out of
/// [`Self::after_input`] because a batched `MotionEvent` carries
/// several samples that all belong to the same *frame*
/// (`on_touch_event`): each one is a real input frame the widgets must
/// see, but only the last one ends the frame and asks for a redraw.
fn run_input_frame(&mut self, ctx: &mut CallbackCtx) {
let window_size = self.window_size();
let ui_state = self.state.android_state_mut();
let cursor = ui_state.cursor.clone();
@@ -332,6 +367,15 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
if let Some(url) = ui_state.pending_open_url.take() {
super::platform::open_url(&mut ctx.env, &ctx.view, &url);
}
}
/// Common tail for every callback that might have changed the cursor,
/// the text focus, or the widget tree: run the sensors that touch
/// input feeds, then ask for a frame if the result needs drawing.
/// Mirrors `default::DefaultApp::window_event`'s tail, split across
/// android-view's several entry points instead of winit's one.
pub(super) fn after_input(&mut self, ctx: &mut CallbackCtx) {
self.run_input_frame(ctx);
// RUST.md's P0 box, "doesn't enter it until I hit space, and also
// doesn't move cursor forward": Gboard needs `updateSelection`
@@ -383,12 +427,14 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
// is actually fed have to reach the log -- "the composer
// floats at launch" is unanswerable from a screenshot alone.
log::info!(
"iris insets: left={} top={} right={} bottom={} ime_bottom={} window={:?}",
"iris insets: left={} top={} right={} bottom={} ime_bottom={} \
ime_visible={} window={:?}",
physical.left,
physical.top,
physical.right,
physical.bottom,
physical.ime_bottom,
physical.ime_visible,
self.window_size(),
);
self.state.android_state_mut().last_insets = current_insets;
@@ -414,6 +460,12 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
// both count. See `iris_core::FrameReport`'s own doc for exactly
// what this does and does not measure.
let frame_start = Instant::now();
// Anything moving on its own -- today a `List` coasting through a
// fling -- is advanced here, before the draw, and asks for the
// next frame at the end of this one. See
// `UiData::tick_animations`; `default/mod.rs`'s
// `RedrawRequested` arm is the same two lines for winit.
let animating = self.rsc.ui.tick_animations(frame_start);
let ui_state = self.state.android_state_mut();
self.render.update(&ui_state.root, &mut self.rsc);
let ui_state = self.state.android_state_mut();
@@ -446,6 +498,12 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
.android_state_mut()
.frame_report
.record_split(frame_start.elapsed(), submit_to_present);
// A frame callback is one-shot, so an animation that wants
// another frame has to say so every frame -- unlike `after_input`,
// which only has to ask when input dirtied something.
if animating {
ctx.view.post_frame_callback(&mut ctx.env);
}
let ui_state = self.state.android_state();
log::debug!(
"render(): after update active={} root_px={:?}",
@@ -554,7 +612,67 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
// -- see `AndroidUiState::content_scale`'s field comment.
let x = event.x(&mut ctx.env);
let y = event.y(&mut ctx.env);
// The event's own clock, converted through one anchor taken on the
// first touch this view ever sees. Android reports sample times in
// the `SystemClock.uptimeMillis()` base, which is the same
// `CLOCK_MONOTONIC` an `Instant` reads, so a single
// `(Instant, nanos)` pair converts every later sample exactly.
// Anchoring **once** rather than per event is what keeps the times
// ordered: a fresh `Instant::now()` per event, minus each sample's
// age inside it, can date a later event's first historical sample
// before the previous event's last one whenever delivery jitters by
// more than the batch spans -- and `VelocityTracker::add_sample`'s
// debug assert would rightly fire on that. See `CursorState::time`.
let event_time = event.event_time_nanos(&mut ctx.env);
let (anchor_at, anchor_nanos) =
*self.input_clock.get_or_insert((Instant::now(), event_time));
let at = |sample_time: jlong| {
anchor_at + Duration::from_nanos(sample_time.saturating_sub(anchor_nanos).max(0) as u64)
};
// **Historical samples first.** A flick on a 120Hz screen is
// delivered as one or two `MotionEvent`s with the intermediate
// positions batched inside them, so reading only `x()`/`y()` threw
// away every sample but the last: the velocity tracker saw one
// `Pan` for the whole gesture, `VelocityTracker::velocity` answers
// 0.0 below two samples, and the release therefore flung at zero --
// Iris's phone, twice ("fling still doesn't work"), while a
// `ui-trace` swipe, which is many evenly-spaced events, flung fine.
// Replayed one at a time through the sensors rather than summarised,
// so the arbiter, the tracker and any other sensor all see the same
// motion the finger actually made; only the last sample ends the
// frame (`after_input`).
if matches!(action, MotionAction::Move) {
let history = event.history_size(&mut ctx.env);
// Android documents the historical samples as oldest first and
// the event's own sample as the newest of the batch; everything
// downstream (`VelocityTracker`, `DragArbiter`'s long-press
// clock) assumes it, so say so here rather than at each reader.
let mut previous = anchor_nanos;
for pos in 0..history {
let hx = event.historical_axis(&mut ctx.env, Axis::X, 0, pos);
let hy = event.historical_axis(&mut ctx.env, Axis::Y, 0, pos);
let ht = event.historical_event_time_nanos(&mut ctx.env, pos);
debug_assert!(
ht >= previous,
"historical sample {pos} of {history} is dated {ht}ns, before the {previous}ns \
sample ahead of it -- the input clock is not what this assumes"
);
previous = ht;
let ui_state = self.state.android_state_mut();
ui_state.cursor.pos = vec2(hx, hy);
ui_state.cursor.time = at(ht);
self.run_input_frame(ctx);
}
debug_assert!(
event_time >= previous,
"the event's own sample is dated {event_time}ns, before its last historical \
sample at {previous}ns"
);
}
let ui_state = self.state.android_state_mut();
ui_state.cursor.time = at(event_time);
match action {
MotionAction::Down => {
ui_state.cursor.pos = vec2(x, y);
@@ -564,6 +682,13 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
MotionAction::Move => {
ui_state.cursor.pos = vec2(x, y);
}
// `Cancel` ends the gesture the same way `Up` does, and must:
// a release that never arrives leaves whichever widget took
// pointer capture holding it forever, with every later touch
// delivered to a drag nobody is performing. Confirmed present
// before this pass rather than assumed -- it was one of the
// three suspects listed for the phone's missing fling, and it
// is not the cause.
MotionAction::Up | MotionAction::Cancel => {
ui_state.cursor.pos = vec2(x, y);
ui_state.cursor.buttons.left.update(false);
@@ -894,6 +1019,7 @@ pub fn new_peer<'local, State: AndroidAppState>(
render,
state,
task_recv,
input_clock: None,
};
let id = android_view::register_view_peer(peer);
super::insets::register(id, shared);
+29 -4
View File
@@ -18,9 +18,14 @@ pub trait FocusHost {
/// side effect the way a real double-click timer does.
fn recent_click(&mut self) -> bool;
fn set_focus(&mut self, id: Option<WeakWidget<TextEdit>>);
/// Called after a `TextEdit` becomes the focus target, with the region
/// it was hit in (`None` when the widget could not be located, which
/// happens for one it was just deselected from).
/// Called on every tap that should put the IME on `id`: the tap that
/// *makes* a `TextEdit` the focus target, and any later tap on one that
/// already is. `region` is where it was hit (`None` when the widget
/// could not be located, which happens for one it was just deselected
/// from). Implementations must be idempotent -- both backends' calls
/// (`showSoftInput`, `set_ime_cursor_area`) already are, which is what
/// lets the repeat tap be handled by the same call rather than by a
/// second "re-show" entry point beside it.
fn focus_gained(&mut self, region: Option<PixelRegion>);
/// Whether `id` is the current focus target -- what [`select`] uses to
/// tell a fresh press (which must wait to see whether it becomes a tap
@@ -155,10 +160,30 @@ fn on_press(
ctx.text.press_origin = None;
return;
}
if matches!(sense, CursorSense::PressEnd(_)) {
let ended = matches!(sense, CursorSense::PressEnd(_));
if ended {
ctx.text.press_origin = None;
}
ctx.select(pos, size, true, false);
// A tap on a field that is *already* focused asks for the
// keyboard again (Iris's phone, 2026-09-06: "I can't reopen
// keyboard by tapping on message box after it already
// happened once"). Dismissing the IME -- back gesture, or
// its own hide button -- takes the keyboard away but leaves
// the field focused, so without this the one branch that
// requests it (the unfocused one below) never runs again
// and the field is permanently unable to summon it.
// Android's own `EditText` does exactly this: every tap on
// a focused field calls `showSoftInput`, which is a no-op
// when the keyboard is already up.
//
// Gated on the same tap-vs-drag test the unfocused branch
// uses, not on `PressEnd` alone, so a drag-to-select that
// happens to finish inside the field does not summon a
// keyboard the reader was not asking for.
if ended && dx.abs() <= DRAG_SLOP && dy.abs() <= DRAG_SLOP {
state.focus_gained(render.window_region(&id, &*rsc));
}
}
_ => {}
}
+5 -3
View File
@@ -1,5 +1,5 @@
use crate::prelude::*;
use winit::dpi::{LogicalPosition, LogicalSize};
use winit::dpi::{PhysicalPosition, PhysicalSize};
impl<T: HasDefaultUiState> FocusHost for T {
fn recent_click(&mut self) -> bool {
@@ -18,9 +18,11 @@ impl<T: HasDefaultUiState> FocusHost for T {
let state = self.default_state_mut();
let Some(region) = region else { return };
state.window.set_ime_allowed(true);
// Physical, like everything else this backend hands winit --
// `default::content_scale`.
state.window.set_ime_cursor_area(
LogicalPosition::<f32>::from(region.top_left.tuple()),
LogicalSize::<f32>::from(region.size().tuple()),
PhysicalPosition::<f32>::from(region.top_left.tuple()),
PhysicalSize::<f32>::from(region.size().tuple()),
);
}
}
+21 -17
View File
@@ -1,4 +1,10 @@
// `CursorState::time` is the sample's own time on every backend. winit
// carries no timestamp on a pointer event, so the moment it is handed to
// us is the closest measurement available here -- which is also what the
// drag code used to do for itself with `Instant::now()`, before Android's
// batched samples made the difference matter (see `sense::CursorState`).
use crate::prelude::*;
use std::time::Instant;
use winit::{
event::{MouseButton, MouseScrollDelta, WindowEvent},
keyboard::{Key, NamedKey},
@@ -11,18 +17,19 @@ pub struct Input {
}
impl Input {
/// `scale_factor` converts winit's physical-pixel event coordinates
/// into the same logical units `UiRenderNode`'s window uniform now uses
/// (`default::render::UiRenderer::new`'s doc comment) -- without it,
/// a cursor position and the widget tree it's tested against would be
/// in two different units on any monitor whose scale factor isn't 1.0.
pub fn event(&mut self, event: &WindowEvent, scale_factor: f32) -> bool {
/// winit's pointer coordinates are physical pixels, which is the
/// space the whole tree is laid out and hit-tested in -- see
/// `default::content_scale`. Nothing is converted here; `dp(...)`
/// resolves against the density at layout time instead.
pub fn event(&mut self, event: &WindowEvent) -> bool {
match event {
WindowEvent::CursorMoved { position, .. } => {
self.cursor.pos = Vec2::new(position.x as f32, position.y as f32) / scale_factor;
self.cursor.pos = Vec2::new(position.x as f32, position.y as f32);
self.cursor.exists = true;
self.cursor.time = Instant::now();
}
WindowEvent::MouseInput { state, button, .. } => {
self.cursor.time = Instant::now();
let buttons = &mut self.cursor.buttons;
let pressed = state.is_pressed();
match button {
@@ -35,15 +42,14 @@ impl Input {
WindowEvent::MouseWheel { delta, .. } => {
let mut delta = match *delta {
MouseScrollDelta::LineDelta(x, y) => Vec2::new(x, y),
MouseScrollDelta::PixelDelta(pos) => {
Vec2::new(pos.x as f32, pos.y as f32) / scale_factor
}
MouseScrollDelta::PixelDelta(pos) => Vec2::new(pos.x as f32, pos.y as f32),
};
if delta.x == 0.0 && self.modifiers.shift {
delta.x = delta.y;
delta.y = 0.0;
}
self.cursor.scroll_delta = delta;
self.cursor.time = Instant::now();
}
WindowEvent::CursorLeft { .. } => {
self.cursor.exists = false;
@@ -74,14 +80,12 @@ impl Input {
}
impl DefaultUiState {
/// Physical pixels, matching `WindowEvent::Resized` (what
/// `UiRenderState::resize` is given) and the swapchain -- see
/// `default::content_scale`.
pub fn window_size(&self) -> Vec2 {
let window = self.renderer.window();
let size = window.inner_size();
let scale_factor = window.scale_factor() as f32;
Vec2::new(
size.width as f32 / scale_factor,
size.height as f32 / scale_factor,
)
let size = self.renderer.window().inner_size();
Vec2::new(size.width as f32, size.height as f32)
}
pub fn cursor_state(&self) -> &CursorState {
+54 -3
View File
@@ -25,6 +25,38 @@ pub use render::*;
pub type Proxy<Event> = EventLoopProxy<Event>;
/// The desktop's `content_scale`: physical pixels per dp, the same
/// quantity Android reads from `DisplayMetrics.density` and feeds to
/// `UiRenderState::set_density` (`android::view::AndroidUiState::
/// content_scale`'s field comment). Everything in this backend is
/// physical pixels -- the window size, the pointer, the widget tree --
/// and `dp(...)` is what resolves against this at layout time, exactly
/// as on the phone. That is a correction from an earlier version that
/// divided winit's coordinates into a separate "logical" space instead:
/// it left `UiRenderState::resize` (physical, from `WindowEvent::
/// Resized`) and the window uniform (logical) disagreeing on any
/// display whose scale factor is not 1.0, and it rasterised glyphs at
/// one resolution to display them at another -- the blur the phone's own
/// stopgap produced before `dp` existed.
///
/// **`IRIS_SCALE` overrides it**, which is how a phone-shaped desktop
/// window runs the phone's density (`run-headless.sh --phone`,
/// docs/RUST.md's layer 2). An unparsable value is a typo in a command
/// somebody just typed, so it says so and uses the window's own answer
/// rather than silently laying out at the wrong density.
pub fn content_scale(window: &Window) -> f32 {
match std::env::var("IRIS_SCALE") {
Err(_) => window.scale_factor() as f32,
Ok(text) => match text.trim().parse::<f32>() {
Ok(scale) if scale > 0.0 => scale,
_ => {
log::warn!("IRIS_SCALE={text:?} is not a positive number; using the window's own");
window.scale_factor() as f32
}
},
}
}
pub struct DefaultUiState {
pub root: Option<StrongWidget>,
pub renderer: UiRenderer,
@@ -214,8 +246,16 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
window.set_visible(true);
let default_state = DefaultUiState::new(window, access_adapter);
let (mut rsc, task_recv) = DefaultRsc::init(default_state.window.clone());
// Both copies of the density, set before the first widget is
// built so text shapes at the right size on the opening frame --
// the same pair `android::view::new_peer` sets from
// `content_scale`. See `iris_core::TextData::density` for why the
// shaper keeps its own.
let scale = content_scale(default_state.window.as_ref());
rsc.ui.text.density = scale;
let state = State::new(default_state, &mut rsc, proxy);
let render = UiRenderState::new();
let mut render = UiRenderState::new();
render.set_density(scale);
Self {
rsc,
state,
@@ -247,8 +287,7 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
ui_state
.access_adapter
.process_event(&ui_state.window, &event);
let scale_factor = ui_state.renderer.window().scale_factor() as f32;
let input_changed = ui_state.input.event(&event, scale_factor);
let input_changed = ui_state.input.event(&event);
let cursor_state = ui_state.cursor_state().clone();
let old = ui_state.focus;
if cursor_state.buttons.left.is_start() {
@@ -267,9 +306,21 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
match &event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::RedrawRequested => {
// Before the draw, so this frame shows this instant's
// position (`UiData::tick_animations`' own doc), and the
// window is asked for another frame while anything is
// still moving -- the winit half of what
// `IrisViewPeer::render`'s `post_frame_callback` does on
// Android. Nothing else in iris moves without an input
// event.
let animating = rsc.ui_mut().tick_animations(std::time::Instant::now());
let ui_state = state.default_state_mut();
render.update(&ui_state.root, rsc);
ui_state.renderer.update(&mut rsc.ui, render);
ui_state.renderer.draw();
if animating {
ui_state.window.request_redraw();
}
// I4 (RUST.md): only produces a `TreeUpdate` when the named
// set actually changed this frame -- see `AccessTree`'s doc
// comment. `render` reflects the draw that just happened,
+10 -21
View File
@@ -66,13 +66,11 @@ impl UiRenderer {
self.config.width = size.width;
self.config.height = size.height;
self.surface.configure(&self.device, &self.config);
// Logical, matching `new`'s own seed -- see the comment there.
let scale_factor = self.window.scale_factor() as f32;
let logical = Vec2::new(
size.width as f32 / scale_factor,
size.height as f32 / scale_factor,
// Physical, matching `new`'s own seed -- see the comment there.
self.ui.resize(
Vec2::new(size.width as f32, size.height as f32),
&self.queue,
);
self.ui.resize(logical, &self.queue);
}
fn create_encoder(device: &Device) -> CommandEncoder {
@@ -162,21 +160,12 @@ impl UiRenderer {
// by:" chain as the message, since `UiRenderNode::new` returns it
// rather than letting wgpu's own default handler panic first (see
// that function's doc comment).
// Logical size (physical / `scale_factor`), matching what the
// Android backend now reports too (`android::render::
// AndroidRenderer::new`, `content_scale`) -- the swapchain still
// configures at the real physical resolution above; only the
// window uniform layout/hit-testing agree on is scaled. Without
// this a window on any monitor whose scale factor isn't 1.0 would
// have the identical "everything too small" bug RUST.md's P0 box
// found on Iris's phone, just never noticed here because this
// crate's own dev monitors happen to run at 1.0.
let scale_factor = window.scale_factor() as f32;
let logical_size = Vec2::new(
size.width as f32 / scale_factor,
size.height as f32 / scale_factor,
);
let ui = UiRenderNode::new(&device, &queue, &config, logical_size)
// Physical size, the same units the swapchain, `WindowEvent::
// Resized`, the pointer and the widget tree all use -- see
// `default::content_scale` for why this backend stopped dividing
// into a separate logical space, and what disagreed while it did.
let physical_size = Vec2::new(size.width as f32, size.height as f32);
let ui = UiRenderNode::new(&device, &queue, &config, physical_size)
.expect("Could not create iris render node!");
Self {
+396
View File
@@ -0,0 +1,396 @@
//! 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). It ends the press exactly as `Up` does -- a
/// release that never arrives leaves pointer capture held forever --
/// which is why a replay file can say it.
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,
}
}
}
#[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 `List` coasting through a fling asks for
/// the next frame through this (`List::set_redraw_handle`), 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 `List::tick_fling` 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);
self.rsc.ui.tick_animations(now);
self.render.update(&self.state.root, &mut self.rsc);
}
/// Frames every `step_ms` up to and including `end_ms` -- what a
/// fling needs, since it moves only while something ticks it
/// (`List::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 | TouchAction::Cancel => self.cursor.buttons.left.update(false),
}
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);
}
}
}
+1
View File
@@ -21,6 +21,7 @@ pub mod default;
pub mod attr;
pub mod event;
pub mod harness;
pub mod platform;
pub mod sense;
pub mod state;
+454 -77
View File
@@ -95,12 +95,39 @@ impl CursorSense {
}
}
#[derive(Default, Clone)]
#[derive(Clone)]
pub struct CursorState {
pub pos: Vec2,
pub exists: bool,
pub buttons: CursorButtons,
pub scroll_delta: Vec2,
/// When this pointer state was *sampled*, from the platform's own
/// input clock -- not when the handler reading it happened to run.
///
/// It exists because Android batches touch samples: a flick on a
/// 120Hz screen arrives as one or two `MotionEvent`s carrying the
/// intermediate positions as *historical* samples
/// (`getHistoricalX`/`getHistoricalEventTime`), which
/// `IrisViewPeer::on_touch_event` replays through the sensor pass one
/// at a time. Every one of those replays happens within the same few
/// microseconds, so a gesture timing itself with `Instant::now()`
/// would see a span of nearly zero across the whole flick and divide
/// by it -- the velocity would be an artefact of how fast we looped,
/// which is exactly the inferred-as-measured number UI_RULES.md
/// forbids. Carrying the sample's own time makes the span real.
pub time: Instant,
}
impl Default for CursorState {
fn default() -> Self {
Self {
pos: Vec2::ZERO,
exists: false,
buttons: CursorButtons::default(),
scroll_delta: Vec2::ZERO,
time: Instant::now(),
}
}
}
#[derive(Default, Clone)]
@@ -732,6 +759,18 @@ impl DragGesture {
match sense {
CursorSense::PressStart(_) => {
self.velocity.reset();
// The press itself is a sample: nothing has moved yet, but
// *when* the finger went down is real and measured, and
// without it a gesture whose whole motion arrives in one
// frame has a single sample and therefore no time span to
// divide by -- `velocity` answers 0.0 and the release does
// not fling. Batched touch delivery makes that shape
// ordinary rather than rare (see `CursorState::time`), and
// `VELOCITY_WINDOW` trims this entry back out the moment
// the gesture is long enough not to need it, so a slow
// drag's velocity is still its recent motion and not its
// whole history.
self.velocity.add_sample(0.0, now);
self.arbiter.press_start(pos_window, now, already_selected);
self.dispatch(render, id, pos_window, now)
}
@@ -743,6 +782,20 @@ impl DragGesture {
} else {
GestureOutcome::Released(None)
};
// The one line that settles "why did that flick not fling"
// from a logcat, which is the only instrument available on
// Iris's phone (this-machine-android: system tracing does
// not work there). Every input to the decision is here, so
// a zero velocity can be told apart from a gesture that
// never reached `Panning` at all -- the two look identical
// on screen and had to be guessed between twice.
log::info!(
"iris drag release: samples={} span={:.1}ms v={:.0} outcome={:?}",
self.velocity.sample_count(),
self.velocity.span().as_secs_f32() * 1000.0,
self.velocity.velocity(),
outcome,
);
self.arbiter.release();
render.release_pointer();
outcome
@@ -752,6 +805,7 @@ impl DragGesture {
// landed outside whichever hit region first noticed it.
_ if self.arbiter.is_idle() => {
self.velocity.reset();
self.velocity.add_sample(0.0, now);
self.arbiter.press_start(pos_window, now, already_selected);
self.dispatch(render, id, pos_window, now)
}
@@ -835,6 +889,22 @@ impl VelocityTracker {
}
}
/// How many samples are currently inside the window, and how long they
/// span. Reported beside the velocity in `DragGesture`'s release log,
/// because a `v=0` on its own cannot say whether the gesture was slow
/// or whether the tracker was simply never fed -- which is exactly the
/// distinction the phone's missing fling turned on.
pub fn sample_count(&self) -> usize {
self.samples.len()
}
pub fn span(&self) -> Duration {
match (self.samples.front(), self.samples.back()) {
(Some(&(first, _)), Some(&(last, _))) => last.duration_since(first),
_ => Duration::ZERO,
}
}
/// The estimated speed, in units-per-second, over whatever samples
/// currently fall inside the tracking window: total motion divided by
/// the elapsed time between the oldest and newest sample still held.
@@ -844,34 +914,45 @@ impl VelocityTracker {
return 0.0;
}
let total: f32 = self.samples.iter().map(|&(_, d)| d).sum();
let span = self
.samples
.back()
.unwrap()
.0
.duration_since(self.samples.front().unwrap().0)
.as_secs_f32();
let span = self.span().as_secs_f32();
if span <= 0.0 { 0.0 } else { total / span }
}
}
/// Android's fling deceleration curve, ported from AOSP's
/// `android.widget.OverScroller.SplineOverScroller` (the same curve
/// Compose's `androidx.compose.ui.gestures.AndroidFlingSpline` and
/// `androidx.compose.foundation.gestures.FlingCalculator` reuse) so a
/// fling here travels the same distance a Compose `LazyColumn`'s own
/// Compose's `androidx.compose.animation.AndroidFlingSpline` and
/// `androidx.compose.animation.FlingCalculator` reuse) so a fling here
/// travels the same distance a Compose `LazyColumn`'s own
/// `ScrollableDefaults.flingBehavior()` would for the same initial
/// velocity -- RUST.md's "Benchmark v2" box asked the two apps' fling
/// phase to be comparable, and IRIS_TODO.md's "swiping has no momentum"
/// asked for the same physics a reader's muscle memory already expects
/// from every other Android scroll view.
/// from every other Android scroll view. Both sources were read at
/// `frameworks/base`'s `core/java/android/widget/OverScroller.java` and
/// `androidx.compose.animation:animation:1.12.0`'s `SplineBasedDecay.kt`
/// (2026-09-07); they agree line for line.
///
/// The curve is a cubic-Bezier-derived spline sampled into two lookup
/// tables at start-up (`SPLINE`, built once via [`std::sync::OnceLock`]):
/// `SPLINE_POSITION[i]`/`SPLINE_TIME[i]` give the fraction of total
/// distance/time elapsed at the `i`th of 100 even steps along the curve's
/// own parameter. A lookup at an arbitrary time fraction interpolates
/// between the two bracketing samples.
/// **One table, indexed by even steps of *time*.** `SPLINE_POSITION[i]`
/// is the fraction of the total distance covered at time fraction
/// `i / NB_SAMPLES`, so a lookup brackets `t` between `index / N` and
/// `(index + 1) / N` -- never between table entries. AOSP builds a second
/// table, `SPLINE_TIME`, purely for `adjustDuration` (re-timing a fling
/// whose target moved), which nothing here has; it is deliberately not
/// built, so there is one array and one indexing rule rather than two of
/// each to pick the wrong one from.
///
/// The wrong one was picked, and this is what it cost. Until 2026-09-07
/// the two halves of AOSP's build loop were transposed -- the bisection
/// solved the tension curve and the sample evaluated the `P1`/`P2` one,
/// where AOSP does the opposite -- which made this table and `SPLINE_TIME`
/// *identical*, and the old lookup, which bracketed `t` between
/// `SPLINE_TIME` entries, then returned exactly `t` for every `t`. A fling
/// coasted at constant speed for its whole duration and stopped dead:
/// Iris's phone report of 2026-09-07, "just linear velocity with an abrupt
/// stop", verbatim out of the arithmetic. Every test it had compared the
/// curve with itself, so none of them could see it;
/// `the_spline_matches_aosps_own_table` pins the absolute numbers now.
mod android_fling_spline {
use std::sync::OnceLock;
@@ -884,24 +965,30 @@ mod android_fling_spline {
const P1: f32 = START_TENSION * INFLEXION;
const P2: f32 = 1.0 - END_TENSION * (1.0 - INFLEXION);
pub(super) struct Spline {
position: [f32; NB_SAMPLES + 1],
time: [f32; NB_SAMPLES + 1],
/// What a lookup answers: how far along the fling is, and how fast it
/// is going there -- AOSP's `distanceCoef`/`velocityCoef` and Compose's
/// `AndroidFlingSpline.FlingResult`. Both are fractions of the fling's
/// *total* distance, the second per unit of its *total* duration, so a
/// caller scales them by `distance` and `distance / duration`.
pub(super) struct SplineSample {
pub(super) distance_fraction: f32,
pub(super) velocity_fraction: f32,
}
fn build() -> Spline {
fn build() -> [f32; NB_SAMPLES + 1] {
let mut position = [0.0f32; NB_SAMPLES + 1];
let mut time = [0.0f32; NB_SAMPLES + 1];
let (mut x_min, mut y_min) = (0.0f32, 0.0f32);
for i in 0..NB_SAMPLES {
let mut x_min = 0.0f32;
for (i, slot) in position.iter_mut().enumerate().take(NB_SAMPLES) {
let alpha = i as f32 / NB_SAMPLES as f32;
let mut x_max = 1.0f32;
let (mut x, mut coef);
loop {
x = x_min + (x_max - x_min) / 2.0;
coef = 3.0 * x * (1.0 - x);
let tx = coef * ((1.0 - x) * START_TENSION + x * END_TENSION) + x * x * x;
// Solved on the `P1`/`P2` curve and sampled on the tension
// one. Transposing these two is the defect this module's
// doc comment describes; they are not interchangeable.
let tx = coef * ((1.0 - x) * P1 + x * P2) + x * x * x;
if (tx - alpha).abs() < 1e-5 {
break;
}
@@ -911,50 +998,37 @@ mod android_fling_spline {
x_min = x;
}
}
position[i] = coef * ((1.0 - x) * P1 + x * P2) + x * x * x;
let mut y_max = 1.0f32;
let (mut y, mut coef_y);
loop {
y = y_min + (y_max - y_min) / 2.0;
coef_y = 3.0 * y * (1.0 - y);
let dy = coef_y * ((1.0 - y) * START_TENSION + y * END_TENSION) + y * y * y;
if (dy - alpha).abs() < 1e-5 {
break;
}
if dy > alpha {
y_max = y;
} else {
y_min = y;
}
}
time[i] = coef_y * ((1.0 - y) * P1 + y * P2) + y * y * y;
*slot = coef * ((1.0 - x) * START_TENSION + x * END_TENSION) + x * x * x;
}
position[NB_SAMPLES] = 1.0;
time[NB_SAMPLES] = 1.0;
Spline { position, time }
position
}
static SPLINE: OnceLock<Spline> = OnceLock::new();
static SPLINE_POSITION: OnceLock<[f32; NB_SAMPLES + 1]> = OnceLock::new();
/// The fraction of total distance covered at `time_fraction` (0..=1
/// of the fling's total duration). Finds the bracketing samples in
/// `SPLINE_TIME` and interpolates linearly between their matching
/// `SPLINE_POSITION` entries, exactly as AOSP's `SplineOverScroller
/// .flingPosition` does.
pub(super) fn distance_fraction(time_fraction: f32) -> f32 {
let spline = SPLINE.get_or_init(build);
/// Sample the curve at `time_fraction` (0..=1 of the fling's total
/// duration), exactly as AOSP's `SplineOverScroller.update` and
/// Compose's `AndroidFlingSpline.flingPosition` do.
pub(super) fn sample(time_fraction: f32) -> SplineSample {
let position = SPLINE_POSITION.get_or_init(build);
let t = time_fraction.clamp(0.0, 1.0);
let index = ((t * NB_SAMPLES as f32) as usize).min(NB_SAMPLES - 1);
let t_inf = spline.time[index];
let t_sup = spline.time[index + 1];
let d_inf = spline.position[index];
let d_sup = spline.position[index + 1];
let span = t_sup - t_inf;
if span <= 0.0 {
d_inf
} else {
d_inf + (d_sup - d_inf) * (t - t_inf) / span
let index = (t * NB_SAMPLES as f32) as usize;
if index >= NB_SAMPLES {
// The end of the fling: all of the distance covered and
// nothing left moving. AOSP's `distanceCoef = 1f` /
// `velocityCoef = 0f` defaults, which its
// `if (index < NB_SAMPLES)` leaves in place.
return SplineSample {
distance_fraction: 1.0,
velocity_fraction: 0.0,
};
}
let t_inf = index as f32 / NB_SAMPLES as f32;
let t_sup = (index + 1) as f32 / NB_SAMPLES as f32;
let velocity_fraction = (position[index + 1] - position[index]) / (t_sup - t_inf);
SplineSample {
distance_fraction: position[index] + (t - t_inf) * velocity_fraction,
velocity_fraction,
}
}
}
@@ -964,6 +1038,17 @@ mod android_fling_spline {
/// friction of `0.84` per frame at 60Hz corresponds to
/// (`ln(0.78)/ln(0.9)`, `SplineOverScroller.DECELERATION_RATE`).
const FLING_FRICTION: f32 = 0.015;
/// AOSP's own look-and-feel tuning constant, the argument
/// `SplineOverScroller`'s constructor passes to `computeDeceleration` when
/// it builds `mPhysicalCoeff` -- *not* the scroll friction, which is a
/// different number used a different place in the same formula. This was
/// `FLING_FRICTION` here until 2026-09-07, making the coefficient 56x too
/// small, which put an `ln` of a 56x-too-large ratio through
/// `exp(_/(rate-1))`: an ordinary flick came out lasting **30 seconds**
/// instead of 1.6. Nothing could see it while a finger fling never
/// animated at all (`List::fling`'s doc), which is why two defects had to
/// be fixed before either was visible.
const FLING_TUNING: f32 = 0.84;
fn deceleration_rate() -> f32 {
(0.78f32.ln()) / (0.9f32.ln())
}
@@ -975,11 +1060,15 @@ const GRAVITY_EARTH: f32 = 9.80665;
/// ported the same way Compose's `FlingCalculator` is, including its
/// `density`-dependent physical coefficient (`computeDeceleration`,
/// `GravityEarth * 39.37 * density * 160 * friction`). Density and
/// velocity/distance units cancel algebraically as long as velocity and
/// the returned distance share one pixel space (physical or logical) --
/// [`crate::widget::List::fling`] relies on exactly that cancellation to
/// avoid needing a display density of its own, since iris's `List`
/// already works in logical (density-independent) pixels throughout.
/// `density` is physical pixels per `dp`, and the velocity handed in has
/// to be in those same physical pixels -- which is what a touch event
/// carries. It does **not** cancel out: `duration` is
/// `exp(ln(k*v/C) / (rate-1))` with `C` proportional to density, so the
/// wrong density changes how long a fling lasts exponentially rather than
/// scaling it. An earlier version of this comment claimed the opposite and
/// `List::fling` passed `1.0`; on a 2.75-density screen that gave a
/// one-second flick a 45-second coast (measured 2026-09-07). `List` reads
/// its density from the painter now.
pub struct FlingCalculator {
physical_coefficient: f32,
}
@@ -987,7 +1076,7 @@ pub struct FlingCalculator {
impl FlingCalculator {
pub fn new(density: f32) -> Self {
Self {
physical_coefficient: GRAVITY_EARTH * 39.37 * density * 160.0 * FLING_FRICTION,
physical_coefficient: GRAVITY_EARTH * 39.37 * density * 160.0 * FLING_TUNING,
}
}
@@ -1027,17 +1116,35 @@ impl FlingCalculator {
}
/// The signed distance covered by `elapsed` into a fling of this
/// `velocity` that started at `t0` -- what a per-frame ticker
/// (`List::tick_fling`) calls to find how far to have scrolled by now.
/// Clamped to the full `distance()` once `elapsed` reaches
/// `duration()`, so a caller need not special-case "past the end."
/// `velocity` -- what a per-frame ticker (`List::tick_fling`) calls to
/// find how far to have scrolled by now. Clamped to the full
/// `distance()` once `elapsed` reaches `duration()`, so a caller need
/// not special-case "past the end."
pub fn position_at(&self, velocity: f32, elapsed: Duration) -> f32 {
let duration = self.duration(velocity);
if duration.is_zero() {
return 0.0;
}
let fraction = (elapsed.as_secs_f32() / duration.as_secs_f32()).min(1.0);
self.distance(velocity) * android_fling_spline::distance_fraction(fraction)
let fraction = elapsed.as_secs_f32() / duration.as_secs_f32();
self.distance(velocity) * android_fling_spline::sample(fraction).distance_fraction
}
/// The signed *speed* at `elapsed` into the same fling, in the units
/// `velocity` was given in -- AOSP's `mCurrVelocity` and Compose's
/// `FlingInfo.velocity`. It falls from roughly `velocity` at the start
/// to zero at `duration()`, which is the whole difference between a
/// fling and a constant-speed slide, so it is what
/// `List::tick_fling`'s debug line reports: successive frames printing
/// a shrinking number is the evidence that the curve is being followed
/// at all.
pub fn velocity_at(&self, velocity: f32, elapsed: Duration) -> f32 {
let duration = self.duration(velocity);
if duration.is_zero() {
return 0.0;
}
let fraction = elapsed.as_secs_f32() / duration.as_secs_f32();
android_fling_spline::sample(fraction).velocity_fraction * self.distance(velocity)
/ duration.as_secs_f32()
}
}
@@ -1147,6 +1254,39 @@ mod fling_calculator_tests {
}
}
/// The absolute numbers, against AOSP's own formula worked by hand --
/// the one thing every other test here cannot see, because they all
/// compare this calculator with itself (monotonic, signed, integrates
/// to the closed form) and so pass just as happily with a coefficient
/// 56x out. That is exactly the state this file was in: an ordinary
/// flick lasted 30 seconds on the emulator and every test was green.
///
/// `SplineOverScroller` at ppi = 2.75*160 = 440:
/// `mPhysicalCoeff = 9.80665 * 39.37 * 440 * 0.84 = 142,698`;
/// `l = ln(0.35 * v / (0.015 * mPhysicalCoeff))`;
/// `duration = exp(l / (DECELERATION_RATE - 1))`.
/// For v = 3000 px/s that is 0.592s and 621px; for 11444 px/s,
/// 1.586s.
#[test]
fn a_flick_lasts_what_aosps_own_formula_says_it_does() {
let calc = FlingCalculator::new(2.75);
let slow = calc.duration(3000.0).as_secs_f32();
assert!(
(slow - 0.592).abs() < 0.02,
"3000px/s at density 2.75 should settle in ~0.59s, got {slow}s"
);
let distance = calc.distance(3000.0);
assert!(
(distance - 621.5).abs() < 5.0,
"3000px/s at density 2.75 should travel ~621px, got {distance}"
);
let fast = calc.duration(11444.0).as_secs_f32();
assert!(
(fast - 1.586).abs() < 0.05,
"11444px/s at density 2.75 should settle in ~1.59s, got {fast}s"
);
}
#[test]
fn position_at_is_monotonic_and_clamped_past_the_end() {
let calc = FlingCalculator::new(1.0);
@@ -1169,6 +1309,93 @@ mod fling_calculator_tests {
total
);
}
/// The table itself, against AOSP's own entries. Every number here
/// came out of `benches/fling_spline_reference.py`, which is a
/// separate hand transcription of `OverScroller.java` and
/// `SplineBasedDecay.kt` -- so this is the one test in the file that
/// is not the Rust code grading its own homework, and the only kind
/// that could have caught the transposed build loop
/// `android_fling_spline`'s doc describes.
///
/// The property that names the old defect directly: the curve is
/// **not** the identity. At a tenth of the way through its time a
/// fling has covered 27.4% of its distance, and at half its time
/// 85.8%. The old table returned 0.100 and 0.500 -- a constant-speed
/// slide -- so the two `assert!`s below fail by a factor of three.
#[test]
fn the_spline_matches_aosps_own_table() {
for (t, expected) in [
(0.0f32, 0.000023f32),
(0.1, 0.274002),
(0.25, 0.583811),
(0.5, 0.858411),
(0.75, 0.971068),
(0.9, 0.995811),
(1.0, 1.0),
] {
let got = android_fling_spline::sample(t).distance_fraction;
assert!(
(got - expected).abs() < 1e-4,
"distance fraction at t={t}: got {got}, AOSP says {expected}"
);
}
// Speed falls monotonically to nothing -- the difference between
// a fling and a slide, and what the abrupt stop was.
let mut last = f32::INFINITY;
for step in 0..=100 {
let v = android_fling_spline::sample(step as f32 / 100.0).velocity_fraction;
assert!(v <= last + 1e-4, "speed rose at t={step}/100: {v} > {last}");
last = v;
}
assert_eq!(android_fling_spline::sample(1.0).velocity_fraction, 0.0);
}
/// The same curve carried through `distance`/`duration` at Iris's own
/// phone density (2.55, `docs/bench/iris-phone-v2-2026-09-06.md`),
/// again with every number from `benches/fling_spline_reference.py`.
/// A fling's *speed* a third of the way through is 4733px/s out of an
/// initial 11064 -- what a reader sees as deceleration, and the
/// quantity that was constant before this.
///
/// The sample fractions are deliberately not round: the velocity
/// coefficient is piecewise constant across each of the 100 samples,
/// so `0.75` sits exactly on a step and the assertion would be about
/// which side of it the last float landed rather than about the curve.
#[test]
fn a_flick_decelerates_the_way_aosp_says_it_does() {
let calc = FlingCalculator::new(2.55);
let velocity = 11064.0f32;
let duration = calc.duration(velocity);
assert!(
(duration.as_secs_f32() - 1.6357).abs() < 0.01,
"duration {duration:?}"
);
assert!(
(calc.distance(velocity) - 6334.2).abs() < 5.0,
"distance {}",
calc.distance(velocity)
);
for (fraction, position, speed) in [
(0.125f32, 2123.3f32, 9202.1f32),
(0.335, 4458.3, 4733.0),
(0.505, 5459.0, 2649.6),
(0.755, 6158.7, 950.9),
] {
let at = duration.mul_f32(fraction);
let got_position = calc.position_at(velocity, at);
let got_speed = calc.velocity_at(velocity, at);
assert!(
(got_position - position).abs() < 5.0,
"position at {fraction} of the fling: got {got_position}, AOSP says {position}"
);
assert!(
(got_speed - speed).abs() < 20.0,
"speed at {fraction} of the fling: got {got_speed}, AOSP says {speed}"
);
}
assert_eq!(calc.velocity_at(velocity, duration), 0.0);
}
}
#[cfg(test)]
@@ -1410,3 +1637,153 @@ mod drag_arbiter_tests {
);
}
}
/// [`DragGesture`] end to end, at the shape Android actually delivers a
/// flick in. The arbiter and the tracker each behave correctly on their
/// own (the two modules above); what these cover is the join between them
/// at release, which is where the phone's missing fling lived.
#[cfg(test)]
mod drag_gesture_tests {
use super::*;
use std::sync::LazyLock;
static BASE: LazyLock<Instant> = LazyLock::new(Instant::now);
fn t(ms: u64) -> Instant {
*BASE + Duration::from_millis(ms)
}
/// A `UiRenderState` with nothing in it. `DragGesture` only ever calls
/// `capture_pointer`/`release_pointer` on it, which are bookkeeping on
/// a `Cell` and need no widget tree behind them.
fn render() -> UiRenderState {
UiRenderState::new()
}
/// The id `capture_pointer` records. Any id will do -- nothing here
/// resolves it -- so it comes from a real (empty) widget registry
/// rather than being fabricated.
fn some_id(ui: &mut UiData) -> WidgetId {
ui.widgets.add_strong(Rect::new(UiColor::WHITE)).id()
}
/// **The phone's shape.** A 120Hz flick reaches the app as very few
/// `MotionEvent`s, so before `on_touch_event` replayed the historical
/// samples inside them a whole gesture could be press, one move past
/// the slop, release. That released at `v=0` -- `velocity()` needs two
/// samples and the single `Pan` frame was the only one -- so the list
/// stopped dead under the finger while the same gesture driven as many
/// evenly-spaced `ui-trace` events flung perfectly. The press is a
/// sample now, so even this minimum still carries a real speed.
#[test]
fn a_flick_delivered_as_one_move_frame_still_releases_with_a_velocity() {
let mut ui = UiData::default();
let id = some_id(&mut ui);
let r = render();
let mut g = DragGesture::new();
g.handle(
&r,
id,
CursorSense::PressStart(CursorButton::Left),
Vec2::ZERO,
t(0),
false,
);
g.handle(
&r,
id,
CursorSense::Pressing(CursorButton::Left),
Vec2::new(0.0, 100.0),
t(8),
false,
);
let out = g.handle(
&r,
id,
CursorSense::PressEnd(CursorButton::Left),
Vec2::new(0.0, 100.0),
t(16),
false,
);
// (100 - DRAG_SLOP) px over the 8ms between the press and the one
// move that arrived: a real measurement of what was delivered, not
// an estimate of what the finger "probably" did in between.
let expected = (100.0 - DRAG_SLOP) / 0.008;
match out {
GestureOutcome::Released(Some(v)) => {
assert!((v - expected).abs() < 1.0, "expected ~{expected}, got {v}");
}
other => panic!("expected a released pan, got {other:?}"),
}
}
/// The other half of the same join, and the case the fix had no
/// reason to touch: a press and release with no motion at all is a
/// tap, and must not acquire a velocity from the seeded press sample.
#[test]
fn a_tap_is_still_a_tap_and_flings_nothing() {
let mut ui = UiData::default();
let id = some_id(&mut ui);
let r = render();
let mut g = DragGesture::new();
g.handle(
&r,
id,
CursorSense::PressStart(CursorButton::Left),
Vec2::ZERO,
t(0),
false,
);
let out = g.handle(
&r,
id,
CursorSense::PressEnd(CursorButton::Left),
Vec2::ZERO,
t(20),
false,
);
assert_eq!(out, GestureOutcome::Tapped);
}
/// A long-press selection released while the finger was still moving
/// must not fling either -- `Released(None)`, never the tracked
/// velocity. Also untouched by the press-seeding above, which is why
/// it is checked here rather than assumed.
#[test]
fn a_selection_release_carries_no_velocity() {
let mut ui = UiData::default();
let id = some_id(&mut ui);
let r = render();
let mut g = DragGesture::new();
g.handle(
&r,
id,
CursorSense::PressStart(CursorButton::Left),
Vec2::ZERO,
t(0),
false,
);
// Held still past LONG_PRESS, which is what starts a selection.
g.handle(
&r,
id,
CursorSense::Pressing(CursorButton::Left),
Vec2::ZERO,
t(0) + LONG_PRESS,
false,
);
let out = g.handle(
&r,
id,
CursorSense::PressEnd(CursorButton::Left),
Vec2::new(0.0, 50.0),
t(0) + LONG_PRESS + Duration::from_millis(10),
false,
);
assert_eq!(out, GestureOutcome::Released(None));
}
}
+1
View File
@@ -51,6 +51,7 @@ fn cursor_at(pos: Vec2) -> CursorState {
exists: true,
buttons: Default::default(),
scroll_delta: Vec2::ZERO,
..Default::default()
}
}
+155 -9
View File
@@ -225,6 +225,21 @@ pub struct List {
/// (headless tests, a caller driving `tick_fling` by hand as
/// `bench_client.rs`'s scripted phases do).
redraw: Option<Arc<dyn RequestRedraw>>,
/// Physical pixels per `dp`, copied from the painter on every `draw`
/// -- what [`Self::fling`] hands `FlingCalculator`. 1.0 until this
/// list has been drawn once, which is also the only state in which a
/// fling is impossible (`fling` needs an anchor, and an anchor comes
/// from a draw).
///
/// It has to be the real one: the deceleration constant is
/// `GRAVITY * 39.37 * density * 160 * friction`, and the velocity fed
/// in is in the same physical pixels the touch events arrive in, so a
/// hardcoded 1.0 against a 2.75-density screen does not cancel out --
/// it makes the fling last exponentially too long. Measured on this
/// checkout's emulator, 2026-09-07, once flings could animate at all:
/// a flick that should coast for about a second ran for **45
/// seconds**.
density: f32,
/// Whether the last `draw` found no more content above the topmost
/// visible row (its top edge at or past the viewport's own top, with
/// no `prev_slot`) -- what `tick_fling` clamps a fling moving toward
@@ -244,7 +259,16 @@ pub struct List {
struct Fling {
calc: FlingCalculator,
velocity: f32,
started_at: Instant,
/// When the fling's own curve begins -- **the first `tick_fling`,
/// not the release**. It is set there rather than in `fling` so the
/// only clock this widget reads is the one its driver hands it: a
/// caller running frames on an explicit clock (`iris::harness`, and
/// `bench_client.rs`'s scripted phases) would otherwise start every
/// fling at the wall clock and advance it on a different one, and a
/// fling released at t=500ms would arrive already over. The
/// difference in a running app is at most one frame, since that is
/// how soon the fling is first ticked.
started_at: Option<Instant>,
applied: f32,
}
@@ -261,6 +285,7 @@ impl List {
last_viewport_len: 0.0,
fling: None,
redraw: None,
density: 1.0,
at_start: false,
at_end: false,
pending_tap: None,
@@ -418,11 +443,32 @@ impl List {
/// has no idea a finger came back down, and Android's own `Scroller`
/// relies on the view calling `abortAnimation` for the same reason.
///
/// Density cancels out of the underlying spline as long as velocity
/// and the distance it produces share one pixel space (see
/// `FlingCalculator`'s own doc) -- `List` works entirely in logical
/// pixels, so `1.0` here is not a placeholder for "unknown density,"
/// it is the correct density for a self-consistent unit system.
/// The density handed to `FlingCalculator` is this list's own
/// (`self.density`, taken from the painter in `draw`), not `1.0`: it
/// does **not** cancel out of the spline -- see `FlingCalculator`'s
/// doc, which used to claim the opposite, and the 45-second coast that
/// claim produced.
///
/// **Sets the fling; it does not drive it.** A fling moves only while
/// something calls [`Self::tick_fling`] once per frame, and what does
/// that in a running app is `UiData::tick_animations`, over the ids
/// `UiData::animate` was given. So a caller starting a fling from a
/// gesture registers the list in the same breath:
///
/// ```ignore
/// list(ui).fling(-velocity);
/// let id = list.id();
/// ui.ui_mut().animate(id);
/// ```
///
/// Split that way because the two halves have different owners: the
/// velocity is the list's business, and whether anything animates at
/// all is the frame loop's. Missing the second call is what a finger
/// fling did on Iris's phone for two builds -- the velocity was right
/// and nothing ever advanced it, which looks exactly like a list that
/// stops dead under the finger. A caller driving frames itself
/// (`bench_client.rs`'s fling phase, the headless tests) calls
/// `tick_fling` directly instead and does not register.
pub fn fling(&mut self, velocity_px_per_s: f32) {
// A NaN/inf velocity (a `VelocityTracker::velocity()` divide-by-
// near-zero span, or a caller passing a raw device value straight
@@ -436,9 +482,9 @@ impl List {
return;
}
self.fling = Some(Fling {
calc: FlingCalculator::new(1.0),
calc: FlingCalculator::new(self.density),
velocity: velocity_px_per_s,
started_at: Instant::now(),
started_at: None,
applied: 0.0,
});
}
@@ -451,6 +497,17 @@ impl List {
self.fling.is_some()
}
/// The velocity a fling in progress is coasting at, in this list's
/// own pixel space -- `None` when nothing is flinging. What a
/// release's decision looks like from the outside: a
/// `GestureOutcome::Released(Some(v))` is the only thing that puts a
/// value here, so a test (or a diagnostic) can read what the gesture
/// measured at the place it landed, rather than re-timing the
/// gesture itself.
pub fn fling_velocity(&self) -> Option<f32> {
self.fling.as_ref().map(|f| f.velocity)
}
/// Cancel any fling in progress with no further movement -- the next
/// touch-down's job, per `fling`'s own doc.
pub fn cancel_fling(&mut self) {
@@ -472,12 +529,26 @@ impl List {
let Some(f) = &mut self.fling else {
return false;
};
let elapsed = now.saturating_duration_since(f.started_at);
let elapsed = now.saturating_duration_since(*f.started_at.get_or_insert(now));
let target = f.calc.position_at(f.velocity, elapsed);
let delta = target - f.applied;
f.applied = target;
let settled_on_schedule = elapsed >= f.calc.duration(f.velocity);
let velocity = f.velocity;
// The evidence that the spline is actually being followed, at the
// one granularity where a linear coast and a decelerating one look
// different: successive `dy` and `speed` shrinking. It was neither
// observable nor observed while `distance_fraction` returned `t`
// (`android_fling_spline`'s doc), which is why this is here rather
// than the total-travel line the release log already carries.
log::debug!(
"iris fling tick: t={:.3}s dy={:+.1}px speed={:.0}px/s of {:.0} left={:.1}px",
elapsed.as_secs_f32(),
delta,
f.calc.velocity_at(velocity, elapsed),
velocity,
f.calc.distance(velocity) - target,
);
self.scroll(delta);
// Clamp: a fling moving toward the start that has already reached
@@ -876,8 +947,20 @@ impl List {
const GENEROUS_PADDING: f32 = 100_000.0;
impl Widget for List {
/// A `List` animates exactly one thing, a fling
/// ([`Self::tick_fling`]). The registration that makes this run is
/// `UiData::animate` beside the `fling` call -- see `fling`'s own doc.
fn tick(&mut self, now: Instant) -> bool {
self.tick_fling(now)
}
fn draw(&mut self, painter: &mut Painter) -> Size {
let axis = self.axis;
// Learned from the frame rather than passed in: a fling's
// deceleration is a physical quantity and needs the real display
// density, and `draw` is where this widget meets the only thing
// that knows it. See `fling`.
self.density = painter.density();
let output_len = painter.output_size().axis(axis);
self.viewport_len = painter.region().axis(axis).len().to_abs(output_len);
@@ -1573,6 +1656,56 @@ mod tests {
assert!(!rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
}
/// The half `fling` itself does not do: a registered list is advanced
/// by the frame loop's own driver, and unregisters itself when the
/// fling settles. Written against `UiData::tick_animations` rather
/// than `tick_fling` because the defect it pins is exactly the gap
/// between the two -- a fling with a correct velocity that nothing
/// ever advanced, which is what a finger fling did on the phone.
#[test]
fn a_registered_fling_is_driven_by_tick_animations_and_then_unregisters() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
let before = rsc
.ui
.widgets
.get(&list_weak)
.unwrap()
.anchor_position_display();
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(-8000.0);
rsc.ui.animate(list_weak.id());
let start = Instant::now();
let mut animating = true;
let mut steps = 0;
while animating && steps < 600 {
animating = rsc
.ui
.tick_animations(start + std::time::Duration::from_millis(steps * 16));
render.update(&root, &mut rsc);
steps += 1;
}
assert!(!animating, "the driver never stopped within 600 frames");
assert!(steps > 1, "the fling settled without ever moving");
assert!(!rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
assert_ne!(
before,
rsc.ui
.widgets
.get(&list_weak)
.unwrap()
.anchor_position_display(),
"the list is where it started -- the fling was registered but never applied"
);
// Nothing left registered, so the next frame costs nothing: the
// path out of `animate` is the `false` answer, not a caller
// remembering to remove it.
assert!(!rsc.ui.tick_animations(start));
}
#[test]
fn fling_distance_is_positive_toward_the_end() {
let mut rsc = TestRsc {
@@ -1670,6 +1803,19 @@ mod tests {
w[1]
);
}
// Non-increasing is not deceleration: a fling that coasts at a
// constant speed and then stops dead satisfies every `<=` above,
// and that is exactly what iris shipped until 2026-09-07
// (`android_fling_spline`'s doc). Over the samples collected here
// -- the earliest part of the curve, since row 0 leaves the loaded
// extents soon after -- AOSP's spline has already lost more than
// a fifth of its speed.
let (first, last) = (deltas[1], *deltas.last().unwrap());
assert!(
last < first * 0.8,
"fling barely slowed across {} ticks: {first} -> {last}",
deltas.len()
);
}
#[test]
+1 -2
View File
@@ -1,6 +1,5 @@
use super::*;
use crate::prelude::*;
use std::time::Instant;
// these methods should "not require any context" (require unit) because they're in core
widget_trait! {
@@ -111,7 +110,7 @@ widget_trait! {
let id = ctx.widget.id();
let (sense, pos) = (ctx.data.sense, ctx.data.cursor.pos);
ctx.widget(rsc)
.drag(ctx.data.render, id, sense, pos, Instant::now());
.drag(ctx.data.render, id, sense, pos, ctx.data.cursor.time);
},
)
.add(state)
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "transcript-fixture"
version.workspace = true
edition.workspace = true
# The bench fixture, opened as a real transcript screen with no server --
# docs/RUST.md's "Three test layers". It was `iris-android-app`'s
# `bench_client.rs` alone until 2026-09-07; the fixture-loading and
# fold-driving half moved here so the headless harness (layer 1), the
# phone-shaped desktop window (layer 2) and the Android bench (layer 3)
# all open the *same* screen from the same bytes, per AGENTS.md's rule
# that nothing UI-shaped lives in a platform crate.
[dependencies]
iris = { path = ".." }
transcript-ui = { path = "../transcript-ui" }
client-core = { path = "../../client-core" }
event-model = { path = "../../event-model" }
# `float_roundtrip` for the same reason `server/Cargo.toml` has it: a `ts`
# read back must be the one that was written (AGENTS.md).
serde_json = { version = "1", features = ["float_roundtrip"] }
[dev-dependencies]
winit = { workspace = true }
+73
View File
@@ -0,0 +1,73 @@
//! Layer 2 of docs/RUST.md's "Three test layers": the fixture-backed
//! transcript screen in a phone-shaped window, for looking at.
//!
//! iris/run-headless.sh phone --phone --shot /tmp/phone.png -- -p transcript-fixture
//!
//! `--phone` sets the headless sway output to the phone's own 1080x2424
//! and exports `IRIS_SCALE=2.55`, so this draws at the density Iris's
//! phone reports (`transcript_fixture::PHONE_SCALE`) rather than the
//! desktop's 1.0 -- same screen, same fixture and the same folding as
//! the Android bench and the headless tests, so what differs between a
//! screenshot here and one from the phone is the renderer, never the
//! data.
//!
//! No server: `transcript-fixture` embeds the transcript. Colour,
//! spacing, type and anything a person has to *see* is answered here;
//! anything with an assertion behind it belongs in `tests/
//! phone_screen.rs` one layer down.
use iris::prelude::*;
use winit::{dpi::PhysicalSize, window::WindowAttributes};
fn main() {
DefaultApp::<Client>::run();
}
#[derive(DefaultUiState)]
pub struct Client {
ui_state: DefaultUiState,
#[allow(dead_code)]
screen: Option<transcript_ui::TranscriptScreen>,
}
impl DefaultAppState for Client {
fn window_attributes() -> WindowAttributes {
WindowAttributes::default()
.with_title("iris transcript (bench fixture)")
.with_inner_size(PhysicalSize::new(
transcript_fixture::PHONE_WIDTH,
transcript_fixture::PHONE_HEIGHT,
))
}
fn new(
mut ui_state: DefaultUiState,
rsc: &mut DefaultRsc<Self>,
_: Proxy<Self::Event>,
) -> Self {
let screen = match transcript_fixture::open(rsc, &mut ui_state) {
Ok(opened) => {
// A fling coasts only while something asks for the next
// frame; on the desktop that is the window's own redraw
// request (`List::fling`'s doc).
let handle = rsc.tasks.redraw_handle();
(opened.screen.list)(rsc).set_redraw_handle(handle);
Some(opened.screen)
}
// On screen rather than a panic: this window exists to be
// looked at, and "the fixture stopped folding" is something
// to read, not a process that vanished (UI_RULES.md).
Err(message) => {
let text = wtext(format!("Couldn't fold the bench fixture: {message}"))
.color(Color::WHITE)
.wrap(true)
.pad(dp(16))
.add_strong(rsc)
.any();
ui_state.set_root(text);
None
}
};
Self { ui_state, screen }
}
}
+166
View File
@@ -0,0 +1,166 @@
//! The checked-in bench fixture, opened as a real transcript screen with
//! no server -- shared by every layer of docs/RUST.md's test rig.
//!
//! The bytes are `app/bench-fixture/assets/transcript.jsonl` (1,915,760
//! bytes, generated by `app/bench-fixture/generate.py`, never a real
//! transcript -- that file's own README), embedded with `include_str!`.
//! The first [`BACKLOG_COUNT`] non-blank lines are the opening window,
//! folded once through `client_core::transcript_fold::fold_page` exactly
//! as a real `/transcript` page would be; the rest are the streaming
//! tail, replayed one at a time through `fold_event` the way a live SSE
//! frame arrives.
//!
//! This half used to live in `iris-android-app`'s `bench_client.rs`, and
//! moved here on 2026-09-07 so the headless harness and a desktop window
//! open the same screen from the same bytes (AGENTS.md: nothing
//! UI-shaped in a platform crate). What stayed there is the JNI half --
//! the clipboard, the battery sampler, the IME calls and the report.
use client_core::transcript_fold::{TranscriptItem, TranscriptRow, fold_page, group_tool_runs};
use event_model::SeqEvent;
use iris::prelude::*;
/// bench-fixture/README.md: the first `BACKLOG_COUNT` non-blank lines are
/// the opening window; the rest are the streaming tail. Kept in sync with
/// `BenchFixture.kt`'s identical constant by hand -- both read the same
/// checked-in file, so a mismatch would only mean the two apps' bench
/// builds open a different split of it, not a wrong-vs-right answer.
pub const BACKLOG_COUNT: usize = 3200;
const FIXTURE_JSONL: &str = include_str!("../../../app/bench-fixture/assets/transcript.jsonl");
/// Iris's phone as `docs/bench/iris-phone-v2-2026-09-06.md` and
/// `docs/IRIS_TODO.md` record it: a 1080x2424 surface at
/// `content_scale: 2.55`, 120Hz. Read from those reports, never typed
/// from memory -- every layer of the rig lays out at this size and
/// density so a screenshot and a headless assertion are about the same
/// screen.
pub const PHONE_WIDTH: f32 = 1080.0;
pub const PHONE_HEIGHT: f32 = 2424.0;
pub const PHONE_SCALE: f32 = 2.55;
/// 120Hz, the refresh rate that report ran at: 8.3ms a frame.
pub const PHONE_FRAME_MS: u64 = 8;
pub fn phone_size() -> Vec2 {
Vec2::new(PHONE_WIDTH, PHONE_HEIGHT)
}
/// The fixture split the way the wire delivers it: raw JSON values for
/// the opening page (`fold_page` takes a page of wire JSON, same as a
/// real `/transcript` response) and parsed `SeqEvent`s for the tail
/// (`fold_event` takes one live event at a time, same as an SSE frame).
pub struct Fixture {
pub backlog: Vec<serde_json::Value>,
pub stream_tail: Vec<SeqEvent>,
}
impl Fixture {
/// Parses the whole fixture. Panics on malformed input: this is a
/// generated file compiled into the binary, so a parse failure is a
/// broken build rather than a condition a caller could recover from
/// (CODE_RULES: separate recoverable conditions from programmer
/// error).
pub fn parse() -> Self {
let mut backlog = Vec::with_capacity(BACKLOG_COUNT);
let mut stream_tail = Vec::new();
for (i, line) in FIXTURE_JSONL
.lines()
.filter(|line| !line.trim().is_empty())
.enumerate()
{
let value: serde_json::Value =
serde_json::from_str(line).expect("bench fixture is generated JSON, always valid");
if i < BACKLOG_COUNT {
backlog.push(value);
} else {
stream_tail.push(
serde_json::from_value(value)
.expect("bench fixture event matches event-model's SeqEvent"),
);
}
}
Self {
backlog,
stream_tail,
}
}
/// The opening page folded into transcript items -- the same
/// `fold_page` a real first load runs. `Err` carries the fold's own
/// message, which a caller shows on screen rather than panicking, so
/// a fixture that stops folding is visible in the app instead of
/// being a crash on launch.
pub fn backlog_items(&self) -> Result<Vec<TranscriptItem>, String> {
fold_page(&self.backlog)
}
}
/// The fixture's opening page as the rows a screen is built from.
pub fn rows(items: &[TranscriptItem]) -> Vec<TranscriptRow> {
group_tool_runs(items)
}
/// Everything a caller needs to run the fixture as an app screen would:
/// the screen, the folded items behind it, and the events not yet
/// streamed. The tree itself comes back separately from
/// [`build_screen`], since whoever takes it owns it.
pub struct Opened {
pub screen: transcript_ui::TranscriptScreen,
pub items: Vec<TranscriptItem>,
/// The tail, for a caller that goes on replaying it one event at a
/// time through `fold_event`/`TranscriptScreen::apply` -- the
/// streaming phase of either app's benchmark.
pub stream_tail: Vec<SeqEvent>,
}
/// Build the transcript screen over the fixture's opening page, without
/// claiming the window's root -- `transcript_ui::build_tree`'s own split,
/// for a caller (the Android bench) that puts the screen inside a shell
/// of its own.
pub fn build_screen<Rsc: HasEvents>(rsc: &mut Rsc) -> Result<(Opened, StrongWidget), String>
where
Rsc::State: FocusHost + OpenUrl,
{
let fixture = Fixture::parse();
let items = fixture.backlog_items()?;
let (screen, tree) = transcript_ui::build_tree(rsc, rows(&items));
Ok((
Opened {
screen,
items,
stream_tail: fixture.stream_tail,
},
tree,
))
}
/// [`build_screen`] with the screen as the window's root -- what the
/// headless harness and the desktop window open.
pub fn open<Rsc: HasEvents>(rsc: &mut Rsc, ui_state: &mut impl HasRoot) -> Result<Opened, String>
where
Rsc::State: FocusHost + OpenUrl,
{
let (opened, tree) = build_screen(rsc)?;
ui_state.set_root(tree);
Ok(opened)
}
#[cfg(test)]
mod tests {
use super::*;
/// The split is what both bench clients assume; a fixture that
/// stopped having a streaming tail would make the Android bench's
/// stream phase silently measure nothing.
#[test]
fn the_fixture_has_a_backlog_and_a_streaming_tail() {
let fixture = Fixture::parse();
assert_eq!(fixture.backlog.len(), BACKLOG_COUNT);
assert!(
fixture.stream_tail.len() >= 400,
"the stream phase replays 400 events; the fixture has {}",
fixture.stream_tail.len()
);
assert!(!fixture.backlog_items().expect("the page folds").is_empty());
}
}
@@ -0,0 +1,175 @@
//! Layer 1 of docs/RUST.md's "Three test layers": the real transcript
//! screen, over the real bench fixture, at the phone's size and density,
//! driven by `iris::harness` with no window, no compositor and no GPU.
//!
//! Every gesture here is a file under `touch/` -- see
//! `flick-120hz.touch` for why the *shape* of the delivery is the whole
//! point, and why the emulator cannot produce it (a `ui-trace` swipe is
//! many evenly-spaced events; a finger at 120Hz is five samples in
//! 20ms).
use iris::harness::{Harness, TouchScript};
use iris::prelude::*;
use transcript_fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
/// The screen open on the fixture, framed twice: once to draw, once for
/// `List::repair_anchor` to resolve the opening `snap_end` into a real
/// anchor, which is what every assertion about scroll position reads.
fn opened() -> (Harness, transcript_ui::TranscriptScreen) {
let mut h = Harness::new(phone_size(), PHONE_SCALE);
let opened = transcript_fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
h.frame(0);
h.frame(PHONE_FRAME_MS);
(h, opened.screen)
}
fn script(name: &str, text: &str) -> TouchScript {
TouchScript::parse(text).unwrap_or_else(|e| panic!("{name}: {e}"))
}
fn offset(h: &mut Harness, screen: &transcript_ui::TranscriptScreen) -> String {
(screen.list)(&mut h.rsc).anchor_position_display()
}
/// (a) and (b) together, because the second is only meaningful if the
/// first happened: the recorded flick must release with a real velocity
/// (`GestureOutcome::Released(Some(v))`, which is the only thing that
/// puts a value in `List::fling_velocity`), and the list must then
/// actually travel and stop on the spline's own schedule.
#[test]
fn a_recorded_flick_releases_with_a_velocity_and_flings_the_list() {
let (mut h, screen) = opened();
let before = offset(&mut h, &screen);
let flick = script("flick-120hz", include_str!("../touch/flick-120hz.touch"));
h.replay(&flick);
let velocity = (screen.list)(&mut h.rsc)
.fling_velocity()
.expect("the flick must release as a pan with a velocity, not a tap");
assert!(
velocity.abs() > 1_000.0,
"a 188px, 16ms flick is thousands of px/s; got {velocity}"
);
// Android's own spline says how long a fling at this speed runs. The
// list learns its density from the painter, so this is the same
// curve it is using.
let expected = FlingCalculator::new(PHONE_SCALE).duration(velocity);
let end = flick.end_ms() + expected.as_millis() as u64 * 2;
let mut settled_at = None;
let mut t = flick.end_ms();
while t <= end {
h.frame(t);
if settled_at.is_none() && !(screen.list)(&mut h.rsc).is_scrolling() {
settled_at = Some(t);
}
t += PHONE_FRAME_MS;
}
let after = offset(&mut h, &screen);
assert_ne!(
before, after,
"the fling ticks must have moved the list off where the flick left it"
);
let settled_at = settled_at.expect("the fling must stop on its own, not run forever");
let ran_for = settled_at - flick.end_ms();
assert!(
ran_for <= expected.as_millis() as u64 + PHONE_FRAME_MS * 2,
"the fling ran {ran_for}ms against the spline's own {}ms",
expected.as_millis()
);
}
/// The half the flick fix had no reason to touch: a tap must decide
/// `Tapped`, which means no velocity anywhere and nothing moved.
#[test]
fn a_tap_on_a_row_moves_nothing() {
let (mut h, screen) = opened();
let before = offset(&mut h, &screen);
h.replay(&script("tap", include_str!("../touch/tap.touch")));
assert_eq!(
(screen.list)(&mut h.rsc).fling_velocity(),
None,
"a tap must not fling"
);
// Frames it would have moved in, had anything been moving.
h.frames_until(100, 400, PHONE_FRAME_MS);
assert_eq!(before, offset(&mut h, &screen), "a tap must scroll nothing");
assert_eq!(
h.state.opened_urls,
Vec::<String>::new(),
"no link was under this tap"
);
}
/// A press held past `LONG_PRESS` and then dragged selects text rather
/// than panning -- the other branch of the same arbiter the flick goes
/// through.
#[test]
fn a_long_press_and_drag_selects_text() {
let (mut h, screen) = opened();
let before = offset(&mut h, &screen);
h.replay(&script(
"long-press",
include_str!("../touch/long-press.touch"),
));
let selected = screen
.selected_text(&mut h.rsc)
.expect("a long-press then drag must leave text selected");
assert!(
!selected.trim().is_empty(),
"the selection covered no characters: {selected:?}"
);
assert_eq!(
before,
offset(&mut h, &screen),
"a selection must not also pan the list"
);
}
/// The composer sits on whatever the platform says the bottom of usable
/// space is -- the keyboard's inset while it is open
/// (`Composer::set_bottom_inset`, the path Android's
/// `on_insets_changed` feeds). Checked here rather than on the emulator
/// because it is a layout fact, and the emulator costs minutes.
#[test]
fn the_composer_sits_above_a_simulated_ime_inset() {
let (mut h, screen) = opened();
let height = h.size().y;
let field_bottom = |h: &mut Harness| {
h.render
.window_region(&screen.composer.field, &h.rsc)
.expect("the composer field is on screen")
.bot_right
.y
};
let closed = field_bottom(&mut h);
assert!(
closed <= height,
"the composer is off the bottom of the window even with no keyboard: {closed} > {height}"
);
// A Gboard-sized keyboard on this surface. Any real number would do;
// what matters is that the bar clears it.
let ime = 1000.0;
screen.composer.set_bottom_inset(&mut h.rsc, ime);
h.frame(PHONE_FRAME_MS * 2);
let open = field_bottom(&mut h);
assert!(
open <= height - ime,
"the keyboard covers the composer: its bottom is at {open}, the IME starts at {}",
height - ime
);
assert!(
(closed - open - ime).abs() < 1.0,
"the composer moved {} for a {ime}px inset",
closed - open
);
}
@@ -0,0 +1,21 @@
# A finger flick the shape Iris's phone delivers one, from
# docs/bench/iris-phone-v2-2026-09-06.md and docs/IRIS_TODO.md's
# "From the phone, 2026-09-06, 22:16": at 120Hz a flick reaches the app
# as DOWN, one or two MOVEs and UP inside a few frames, with the
# intermediate positions batched inside those MOVEs as historical
# samples (~4ms apart, the touch digitiser's own rate) rather than
# arriving as separate events. Each line here is one such sample, which
# is exactly what `IrisViewPeer::on_touch_event` replays through the
# sensors one at a time -- so the whole gesture is 20ms and five
# samples, and the velocity has to come out of *those*.
#
# Downward (increasing y) on purpose: the screen opens pinned to the
# newest end, so a flick the other way has nothing left to scroll to and
# the fling clamps on its first tick -- a pass that would prove nothing.
# Coordinates are physical pixels on a 1080x2424 surface.
0 down 540 1000
4 move 540 1040
8 move 540 1086
12 move 540 1138
16 move 540 1196
20 up 540 1196
@@ -0,0 +1,11 @@
# A long-press then a drag across the text: held past LONG_PRESS
# (500ms) without moving, which is what starts a selection rather than a
# pan, then dragged sideways so the selection actually covers
# something. A press alone leaves a collapsed caret and no selected
# text (`Selection::begin`), which is why this file does not stop at the
# hold.
0 down 300 1000
520 move 300 1000
560 move 700 1000
600 move 900 1000
640 up 900 1000
+5
View File
@@ -0,0 +1,5 @@
# The case the flick had no reason to touch: a press and release in one
# place, well inside DRAG_SLOP and well under LONG_PRESS. It must be a
# tap -- no pan, no velocity, nothing moved.
0 down 540 1000
80 up 540 1000
+2 -2
View File
@@ -52,7 +52,7 @@ pub mod tool;
use client_core::transcript_fold::TranscriptRow as FoldedRow;
use iris::prelude::*;
use selection::Selection;
use std::{cell::RefCell, rc::Rc, time::Instant};
use std::{cell::RefCell, rc::Rc};
pub struct TranscriptScreen {
/// The transcript's own `List` -- exposed so a caller can read
@@ -412,7 +412,7 @@ where
row,
ctx.data.cursor.pos,
ctx.data.sense,
Instant::now(),
ctx.data.cursor.time,
ctx.data.render,
);
},
+2 -2
View File
@@ -29,7 +29,7 @@ use crate::tool::ToolRow;
use client_core::markdown_blocks::{Block, BlockKind, common_prefix, split_blocks};
use client_core::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
use iris::prelude::*;
use std::{cell::RefCell, rc::Rc, time::Instant};
use std::{cell::RefCell, rc::Rc};
/// The gap drawn between two markdown blocks of one message. A block used
/// to be separated by the blank line `markdown::render_markdown` put in
@@ -237,7 +237,7 @@ where
Some((key, pos, size)),
cursor,
ctx.data.sense,
Instant::now(),
ctx.data.cursor.time,
ctx.data.render,
);
// A *tap*, decided by the same `DragArbiter` the pan and
+8 -1
View File
@@ -294,7 +294,14 @@ impl Selection {
// happened to end with the finger still moving, and never a
// tap/long-press that never left `Undecided` -- exactly what
// `DragGesture`'s `Some(v)` already encodes.
GestureOutcome::Released(Some(v)) => list(ui).fling(-v),
GestureOutcome::Released(Some(v)) => {
list(ui).fling(-v);
// The half that actually makes it move -- see
// `List::fling`'s doc. Without it the velocity is
// computed, stored, and never advanced by anything.
let id = list.id();
ui.ui_mut().animate(id);
}
// A tap is nobody's business here -- `row.rs` reads it from
// the returned outcome and follows a link if one was under
// the finger.
+2 -2
View File
@@ -37,7 +37,7 @@ use crate::selection::Selection;
use client_core::tool_summary::{ToolInput, parse_tool_input};
use client_core::transcript_fold::{ToolState, TranscriptItem};
use iris::prelude::*;
use std::{cell::Cell, cell::RefCell, collections::HashMap, rc::Rc, time::Instant};
use std::{cell::Cell, cell::RefCell, collections::HashMap, rc::Rc};
/// A card's own fill: Surface 0, what Material's filled `Card` resolves to
/// under `Theme.kt`'s scheme. One step *above* the page, so a card reads
@@ -202,7 +202,7 @@ fn on_tap<Rsc: HasEvents>(
None,
ctx.data.cursor.pos,
ctx.data.sense,
Instant::now(),
ctx.data.cursor.time,
ctx.data.render,
);
if outcome == GestureOutcome::Tapped {