Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4274b8b8d0 | ||
|
|
73f956f8e0 | ||
|
|
038f6a3832 | ||
|
|
1121d7cc83 | ||
|
|
232de0ec53 | ||
|
|
e430880cde | ||
|
|
a999bd106a | ||
|
|
6840edf61e | ||
|
|
333220196e | ||
|
|
7f4ea7e8fd | ||
|
|
591128eef1 |
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -8,6 +8,79 @@ 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
|
||||
|
||||
+74
-17
@@ -351,13 +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."** *(Three defects fixed 2026-09-07;
|
||||
open until the phone says so. **The line to look for:**
|
||||
`adb logcat | grep "iris drag release"` -- `samples=1` or `span=0.0ms`
|
||||
means the batched samples are not reaching the tracker there, while a
|
||||
sensible span with `v=` in the thousands and
|
||||
`outcome=Released(Some(…))` means the gesture was measured right and
|
||||
anything still wrong is downstream.)* 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
|
||||
@@ -383,11 +397,29 @@ Iris's report, verbatim, with a screenshot. Phone: Mali-G715 (Vulkan),
|
||||
`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).
|
||||
- [x] **"Message box does not push up the scroll area."** *(Fixed
|
||||
2026-09-07: height and visibility are two JNI values now. Emulator:
|
||||
`iris insets: … bottom=883 ime_bottom=883 ime_visible=true`, composer
|
||||
box `31,2277..1048,2329` -> `31,1457..1048,1509`, and the list follows
|
||||
because it is `rest(1)` in the same `Span`.)* 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
|
||||
@@ -418,11 +450,12 @@ 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`, with `clearing_the_atlas_re_renders_cached_text_
|
||||
instead_of_reusing_it` run and passing (2026-09-07). Ticked on the
|
||||
code; still wants phone-side confirmation** -- no emulator here has a
|
||||
Vulkan adapter, and the GLES path may not destroy the surface at all,
|
||||
so the emulator cannot reproduce the state Iris photographed.
|
||||
**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`
|
||||
@@ -898,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.
|
||||
@@ -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.
|
||||
+225
-31
@@ -43,6 +43,29 @@ 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
|
||||
@@ -70,7 +93,7 @@ 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, rig not yet built)
|
||||
### 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
|
||||
@@ -78,42 +101,213 @@ 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/src/layout_tests.rs` already builds a tree over `UiRenderState`
|
||||
with no window, `sense.rs`'s gesture tests feed fabricated
|
||||
`CursorState`s with their own times, and `List::tick_fling` is
|
||||
driven by hand. Extend that into one harness that opens
|
||||
`transcript-ui`'s screen on `app/bench-fixture` (as `bench_client.rs`
|
||||
does on Android, no server), at the phone's logical size and
|
||||
`content_scale` from `docs/bench/iris-phone-v2-2026-09-06.md` (2.55,
|
||||
120Hz -- read, never typed from memory), ticks frames, and feeds a
|
||||
**replayed touch stream** from a trivial file of `(t_ms, action, x,
|
||||
y)` lines with `cursor.time` taken from the file. That is what the
|
||||
emulator cannot do at all: the batched 120Hz flick from the phone
|
||||
report becomes a deterministic test asserting on scroll offset and on
|
||||
the `iris drag release:` velocity. Anything about layout, scroll
|
||||
position, selection, focus or fold state is answered here, with
|
||||
assertions rather than eyes. Nothing renders; a widget's placed
|
||||
rectangle is the evidence.
|
||||
`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.**
|
||||
`iris/run-headless.sh` already runs a winit binary under a private
|
||||
sway and screenshots it with `grim`. Add a `--phone` mode (output and
|
||||
window at the phone's size and scale, with the scale reaching iris
|
||||
the way Android's does so dp layout runs at that density) and
|
||||
touch-shaped mouse input: a left-button drag pans and flings through
|
||||
`DragArbiter`, long-press selects, no hover. One input path, not a
|
||||
parallel one (`CODE_RULES`). Colour, spacing, text and anything a
|
||||
person has to see is answered here. `swaymsg seat - cursor
|
||||
move/press/release` drives it when a gesture is needed on screen.
|
||||
|
||||
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.
|
||||
|
||||
Pass condition for the rig: `cargo test` runs a fixture-backed headless
|
||||
transcript screen with a replayed flick and asserts a nonzero release
|
||||
velocity and a moved scroll offset; one command opens the same screen in
|
||||
a phone-shaped window and screenshots it. Record the commands here when
|
||||
it lands.
|
||||
### 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
|
||||
|
||||
|
||||
@@ -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.
|
||||
Generated
+37
-16
@@ -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",
|
||||
|
||||
+9
-1
@@ -89,7 +89,15 @@ 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
|
||||
|
||||
Generated
+12
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -22,7 +22,28 @@ android {
|
||||
// untested fallback path is its own defect. `build-apk.sh`'s
|
||||
// `cargo ndk -P` is kept at the same number.
|
||||
minSdk = 29
|
||||
targetSdk = 34
|
||||
// 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"
|
||||
}
|
||||
|
||||
@@ -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,39 +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();
|
||||
// **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: `setDecorFitsSystemWindows
|
||||
// (false)` above makes this an edge-to-edge window, 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;
|
||||
}
|
||||
((IrisView) v).applyWindowInsets(left, top, right, bottom, imeBottom, imeVisible);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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}"))
|
||||
@@ -546,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);
|
||||
}
|
||||
@@ -574,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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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"] }
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -65,6 +65,18 @@ pub struct Insets {
|
||||
#[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>>>>;
|
||||
@@ -102,7 +114,8 @@ extern "system" fn apply_window_insets<'local>(
|
||||
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,
|
||||
@@ -110,6 +123,7 @@ extern "system" fn apply_window_insets<'local>(
|
||||
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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
+12
-17
@@ -17,15 +17,14 @@ 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();
|
||||
}
|
||||
@@ -43,9 +42,7 @@ 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;
|
||||
@@ -83,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 {
|
||||
|
||||
+42
-3
@@ -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() {
|
||||
|
||||
+10
-21
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+178
-63
@@ -921,21 +921,38 @@ impl VelocityTracker {
|
||||
|
||||
/// 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;
|
||||
|
||||
@@ -948,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;
|
||||
}
|
||||
@@ -975,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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1106,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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1281,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)]
|
||||
|
||||
+56
-8
@@ -259,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,
|
||||
}
|
||||
|
||||
@@ -434,11 +443,12 @@ 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
|
||||
@@ -474,7 +484,7 @@ impl List {
|
||||
self.fling = Some(Fling {
|
||||
calc: FlingCalculator::new(self.density),
|
||||
velocity: velocity_px_per_s,
|
||||
started_at: Instant::now(),
|
||||
started_at: None,
|
||||
applied: 0.0,
|
||||
});
|
||||
}
|
||||
@@ -487,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) {
|
||||
@@ -508,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
|
||||
@@ -1768,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]
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user