Merge remote-tracking branch 'origin/rustify' into worktree-agent-ace98b0bdaf33ffff
# Conflicts: # docs/IRIS.md # docs/RUST.md
This commit is contained in:
commit
4274b8b8d0
27 files changed
+1571
-149
No files matched your search
@@ -283,6 +283,20 @@ Each exists because something was invisible without it.
|
|||||||
checkout's own emulator, taps "Run benchmark" by label, and prints the
|
checkout's own emulator, taps "Run benchmark" by label, and prints the
|
||||||
report -- written so the P0 build/install/tap/read-report cycle stops
|
report -- written so the P0 build/install/tap/read-report cycle stops
|
||||||
being retyped by hand each time (docs/RUST.md's P0 box).
|
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
|
### Driving the UI
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,43 @@ 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
|
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.
|
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
|
## 2026-09-07: the fling curve was the identity function
|
||||||
|
|
||||||
You said the fling "seems to just be linear velocity with an abrupt stop."
|
You said the fling "seems to just be linear velocity with an abrupt stop."
|
||||||
|
|||||||
@@ -931,3 +931,27 @@ do not duplicate it there.
|
|||||||
p50 dropping below Compose's on the phone. Do this after the four bench
|
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
|
v2 defects (stale primitives, finger fling, decay curve, IME show) are
|
||||||
closed, since they are what make the run unrepresentative today.
|
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
|
> `SizeCtx` and `Cache` are gone with it — see `LAYOUT.md` for the full
|
||||||
> design, the move-offset mechanism this shipped alongside, and the file
|
> design, the move-offset mechanism this shipped alongside, and the file
|
||||||
> list.
|
> 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.
|
||||||
+103
-32
@@ -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
|
prerequisites in this order. Each item is ticked here by the agent that
|
||||||
closes it.
|
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)
|
### 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
|
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
|
the first thing to look at moving, since a desktop bench on the same
|
||||||
fixture is layer 2 of the test rig below.
|
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
|
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
|
can answer the question, and go up only when it cannot. The emulator
|
||||||
@@ -78,43 +101,91 @@ costs minutes a cycle; the desktop window seconds; the headless harness
|
|||||||
runs inside `cargo test`.
|
runs inside `cargo test`.
|
||||||
|
|
||||||
1. **Headless, in-process, no compositor and no GPU -- the default.**
|
1. **Headless, in-process, no compositor and no GPU -- the default.**
|
||||||
`iris/src/layout_tests.rs` already builds a tree over `UiRenderState`
|
`iris::harness` (`iris/src/harness.rs`), plus the fixture crate it
|
||||||
with no window, `sense.rs`'s gesture tests feed fabricated
|
opens. `Harness::new(size, density)` builds an `Rsc`, a
|
||||||
`CursorState`s with their own times, and `List::tick_fling` is
|
`UiRenderState` and a state whose `FocusHost`/`OpenUrl` *record* what
|
||||||
driven by hand. Extend that into one harness that opens
|
the platform was asked for; `frame(t_ms)`/`frames_until(..)` run
|
||||||
`transcript-ui`'s screen on `app/bench-fixture` (as `bench_client.rs`
|
frames on a clock the test owns, and `replay(&TouchScript)` feeds a
|
||||||
does on Android, no server), at the phone's logical size and
|
recorded gesture one sample at a time exactly as
|
||||||
`content_scale` from `docs/bench/iris-phone-v2-2026-09-06.md` (2.55,
|
`IrisViewPeer::on_touch_event` replays Android's historical samples.
|
||||||
120Hz -- read, never typed from memory), ticks frames, and feeds a
|
The recordings are plain `t_ms action x y` files under
|
||||||
**replayed touch stream** from a trivial file of `(t_ms, action, x,
|
`iris/transcript-fixture/touch/`, and `flick-120hz.touch` is the
|
||||||
y)` lines with `cursor.time` taken from the file. That is what the
|
phone's own shape: DOWN, four samples 4ms apart, UP, 20ms in total.
|
||||||
emulator cannot do at all: the batched 120Hz flick from the phone
|
|
||||||
report becomes a deterministic test asserting on scroll offset and on
|
cd iris && cargo test -p transcript-fixture
|
||||||
the `iris drag release:` velocity. Anything about layout, scroll
|
|
||||||
position, selection, focus or fold state is answered here, with
|
runs in about a second and asserts (a) the flick releases with a real
|
||||||
assertions rather than eyes. Nothing renders; a widget's placed
|
velocity (`List::fling_velocity`, which only `Released(Some(v))`
|
||||||
rectangle is the evidence.
|
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.**
|
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
|
cd iris && ./run-headless.sh phone --phone --shot /tmp/p.png -- -p transcript-fixture
|
||||||
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
|
About 15 seconds warm. `--phone` sets the private sway output to
|
||||||
touch-shaped mouse input: a left-button drag pans and flings through
|
1080x2424@120Hz and exports `IRIS_SCALE=2.55`, which reaches iris the
|
||||||
`DragArbiter`, long-press selects, no hover. One input path, not a
|
way `DisplayMetrics.density` does on Android
|
||||||
parallel one (`CODE_RULES`). Colour, spacing, text and anything a
|
(`iris::default::content_scale`) -- the desktop backend now lays out
|
||||||
person has to see is answered here. `swaymsg seat - cursor
|
in physical pixels with a density instead of dividing into a separate
|
||||||
move/press/release` drives it when a gesture is needed on screen.
|
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.**
|
3. **The Android emulator -- platform plumbing and the final pass.**
|
||||||
JNI, IME, insets, surface lifecycle, the renderer rebuild, and one
|
JNI, IME, insets, surface lifecycle, the renderer rebuild, and one
|
||||||
verification run before a build goes to the phone. Not for iterating
|
verification run before a build goes to the phone. Not for iterating
|
||||||
on layout.
|
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
|
### 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
|
Iris's three lines on the `ed04d4c` build (Pixel 9 Pro XL, GrapheneOS
|
||||||
|
|||||||
Generated
+37
-16
@@ -965,9 +965,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dlib"
|
name = "dlib"
|
||||||
version = "0.5.2"
|
version = "0.5.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
|
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libloading",
|
"libloading",
|
||||||
]
|
]
|
||||||
@@ -2875,9 +2875,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.38.4"
|
version = "0.41.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
|
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
@@ -3058,6 +3058,15 @@ version = "0.8.52"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce"
|
checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rig-input"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"iris",
|
||||||
|
"wayland-client",
|
||||||
|
"wayland-protocols-wlr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
@@ -3646,6 +3655,18 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "transcript-fixture"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"client-core",
|
||||||
|
"event-model",
|
||||||
|
"iris",
|
||||||
|
"serde_json",
|
||||||
|
"transcript-ui",
|
||||||
|
"winit",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "transcript-ui"
|
name = "transcript-ui"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -3894,9 +3915,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wayland-backend"
|
name = "wayland-backend"
|
||||||
version = "0.3.12"
|
version = "0.3.17"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9"
|
checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"downcast-rs",
|
"downcast-rs",
|
||||||
@@ -3908,9 +3929,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wayland-client"
|
name = "wayland-client"
|
||||||
version = "0.31.12"
|
version = "0.31.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec"
|
checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"rustix 1.1.3",
|
"rustix 1.1.3",
|
||||||
@@ -3942,9 +3963,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wayland-protocols"
|
name = "wayland-protocols"
|
||||||
version = "0.32.10"
|
version = "0.32.13"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3"
|
checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"wayland-backend",
|
"wayland-backend",
|
||||||
@@ -3967,9 +3988,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wayland-protocols-wlr"
|
name = "wayland-protocols-wlr"
|
||||||
version = "0.3.10"
|
version = "0.3.12"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3"
|
checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"wayland-backend",
|
"wayland-backend",
|
||||||
@@ -3980,9 +4001,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wayland-scanner"
|
name = "wayland-scanner"
|
||||||
version = "0.31.8"
|
version = "0.31.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3"
|
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quick-xml",
|
"quick-xml",
|
||||||
@@ -3991,9 +4012,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wayland-sys"
|
name = "wayland-sys"
|
||||||
version = "0.31.8"
|
version = "0.31.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd"
|
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dlib",
|
"dlib",
|
||||||
"log",
|
"log",
|
||||||
|
|||||||
+9
-1
@@ -89,7 +89,15 @@ name = "message_list"
|
|||||||
harness = false
|
harness = false
|
||||||
|
|
||||||
[workspace]
|
[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
|
# android-app pulls in android-view, which needs the NDK sysroot to link
|
||||||
# -- excluded so `cargo build --workspace --all-targets` on the host stays
|
# -- excluded so `cargo build --workspace --all-targets` on the host stays
|
||||||
# buildable. Cross-compile it from its own directory (its own single-crate
|
# buildable. Cross-compile it from its own directory (its own single-crate
|
||||||
|
|||||||
Generated
+12
@@ -1774,6 +1774,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tabs-ui",
|
"tabs-ui",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"transcript-fixture",
|
||||||
"transcript-ui",
|
"transcript-ui",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3864,6 +3865,17 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "transcript-fixture"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"client-core",
|
||||||
|
"event-model",
|
||||||
|
"iris",
|
||||||
|
"serde_json",
|
||||||
|
"transcript-ui",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "transcript-ui"
|
name = "transcript-ui"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ log = "0.4.28"
|
|||||||
# which Cargo's `unused_dependencies` lint (on by default) correctly flags.
|
# which Cargo's `unused_dependencies` lint (on by default) correctly flags.
|
||||||
tabs-ui = { path = "../tabs-ui", optional = true }
|
tabs-ui = { path = "../tabs-ui", optional = true }
|
||||||
transcript-ui = { path = "../transcript-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 }
|
client-core = { path = "../../client-core", optional = true }
|
||||||
event-model = { path = "../../event-model", optional = true }
|
event-model = { path = "../../event-model", optional = true }
|
||||||
serde_json = { version = "1", features = ["float_roundtrip"], 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
|
# `event-model` -- `lib.rs`'s `ActiveClient` selection gives this feature
|
||||||
# priority over `transcript-screen`'s own `TranscriptClient` when both are
|
# priority over `transcript-screen`'s own `TranscriptClient` when both are
|
||||||
# listed, which is how this crate's build command names both explicitly.
|
# 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]
|
[profile.release]
|
||||||
panic = "abort"
|
panic = "abort"
|
||||||
|
|||||||
@@ -6,14 +6,11 @@
|
|||||||
//!
|
//!
|
||||||
//! **Reuses `transcript_client.rs`'s shape** (folded items, the same
|
//! **Reuses `transcript_client.rs`'s shape** (folded items, the same
|
||||||
//! `TranscriptScreen::apply` incremental update on every event) with the
|
//! `TranscriptScreen::apply` incremental update on every event) with the
|
||||||
//! network half replaced by the checked-in fixture, embedded with
|
//! network half replaced by the checked-in fixture. Reading that fixture
|
||||||
//! `include_str!` -- `app/bench-fixture/assets/transcript.jsonl`,
|
//! and folding it into a screen is **`transcript-fixture`'s** job, not
|
||||||
//! 1,915,760 bytes, generated by `app/bench-fixture/generate.py` and never
|
//! this file's -- the same crate the headless harness and the
|
||||||
//! a real transcript (that file's own README). The first 3,200 lines are
|
//! phone-shaped desktop window open, so all three measure one screen
|
||||||
//! the opening backlog, folded once through
|
//! (AGENTS.md's sharing rule; moved out of here 2026-09-07). The tail is
|
||||||
//! `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,
|
|
||||||
//! replayed one at a time through `fold_event` -- the same fold path a
|
//! replayed one at a time through `fold_event` -- the same fold path a
|
||||||
//! live SSE reply arrives on -- by the "Run benchmark" control below.
|
//! live SSE reply arrives on -- by the "Run benchmark" control below.
|
||||||
//! Streaming through `apply` rather than a full rebuild per event is what
|
//! Streaming through `apply` rather than a full rebuild per event is what
|
||||||
@@ -22,7 +19,7 @@
|
|||||||
|
|
||||||
use crate::bench_jni::PlatformHandle;
|
use crate::bench_jni::PlatformHandle;
|
||||||
use android_view::jni::{JavaVM, objects::GlobalRef};
|
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 event_model::SeqEvent;
|
||||||
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
|
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
|
||||||
use iris::prelude::*;
|
use iris::prelude::*;
|
||||||
@@ -30,13 +27,6 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
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
|
/// RUST.md's "Benchmark v2" spec, written once so both apps' bench clients
|
||||||
/// implement the identical four phases -- see that box before changing any
|
/// implement the identical four phases -- see that box before changing any
|
||||||
/// constant here, since a mismatch would make the two reports stop
|
/// 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.
|
/// when a later step in the same phase needs to read state back.
|
||||||
const ANIM_STEP_MS: u64 = 16;
|
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
|
/// 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
|
/// scrolls instead of growing -- roughly a third of a phone screen, the
|
||||||
/// share the pane used to reserve unconditionally. An empty report takes
|
/// 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 {
|
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
|
||||||
wtext(message.to_string())
|
wtext(message.to_string())
|
||||||
.color(Color::WHITE)
|
.color(Color::WHITE)
|
||||||
@@ -322,12 +285,12 @@ impl AndroidAppState for BenchClient {
|
|||||||
last_top_pad: 0.0,
|
last_top_pad: 0.0,
|
||||||
};
|
};
|
||||||
|
|
||||||
let (backlog, stream_tail) = parse_fixture();
|
match transcript_fixture::build_screen(rsc) {
|
||||||
client.stream_tail = stream_tail;
|
Ok((opened, tree)) => {
|
||||||
match fold_page(&backlog) {
|
client.items = opened.items;
|
||||||
Ok(items) => {
|
client.stream_tail = opened.stream_tail;
|
||||||
client.items = items;
|
(client.content)(rsc).set(tree);
|
||||||
client.rebuild_transcript(rsc);
|
client.screen = Some(opened.screen);
|
||||||
}
|
}
|
||||||
Err(message) => {
|
Err(message) => {
|
||||||
client.show_message(rsc, &format!("Couldn't fold the bench fixture: {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) {
|
fn rebuild_transcript(&mut self, rsc: &mut Rsc) {
|
||||||
let rows = group_tool_runs(&self.items);
|
let (screen, tree) = transcript_ui::build_tree(rsc, transcript_fixture::rows(&self.items));
|
||||||
let (screen, tree) = transcript_ui::build_tree(rsc, rows);
|
|
||||||
(self.content)(rsc).set(tree);
|
(self.content)(rsc).set(tree);
|
||||||
self.screen = Some(screen);
|
self.screen = Some(screen);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 [-- cargo args]
|
||||||
# ./run-headless.sh tabs --shot /tmp/tabs.png --seconds 4
|
# ./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
|
# `--bin` runs a real crate binary instead of an example (E4's
|
||||||
# `desktop-app`, which is a window a person runs, not a demo) --
|
# `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"
|
run="${XDG_RUNTIME_DIR:-/tmp}/iris-headless"
|
||||||
seconds=3
|
seconds=3
|
||||||
shot=""
|
shot=""
|
||||||
|
replay=""
|
||||||
example=""
|
example=""
|
||||||
kind=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
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--shot) shot=$2; shift 2 ;;
|
--shot) shot=$2; shift 2 ;;
|
||||||
--seconds) seconds=$2; shift 2 ;;
|
--seconds) seconds=$2; shift 2 ;;
|
||||||
--bin) kind=bin; shift ;;
|
--bin) kind=bin; shift ;;
|
||||||
|
--phone) phone=yes; shift ;;
|
||||||
|
--replay) replay=$2; shift 2 ;;
|
||||||
--) shift; break ;;
|
--) shift; break ;;
|
||||||
*) example=$1; shift ;;
|
*) example=$1; shift ;;
|
||||||
esac
|
esac
|
||||||
done
|
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"
|
mkdir -p "$run"
|
||||||
export SWAYSOCK="$run/sway.sock"
|
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
|
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"
|
cd "$here"
|
||||||
if [ "$kind" = bin ]; then
|
if [ "$kind" = bin ]; then
|
||||||
cargo build --bin "$example" "$@" >&2
|
cargo build --bin "$example" "$@" >&2
|
||||||
@@ -111,6 +163,18 @@ while [ $i -lt "$((seconds * 2))" ]; do
|
|||||||
i=$((i + 1)); sleep 0.5
|
i=$((i + 1)); sleep 0.5
|
||||||
done
|
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
|
if kill -0 "$pid" 2>/dev/null; then
|
||||||
[ -n "$shot" ] && grim "$shot" && echo "run-headless: wrote $shot" >&2
|
[ -n "$shot" ] && grim "$shot" && echo "run-headless: wrote $shot" >&2
|
||||||
kill "$pid" 2>/dev/null || true
|
kill "$pid" 2>/dev/null || true
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use winit::dpi::{LogicalPosition, LogicalSize};
|
use winit::dpi::{PhysicalPosition, PhysicalSize};
|
||||||
|
|
||||||
impl<T: HasDefaultUiState> FocusHost for T {
|
impl<T: HasDefaultUiState> FocusHost for T {
|
||||||
fn recent_click(&mut self) -> bool {
|
fn recent_click(&mut self) -> bool {
|
||||||
@@ -18,9 +18,11 @@ impl<T: HasDefaultUiState> FocusHost for T {
|
|||||||
let state = self.default_state_mut();
|
let state = self.default_state_mut();
|
||||||
let Some(region) = region else { return };
|
let Some(region) = region else { return };
|
||||||
state.window.set_ime_allowed(true);
|
state.window.set_ime_allowed(true);
|
||||||
|
// Physical, like everything else this backend hands winit --
|
||||||
|
// `default::content_scale`.
|
||||||
state.window.set_ime_cursor_area(
|
state.window.set_ime_cursor_area(
|
||||||
LogicalPosition::<f32>::from(region.top_left.tuple()),
|
PhysicalPosition::<f32>::from(region.top_left.tuple()),
|
||||||
LogicalSize::<f32>::from(region.size().tuple()),
|
PhysicalSize::<f32>::from(region.size().tuple()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+12
-17
@@ -17,15 +17,14 @@ pub struct Input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Input {
|
impl Input {
|
||||||
/// `scale_factor` converts winit's physical-pixel event coordinates
|
/// winit's pointer coordinates are physical pixels, which is the
|
||||||
/// into the same logical units `UiRenderNode`'s window uniform now uses
|
/// space the whole tree is laid out and hit-tested in -- see
|
||||||
/// (`default::render::UiRenderer::new`'s doc comment) -- without it,
|
/// `default::content_scale`. Nothing is converted here; `dp(...)`
|
||||||
/// a cursor position and the widget tree it's tested against would be
|
/// resolves against the density at layout time instead.
|
||||||
/// in two different units on any monitor whose scale factor isn't 1.0.
|
pub fn event(&mut self, event: &WindowEvent) -> bool {
|
||||||
pub fn event(&mut self, event: &WindowEvent, scale_factor: f32) -> bool {
|
|
||||||
match event {
|
match event {
|
||||||
WindowEvent::CursorMoved { position, .. } => {
|
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.exists = true;
|
||||||
self.cursor.time = Instant::now();
|
self.cursor.time = Instant::now();
|
||||||
}
|
}
|
||||||
@@ -43,9 +42,7 @@ impl Input {
|
|||||||
WindowEvent::MouseWheel { delta, .. } => {
|
WindowEvent::MouseWheel { delta, .. } => {
|
||||||
let mut delta = match *delta {
|
let mut delta = match *delta {
|
||||||
MouseScrollDelta::LineDelta(x, y) => Vec2::new(x, y),
|
MouseScrollDelta::LineDelta(x, y) => Vec2::new(x, y),
|
||||||
MouseScrollDelta::PixelDelta(pos) => {
|
MouseScrollDelta::PixelDelta(pos) => Vec2::new(pos.x as f32, pos.y as f32),
|
||||||
Vec2::new(pos.x as f32, pos.y as f32) / scale_factor
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
if delta.x == 0.0 && self.modifiers.shift {
|
if delta.x == 0.0 && self.modifiers.shift {
|
||||||
delta.x = delta.y;
|
delta.x = delta.y;
|
||||||
@@ -83,14 +80,12 @@ impl Input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DefaultUiState {
|
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 {
|
pub fn window_size(&self) -> Vec2 {
|
||||||
let window = self.renderer.window();
|
let size = self.renderer.window().inner_size();
|
||||||
let size = window.inner_size();
|
Vec2::new(size.width as f32, size.height as f32)
|
||||||
let scale_factor = window.scale_factor() as f32;
|
|
||||||
Vec2::new(
|
|
||||||
size.width as f32 / scale_factor,
|
|
||||||
size.height as f32 / scale_factor,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn cursor_state(&self) -> &CursorState {
|
pub fn cursor_state(&self) -> &CursorState {
|
||||||
|
|||||||
+42
-3
@@ -25,6 +25,38 @@ pub use render::*;
|
|||||||
|
|
||||||
pub type Proxy<Event> = EventLoopProxy<Event>;
|
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 struct DefaultUiState {
|
||||||
pub root: Option<StrongWidget>,
|
pub root: Option<StrongWidget>,
|
||||||
pub renderer: UiRenderer,
|
pub renderer: UiRenderer,
|
||||||
@@ -214,8 +246,16 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
|
|||||||
window.set_visible(true);
|
window.set_visible(true);
|
||||||
let default_state = DefaultUiState::new(window, access_adapter);
|
let default_state = DefaultUiState::new(window, access_adapter);
|
||||||
let (mut rsc, task_recv) = DefaultRsc::init(default_state.window.clone());
|
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 state = State::new(default_state, &mut rsc, proxy);
|
||||||
let render = UiRenderState::new();
|
let mut render = UiRenderState::new();
|
||||||
|
render.set_density(scale);
|
||||||
Self {
|
Self {
|
||||||
rsc,
|
rsc,
|
||||||
state,
|
state,
|
||||||
@@ -247,8 +287,7 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
|
|||||||
ui_state
|
ui_state
|
||||||
.access_adapter
|
.access_adapter
|
||||||
.process_event(&ui_state.window, &event);
|
.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);
|
||||||
let input_changed = ui_state.input.event(&event, scale_factor);
|
|
||||||
let cursor_state = ui_state.cursor_state().clone();
|
let cursor_state = ui_state.cursor_state().clone();
|
||||||
let old = ui_state.focus;
|
let old = ui_state.focus;
|
||||||
if cursor_state.buttons.left.is_start() {
|
if cursor_state.buttons.left.is_start() {
|
||||||
|
|||||||
+10
-21
@@ -66,13 +66,11 @@ impl UiRenderer {
|
|||||||
self.config.width = size.width;
|
self.config.width = size.width;
|
||||||
self.config.height = size.height;
|
self.config.height = size.height;
|
||||||
self.surface.configure(&self.device, &self.config);
|
self.surface.configure(&self.device, &self.config);
|
||||||
// Logical, matching `new`'s own seed -- see the comment there.
|
// Physical, matching `new`'s own seed -- see the comment there.
|
||||||
let scale_factor = self.window.scale_factor() as f32;
|
self.ui.resize(
|
||||||
let logical = Vec2::new(
|
Vec2::new(size.width as f32, size.height as f32),
|
||||||
size.width as f32 / scale_factor,
|
&self.queue,
|
||||||
size.height as f32 / scale_factor,
|
|
||||||
);
|
);
|
||||||
self.ui.resize(logical, &self.queue);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_encoder(device: &Device) -> CommandEncoder {
|
fn create_encoder(device: &Device) -> CommandEncoder {
|
||||||
@@ -162,21 +160,12 @@ impl UiRenderer {
|
|||||||
// by:" chain as the message, since `UiRenderNode::new` returns it
|
// by:" chain as the message, since `UiRenderNode::new` returns it
|
||||||
// rather than letting wgpu's own default handler panic first (see
|
// rather than letting wgpu's own default handler panic first (see
|
||||||
// that function's doc comment).
|
// that function's doc comment).
|
||||||
// Logical size (physical / `scale_factor`), matching what the
|
// Physical size, the same units the swapchain, `WindowEvent::
|
||||||
// Android backend now reports too (`android::render::
|
// Resized`, the pointer and the widget tree all use -- see
|
||||||
// AndroidRenderer::new`, `content_scale`) -- the swapchain still
|
// `default::content_scale` for why this backend stopped dividing
|
||||||
// configures at the real physical resolution above; only the
|
// into a separate logical space, and what disagreed while it did.
|
||||||
// window uniform layout/hit-testing agree on is scaled. Without
|
let physical_size = Vec2::new(size.width as f32, size.height as f32);
|
||||||
// this a window on any monitor whose scale factor isn't 1.0 would
|
let ui = UiRenderNode::new(&device, &queue, &config, physical_size)
|
||||||
// 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)
|
|
||||||
.expect("Could not create iris render node!");
|
.expect("Could not create iris render node!");
|
||||||
|
|
||||||
Self {
|
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 attr;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
|
pub mod harness;
|
||||||
pub mod platform;
|
pub mod platform;
|
||||||
pub mod sense;
|
pub mod sense;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|||||||
+23
-3
@@ -259,7 +259,16 @@ pub struct List {
|
|||||||
struct Fling {
|
struct Fling {
|
||||||
calc: FlingCalculator,
|
calc: FlingCalculator,
|
||||||
velocity: f32,
|
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,
|
applied: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,7 +484,7 @@ impl List {
|
|||||||
self.fling = Some(Fling {
|
self.fling = Some(Fling {
|
||||||
calc: FlingCalculator::new(self.density),
|
calc: FlingCalculator::new(self.density),
|
||||||
velocity: velocity_px_per_s,
|
velocity: velocity_px_per_s,
|
||||||
started_at: Instant::now(),
|
started_at: None,
|
||||||
applied: 0.0,
|
applied: 0.0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -488,6 +497,17 @@ impl List {
|
|||||||
self.fling.is_some()
|
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
|
/// Cancel any fling in progress with no further movement -- the next
|
||||||
/// touch-down's job, per `fling`'s own doc.
|
/// touch-down's job, per `fling`'s own doc.
|
||||||
pub fn cancel_fling(&mut self) {
|
pub fn cancel_fling(&mut self) {
|
||||||
@@ -509,7 +529,7 @@ impl List {
|
|||||||
let Some(f) = &mut self.fling else {
|
let Some(f) = &mut self.fling else {
|
||||||
return false;
|
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 target = f.calc.position_at(f.velocity, elapsed);
|
||||||
let delta = target - f.applied;
|
let delta = target - f.applied;
|
||||||
f.applied = target;
|
f.applied = target;
|
||||||
|
|||||||
@@ -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