Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4274b8b8d0 | ||
|
|
73f956f8e0 | ||
|
|
038f6a3832 | ||
|
|
1121d7cc83 | ||
|
|
232de0ec53 | ||
|
|
e430880cde | ||
|
|
a999bd106a | ||
|
|
6840edf61e | ||
|
|
333220196e | ||
|
|
7f4ea7e8fd | ||
|
|
591128eef1 | ||
|
|
ba0f2ea93f | ||
|
|
ed04d4c735 |
No files matched your search
@@ -19,6 +19,21 @@ child process, translated into one common event model.** A new session type
|
|||||||
is a new driver — never a session-type branch in shared code (routes,
|
is a new driver — never a session-type branch in shared code (routes,
|
||||||
transcript, app screens).
|
transcript, app screens).
|
||||||
|
|
||||||
|
The second one, for the Rust port on the `rustify` branch: **the phone app
|
||||||
|
and a planned desktop app share almost all of their code.** Screens, widgets,
|
||||||
|
folding, paging, config and the network client live in the shared crates
|
||||||
|
(`iris`, `client-core`, `transcript-ui`, `tabs-ui`); `android-app` and
|
||||||
|
`desktop-app` are thin entry points that own only what the platform forces
|
||||||
|
(JNI and the IME on one side, winit and argv on the other). The two
|
||||||
|
*layouts* will differ, to suit a phone's screen and a finger against a
|
||||||
|
desktop's screen and a mouse -- but the widgets a layout is made of (a
|
||||||
|
button, a text field, a list, a card) and the styling (colours, spacing,
|
||||||
|
type) are one implementation with no per-platform copy. Anything that could
|
||||||
|
work on both goes in a shared crate the first time it is written, and a
|
||||||
|
platform crate growing a widget or a colour is a defect to move, not a
|
||||||
|
convenience to keep. Iris said this on 2026-09-07; docs/RUST.md carries the
|
||||||
|
details.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
Mirrors `../dev-updater` deliberately: same stack (axum 0.8 +
|
Mirrors `../dev-updater` deliberately: same stack (axum 0.8 +
|
||||||
@@ -268,6 +283,20 @@ Each exists because something was invisible without it.
|
|||||||
checkout's own emulator, taps "Run benchmark" by label, and prints the
|
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
|
||||||
|
|
||||||
|
|||||||
+126
@@ -8,6 +8,132 @@ capability that moved. Small and trivial changes do not go here.
|
|||||||
An entry gives the date, what changed, why, and a short before/after where
|
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
|
||||||
|
|
||||||
|
You said the fling "seems to just be linear velocity with an abrupt stop."
|
||||||
|
It was, exactly: `android_fling_spline`'s lookup returned `t` for every
|
||||||
|
`t`. Two halves of AOSP's spline build loop had been transposed, which made
|
||||||
|
its two tables identical, and the lookup interpolated one against the
|
||||||
|
other -- which reduces algebraically to `t`. So a fling coasted at its
|
||||||
|
release speed for the whole (correctly computed) duration and stopped dead
|
||||||
|
at the end of it.
|
||||||
|
|
||||||
|
Ported exactly now from `OverScroller.java` and Compose's
|
||||||
|
`SplineBasedDecay.kt`, which agree line for line. One public addition:
|
||||||
|
|
||||||
|
**`FlingCalculator::velocity_at(velocity, elapsed) -> f32`**, beside the
|
||||||
|
existing `position_at` -- AOSP's `mCurrVelocity` and Compose's
|
||||||
|
`FlingInfo.velocity`. It is what makes "is this decelerating" answerable
|
||||||
|
rather than inferred, and it is what `List::tick_fling`'s new
|
||||||
|
`iris fling tick:` debug line reports each frame.
|
||||||
|
|
||||||
|
The lesson worth keeping, since it cost two builds on your phone: every
|
||||||
|
test the calculator had compared it with itself -- monotonic, correctly
|
||||||
|
signed, integrates to the closed form, per-tick deltas non-increasing --
|
||||||
|
and **all of them pass on a straight line**. The numbers now come from
|
||||||
|
`iris/benches/fling_spline_reference.py`, a separate hand transcription of
|
||||||
|
the two sources, checked in beside the tests.
|
||||||
|
|
||||||
|
## 2026-09-07: the Android insets bridge counts its own dispatches
|
||||||
|
|
||||||
|
`AndroidUiState::insets_report() -> String` is new, and the bench app's
|
||||||
|
Diagnostics pane shows it. It carries the last insets plus **how many times
|
||||||
|
the platform has delivered any**, because "the keyboard did not push
|
||||||
|
anything up" has two causes that look identical on screen -- the listener
|
||||||
|
never fired, or it fired with a zero height -- and you have no logcat on
|
||||||
|
the phone. `dispatches=0` prints a sentence saying so rather than the
|
||||||
|
numbers, which would be defaults rather than measurements.
|
||||||
|
|
||||||
|
## 2026-09-07: widgets can animate, and a fling finally moves
|
||||||
|
|
||||||
|
Iris's phone said "fling still doesn't work" twice. The velocity was only
|
||||||
|
half of it: **nothing in iris advanced an animation between input
|
||||||
|
events**, so `List::fling` stored a speed that nothing ever applied. Three
|
||||||
|
public changes come out of fixing that.
|
||||||
|
|
||||||
|
**`Widget::tick(&mut self, now: Instant) -> bool`** is a new trait method,
|
||||||
|
defaulted to `false`, so no existing widget changes. A widget that
|
||||||
|
overrides it is animating; answering `false` is how it stops.
|
||||||
|
|
||||||
|
**`UiData::animate(id)` and `UiData::tick_animations(now) -> bool`** are
|
||||||
|
the registry and its driver. A gesture that starts an animation registers
|
||||||
|
the widget; each backend calls `tick_animations` once per frame before the
|
||||||
|
draw and asks for another frame while it answers `true`. That answer is
|
||||||
|
the *only* thing in iris that makes a frame happen without an input event,
|
||||||
|
and an animation's path out is its own `tick` returning false -- nothing
|
||||||
|
has to remember to unregister it.
|
||||||
|
|
||||||
|
// before: the velocity was stored and never applied
|
||||||
|
list(ui).fling(-v);
|
||||||
|
// after
|
||||||
|
list(ui).fling(-v);
|
||||||
|
let id = list.id();
|
||||||
|
ui.ui_mut().animate(id);
|
||||||
|
|
||||||
|
The two calls are deliberate rather than folded into `fling`: the velocity
|
||||||
|
is the list's business and whether anything animates at all is the frame
|
||||||
|
loop's, and a caller driving its own frames (the benchmark, the headless
|
||||||
|
tests) still calls `tick_fling` directly.
|
||||||
|
|
||||||
|
**`FlingCalculator` needs the real display density, and its coefficient
|
||||||
|
was wrong.** `new(density)` takes physical pixels per `dp` and the
|
||||||
|
velocity handed to it must be in those same physical pixels -- the
|
||||||
|
density does *not* cancel out, contrary to what that type's doc used to
|
||||||
|
claim. Separately, `physical_coefficient` multiplied by the scroll
|
||||||
|
friction (0.015) where AOSP multiplies by its own tuning constant 0.84, a
|
||||||
|
factor of 56 inside an exponential. Together they gave an ordinary flick a
|
||||||
|
**45-second** coast, which nobody could see while flings never animated.
|
||||||
|
`List` reads its density from the painter now, and
|
||||||
|
`a_flick_lasts_what_aosps_own_formula_says_it_does` pins the absolute
|
||||||
|
numbers (0.59s and 621px for 3000px/s at density 2.75) against AOSP's
|
||||||
|
formula -- the check every previous test could not make, because they all
|
||||||
|
compared the calculator with itself.
|
||||||
|
|
||||||
|
**`MOVE_CHAIN_LIMIT` is 64, not 16**, in `render_state.rs` and
|
||||||
|
`shader.wgsl` alike. It bounds a walk so a cyclic `parent` cannot hang
|
||||||
|
either side; it was never meant as a claim about tree depth, and the
|
||||||
|
transcript screen's composer field sits 17 slots below the root. Past the
|
||||||
|
bound both walks silently stop summing, so a widget draws and hit-tests
|
||||||
|
short with nothing to say so; the CPU assert now prints the chain, so a
|
||||||
|
cycle and a deep tree can be told apart.
|
||||||
|
|
||||||
## 2026-09-06: tool cards, `ToolState`, and a screen that knows whether its session is working
|
## 2026-09-06: tool cards, `ToolState`, and a screen that knows whether its session is working
|
||||||
|
|
||||||
`transcript_ui::tool` is new: a card per tool call, a group per run
|
`transcript_ui::tool` is new: a card per tool call, a group per run
|
||||||
|
|||||||
+113
-5
@@ -351,7 +351,27 @@ agent ticks it here with the evidence.
|
|||||||
Iris's report, verbatim, with a screenshot. Phone: Mali-G715 (Vulkan),
|
Iris's report, verbatim, with a screenshot. Phone: Mali-G715 (Vulkan),
|
||||||
`content_scale: 2.55`, 120Hz. Open until ticked with phone-side evidence.
|
`content_scale: 2.55`, 120Hz. Open until ticked with phone-side evidence.
|
||||||
|
|
||||||
- [ ] **"Fling still doesn't work."** Second report; the emulator's
|
- [ ] **"Fling still doesn't work."** -> on `ed04d4c`, 2026-09-07:
|
||||||
|
*"flinging now does technically do something, but it seems to just be
|
||||||
|
linear velocity with an abrupt stop."* **It was exactly that, and the
|
||||||
|
arithmetic said so.** `distance_fraction(t)` returned `t` for every `t`
|
||||||
|
-- a constant-speed slide for the whole duration, then a stop at full
|
||||||
|
distance -- because two halves of AOSP's spline build loop were
|
||||||
|
transposed, which made `SPLINE_POSITION` and `SPLINE_TIME` identical, and
|
||||||
|
the lookup bracketed `t` between `SPLINE_TIME` entries rather than
|
||||||
|
between even time steps. The two cancelled to the identity. Ported
|
||||||
|
exactly now from `OverScroller.java` and
|
||||||
|
`androidx.compose.animation:animation:1.12.0`'s `SplineBasedDecay.kt`
|
||||||
|
(they agree line for line), with `iris/benches/fling_spline_reference.py`
|
||||||
|
as an independent transcription supplying the numbers the tests assert
|
||||||
|
on. Emulator, 2026-09-07: a released `v=3750` decelerates
|
||||||
|
`3746 -> 2624 -> 1834 -> 1144 -> 752 -> 449 -> 243 -> 83px/s` across 32
|
||||||
|
frames; a flick into the end of the list stops there in one tick with no
|
||||||
|
overshoot; a tap 200ms into a fling ends it at 11 ticks instead of 32.
|
||||||
|
**Open until the phone says so** -- a flick should now visibly slow
|
||||||
|
before it stops. Its earlier three defects (the velocity, the missing
|
||||||
|
animation registration, the 56x coefficient) are all still fixed and were
|
||||||
|
never the linear part.* Second report; the emulator's
|
||||||
`ui-trace` swipe flings (verified 2026-09-06 with `render()` counts),
|
`ui-trace` swipe flings (verified 2026-09-06 with `render()` counts),
|
||||||
a finger on the phone does not. What differs: a real flick at 120Hz is
|
a finger on the phone does not. What differs: a real flick at 120Hz is
|
||||||
batched by Android into few `MotionEvent`s with *historical* samples
|
batched by Android into few `MotionEvent`s with *historical* samples
|
||||||
@@ -364,14 +384,42 @@ Iris's report, verbatim, with a screenshot. Phone: Mali-G715 (Vulkan),
|
|||||||
last sample is treated as a tap; `ACTION_CANCEL`/pointer-capture
|
last sample is treated as a tap; `ACTION_CANCEL`/pointer-capture
|
||||||
delivering no `Drop`. Log the release decision (samples, span,
|
delivering no `Drop`. Log the release decision (samples, span,
|
||||||
velocity, outcome) at `info` so the next logcat settles it.
|
velocity, outcome) at `info` so the next logcat settles it.
|
||||||
- [ ] **"I can't reopen keyboard by tapping on message box after it
|
- [x] **"I can't reopen keyboard by tapping on message box after it
|
||||||
already happened once."** The field stays focused after the keyboard
|
already happened once."** *(Fixed 2026-09-07: `attr.rs`'s already-
|
||||||
|
focused branch calls `focus_gained` on a tap inside `DRAG_SLOP`.
|
||||||
|
Emulator: first tap `mInputShown=true`, back gesture, second tap
|
||||||
|
`mInputShown=true`. Negative control with that one call removed leaves
|
||||||
|
the second tap at `false`; a horizontal and a vertical swipe over the
|
||||||
|
focused field both leave it at `false`, so the earlier "swiping over
|
||||||
|
the input bar brings up the keyboard" has not returned.)* The field stays focused after the keyboard
|
||||||
is dismissed (back gesture, or the IME's own hide), so `on_press`'s
|
is dismissed (back gesture, or the IME's own hide), so `on_press`'s
|
||||||
already-focused branch never requests the IME again. Android's
|
already-focused branch never requests the IME again. Android's
|
||||||
`EditText` shows the IME on every tap of a focused field; do the same
|
`EditText` shows the IME on every tap of a focused field; do the same
|
||||||
(`FocusHost`: a tap on a focused field requests the IME, idempotent
|
(`FocusHost`: a tap on a focused field requests the IME, idempotent
|
||||||
when it is already shown).
|
when it is already shown).
|
||||||
- [ ] **"Message box does not push up the scroll area."** Since
|
- [ ] **"Message box does not push up the scroll area."**
|
||||||
|
**Reopened by the phone on 2026-09-07** -- *"similarly, the keyboard
|
||||||
|
raising up does not push things upwards"* -- after being ticked on
|
||||||
|
emulator evidence the day before (`ime_bottom=883`, composer box
|
||||||
|
`31,2277..1048,2329` -> `31,1457..1048,1509`). The JNI half was right;
|
||||||
|
what was wrong is one line of `iris/android-app/app/build.gradle`:
|
||||||
|
**`targetSdk = 34`** against `compileSdk = 37`, while the Compose app in
|
||||||
|
`app/` targets 37 and *does* push up on her phone. Below target 35 the
|
||||||
|
window keeps the legacy behaviour, where `adjustResize` shrinks it for
|
||||||
|
the IME and `getInsets(ime()).bottom` therefore measures zero;
|
||||||
|
`setDecorFitsSystemWindows(false)` opts out of that and still takes on
|
||||||
|
the API 36 emulator here, which is why every test run passed. Now
|
||||||
|
`targetSdk = 37`, plus a `WindowInsetsAnimation.Callback` for the devices
|
||||||
|
where only the animation path carries the height -- which also makes the
|
||||||
|
push-up animate (`ime_bottom=509, 663, 833, 881, 883` instead of one
|
||||||
|
jump). **This is a reading, not a measurement**: no Android 17 device is
|
||||||
|
reachable from here. So the Diagnostics pane now prints
|
||||||
|
`insets: dispatches=N left=… ime_bottom=… ime_visible=…` --
|
||||||
|
**screenshot that line with the keyboard open.** `ime_bottom` in the
|
||||||
|
hundreds and the composer risen means fixed; `dispatches` climbing with
|
||||||
|
`ime_bottom=0` means the reading was wrong and the window is still being
|
||||||
|
resized; `dispatches=0` means the listener is not firing at all, which is
|
||||||
|
a third thing again.* Since
|
||||||
`MainActivity` went edge-to-edge (`e12c708`), `adjustResize` no
|
`MainActivity` went edge-to-edge (`e12c708`), `adjustResize` no
|
||||||
longer resizes the window, so the app owns the IME inset -- but
|
longer resizes the window, so the app owns the IME inset -- but
|
||||||
`ime_bottom` is passed through JNI as the boolean `1`/`0` (the
|
`ime_bottom` is passed through JNI as the boolean `1`/`0` (the
|
||||||
@@ -381,7 +429,7 @@ Iris's report, verbatim, with a screenshot. Phone: Mali-G715 (Vulkan),
|
|||||||
composer's position follow the height, the visibility drives the
|
composer's position follow the height, the visibility drives the
|
||||||
boolean the `imePadding` rule in AGENTS.md's "Things that have bitten"
|
boolean the `imePadding` rule in AGENTS.md's "Things that have bitten"
|
||||||
describes.
|
describes.
|
||||||
- [ ] **"Picture is what happens if I leave the app and come back,
|
- [x] **"Picture is what happens if I leave the app and come back,
|
||||||
which completely removes text, and then I tap on the debug info. The
|
which completely removes text, and then I tap on the debug info. The
|
||||||
textures are definitely getting cooked for some reason after leaving
|
textures are definitely getting cooked for some reason after leaving
|
||||||
the app and resuming."** Screenshot: every glyph drawn *before* the
|
the app and resuming."** Screenshot: every glyph drawn *before* the
|
||||||
@@ -402,6 +450,42 @@ Iris's report, verbatim, with a screenshot. Phone: Mali-G715 (Vulkan),
|
|||||||
screenshotted the emulator's GLES path, where a resume may not
|
screenshotted the emulator's GLES path, where a resume may not
|
||||||
destroy the surface at all.
|
destroy the surface at all.
|
||||||
|
|
||||||
|
**Fixed in `ba2afba` and confirmed on the phone (Iris, 2026-09-07:
|
||||||
|
"the resume glyph corruption is fixed").** Closed. The emulator could
|
||||||
|
never have settled it -- no Vulkan adapter here, and the GLES path may
|
||||||
|
not destroy the surface at all -- so the phone was the only place this
|
||||||
|
could be answered, and it has been. `clearing_the_atlas_re_renders_
|
||||||
|
cached_text_instead_of_reusing_it` is what keeps it.
|
||||||
|
|
||||||
|
The reading above is right and the mechanism is one step narrower than
|
||||||
|
"cached text primitives". `IrisViewPeer::surface_changed`
|
||||||
|
(`iris/src/android/view.rs`) *does* already force a full-tree redraw
|
||||||
|
after a rebuild: it calls `render.resize(...)` unconditionally, which
|
||||||
|
sets `UiRenderState::resized`, which makes the next `update` take
|
||||||
|
`redraw_all` rather than `redraw_updates`. So every widget's `draw`
|
||||||
|
really does run again after the resume. What survives it is one cache
|
||||||
|
further in: `TextView::render` (`iris/src/widget/text/mod.rs`) returns
|
||||||
|
its cached `RenderedText` whenever the wrap width, buffer and attrs are
|
||||||
|
unchanged -- true of every pre-resume row -- so `TextData::place` is
|
||||||
|
never reached, nothing is re-rasterised into the fresh atlas, and the
|
||||||
|
*old* atlas's `uv_min`/`uv_max`/`layer` are re-submitted verbatim. Only
|
||||||
|
text whose content changed after the resume (the diagnostics pane Iris
|
||||||
|
tapped) re-shapes, which is exactly the split in her screenshot.
|
||||||
|
`Painter::glyphs` has one call site in the whole workspace, that one,
|
||||||
|
so there is no second holder of a `RenderedText` to fix.
|
||||||
|
|
||||||
|
The fix, in `ba2afba`: `GlyphAtlas::generation`, bumped by
|
||||||
|
`GlyphAtlas::clear`; `RenderedText::generation` recording which atlas
|
||||||
|
its glyphs were placed against; `Painter::atlas_generation()`;
|
||||||
|
`TextView::render`'s cache key gains it; and a `debug_assert_eq!` in
|
||||||
|
`Painter::glyphs` that a submitted quad's generation is the live one.
|
||||||
|
Headless test
|
||||||
|
`clearing_the_atlas_re_renders_cached_text_instead_of_reusing_it`
|
||||||
|
(`iris/src/widget/text/mod.rs`): draw, `atlas.clear()`, `resize`, draw
|
||||||
|
again, and assert the atlas holds the same glyph count again -- it
|
||||||
|
stays at 0 without the fix, because the cache short-circuits before
|
||||||
|
`place`.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
- [x] **Benchmarks**, not unit tests, run on demand (2026-09-05; a
|
- [x] **Benchmarks**, not unit tests, run on demand (2026-09-05; a
|
||||||
@@ -847,3 +931,27 @@ do not duplicate it there.
|
|||||||
p50 dropping below Compose's on the phone. Do this after the four bench
|
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.
|
||||||
+391
@@ -43,6 +43,397 @@ gated on her verdict**, so this pass works the P0 defects and the pure
|
|||||||
prerequisites in this order. Each item is ticked here by the agent that
|
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)
|
||||||
|
|
||||||
|
Iris plans to develop a desktop app as well, and asked that most code be
|
||||||
|
sharable between desktop and phone. The workspace already has that
|
||||||
|
shape -- `iris`, `client-core`, `transcript-ui` and `tabs-ui` are
|
||||||
|
platform-free, and `android-app`/`desktop-app` are the entry points --
|
||||||
|
so the rule is about keeping it: **a platform crate holds only what the
|
||||||
|
platform forces.** Today that is JNI, the IME and insets bridge, the
|
||||||
|
surface lifecycle and the bench JNI on Android; winit, argv and the
|
||||||
|
config file on the desktop. **What differs is the screen layout**, since a phone
|
||||||
|
screen with a finger and a desktop screen with a mouse want different
|
||||||
|
arrangements -- a session list beside the transcript rather than a
|
||||||
|
screen behind it, hover states, keyboard shortcuts. **What does not
|
||||||
|
differ is everything a layout is built from**: the widgets (a tap
|
||||||
|
button, a text field, a list, a card, a tool-call row), gestures,
|
||||||
|
folding, paging, selection, and the styling -- colours, spacing, type,
|
||||||
|
the surface ladder -- which is the exact same code on both, never a
|
||||||
|
desktop palette beside a phone one. Those are written once in a shared
|
||||||
|
crate, with a platform trait underneath when a behaviour genuinely
|
||||||
|
differs (`FocusHost`, `OpenUrl`, and the insets/`ime_visible`
|
||||||
|
feed are the existing examples). Two checks before finishing a change
|
||||||
|
under `iris/`: does `desktop-app` still build and run with it, and is
|
||||||
|
any UI logic newly in `android-app` that a desktop would also need?
|
||||||
|
The bench client (`android-app/src/bench_client.rs`, ~1000 lines) is
|
||||||
|
the first thing to look at moving, since a desktop bench on the same
|
||||||
|
fixture is layer 2 of the test rig below.
|
||||||
|
|
||||||
|
### Three test layers, cheapest first (decided 2026-09-07; layers 1 and 2 built the same day)
|
||||||
|
|
||||||
|
Iris's suggestion, adopted and layered: test at the cheapest layer that
|
||||||
|
can answer the question, and go up only when it cannot. The emulator
|
||||||
|
costs minutes a cycle; the desktop window seconds; the headless harness
|
||||||
|
runs inside `cargo test`.
|
||||||
|
|
||||||
|
1. **Headless, in-process, no compositor and no GPU -- the default.**
|
||||||
|
`iris::harness` (`iris/src/harness.rs`), plus the fixture crate it
|
||||||
|
opens. `Harness::new(size, density)` builds an `Rsc`, a
|
||||||
|
`UiRenderState` and a state whose `FocusHost`/`OpenUrl` *record* what
|
||||||
|
the platform was asked for; `frame(t_ms)`/`frames_until(..)` run
|
||||||
|
frames on a clock the test owns, and `replay(&TouchScript)` feeds a
|
||||||
|
recorded gesture one sample at a time exactly as
|
||||||
|
`IrisViewPeer::on_touch_event` replays Android's historical samples.
|
||||||
|
The recordings are plain `t_ms action x y` files under
|
||||||
|
`iris/transcript-fixture/touch/`, and `flick-120hz.touch` is the
|
||||||
|
phone's own shape: DOWN, four samples 4ms apart, UP, 20ms in total.
|
||||||
|
|
||||||
|
cd iris && cargo test -p transcript-fixture
|
||||||
|
|
||||||
|
runs in about a second and asserts (a) the flick releases with a real
|
||||||
|
velocity (`List::fling_velocity`, which only `Released(Some(v))`
|
||||||
|
fills), (b) the list travels and settles inside the AOSP spline's own
|
||||||
|
`FlingCalculator::duration`, (c) a tap moves nothing and opens no
|
||||||
|
link, (d) a long-press-then-drag leaves selected text and does not
|
||||||
|
pan, and (e) the composer clears a simulated 1000px IME inset
|
||||||
|
(`Composer::set_bottom_inset`). Each was confirmed to fail without
|
||||||
|
its subject rather than assumed: dropping `animate(id)` from
|
||||||
|
`Selection::drag` -- the phone's own "fling does nothing" defect --
|
||||||
|
and starting the fling curve at the wall clock each fail only the
|
||||||
|
flick test; flinging on `Tapped` fails only the tap test; a 5s
|
||||||
|
`LONG_PRESS` fails only the selection test; a `set_bottom_inset` that
|
||||||
|
ignores its argument fails only the composer test.
|
||||||
|
|
||||||
|
**What still cannot be answered below layer 3**: nothing renders
|
||||||
|
here, so anything about pixels -- glyph rasterisation, the atlas,
|
||||||
|
stale or duplicated primitives, colour, the surface lifecycle, the
|
||||||
|
renderer rebuild -- is invisible to layer 1 and only *looked at* in
|
||||||
|
layer 2. Frame *times* are not measurable at either: layer 1 does no
|
||||||
|
GPU work at all and layer 2 runs a debug build on this VM's virtio
|
||||||
|
GPU, so a number from either is not the phone's. Anything JNI (the
|
||||||
|
IME, real insets, the clipboard, battery) is layer 3 by construction:
|
||||||
|
layer 1 records that the platform was asked and layer 2 has no
|
||||||
|
Android platform to ask.
|
||||||
|
|
||||||
|
2. **A phone-shaped desktop window under headless sway -- for looking.**
|
||||||
|
|
||||||
|
cd iris && ./run-headless.sh phone --phone --shot /tmp/p.png -- -p transcript-fixture
|
||||||
|
|
||||||
|
About 15 seconds warm. `--phone` sets the private sway output to
|
||||||
|
1080x2424@120Hz and exports `IRIS_SCALE=2.55`, which reaches iris the
|
||||||
|
way `DisplayMetrics.density` does on Android
|
||||||
|
(`iris::default::content_scale`) -- the desktop backend now lays out
|
||||||
|
in physical pixels with a density instead of dividing into a separate
|
||||||
|
logical space, so both platforms run one path. `transcript-fixture`'s
|
||||||
|
`phone` example opens the same screen from the same bytes as layer 1
|
||||||
|
and the Android bench.
|
||||||
|
|
||||||
|
A gesture on screen uses the *same recordings*:
|
||||||
|
|
||||||
|
./run-headless.sh phone --phone --replay transcript-fixture/touch/flick-120hz.touch \
|
||||||
|
--shot /tmp/p.png -- -p transcript-fixture
|
||||||
|
|
||||||
|
writes `/tmp/p-before.png` and `/tmp/p.png` either side of the flick;
|
||||||
|
looked at 2026-09-07, the list moved back about seven turns of the
|
||||||
|
fixture and settled.
|
||||||
|
|
||||||
|
**`swaymsg seat - cursor` cannot drive it, and that cost an hour.**
|
||||||
|
This compositor runs the headless backend with no input devices
|
||||||
|
(`WLR_LIBINPUT_NO_DEVICES=1`, `LIBSEAT_BACKEND=noop`): the cursor
|
||||||
|
commands all report `success` and nothing whatever reaches the
|
||||||
|
client, with `swaymsg -t get_seats` showing `capabilities: 0` as the
|
||||||
|
only sign. wlroots 0.19 dropped `WLR_HEADLESS_INPUTS`, and ydotool's
|
||||||
|
uinput device would be ignored by a compositor that is not reading
|
||||||
|
libinput. `iris/rig-input`'s `replay-touch` uses the
|
||||||
|
**virtual-pointer protocol** instead, which is a client protocol and
|
||||||
|
needs neither devices nor root, and it parses `iris::harness`'s own
|
||||||
|
`TouchScript`. Two traps inside it, both found by printing winit's
|
||||||
|
events: a button sent in the same frame as the motion that first puts
|
||||||
|
the pointer over the window is dropped (the client sees the enter,
|
||||||
|
the moves and the *release*, never the press), so the pointer is
|
||||||
|
positioned and left to settle 200ms first; and a leftover window from
|
||||||
|
an earlier manual run **tiles beside the new one**, halving the width
|
||||||
|
and producing a screenshot that looks exactly like a duplicated-
|
||||||
|
primitive rendering bug -- `swaymsg -t get_tree` and `pgrep -af
|
||||||
|
examples/phone` are the check.
|
||||||
|
|
||||||
|
3. **The Android emulator -- platform plumbing and the final pass.**
|
||||||
|
JNI, IME, insets, surface lifecycle, the renderer rebuild, and one
|
||||||
|
verification run before a build goes to the phone. Not for iterating
|
||||||
|
on layout.
|
||||||
|
|
||||||
|
### The 2026-09-07 phone report on `ed04d4c`: the fling was linear, and the keyboard is a targetSdk
|
||||||
|
|
||||||
|
Iris's three lines on the `ed04d4c` build (Pixel 9 Pro XL, GrapheneOS
|
||||||
|
Android 17, Mali-G715, `content_scale` 2.55, 120Hz): item 4 (the resume
|
||||||
|
glyph corruption) **is fixed**, confirmed on the phone; "flinging now does
|
||||||
|
technically do something, but it seems to just be linear velocity with an
|
||||||
|
abrupt stop"; and "similarly, the keyboard raising up does not push things
|
||||||
|
upwards."
|
||||||
|
|
||||||
|
**The fling was exactly, arithmetically linear.** Not approximately.
|
||||||
|
`android_fling_spline::distance_fraction(t)` returned `t` for every `t`,
|
||||||
|
which is a constant-speed slide for the full `duration()` and then a stop
|
||||||
|
at full distance -- Iris's sentence, read straight off the code. Two
|
||||||
|
transposed halves of one AOSP loop did it, and they compounded:
|
||||||
|
|
||||||
|
1. AOSP's `SplineOverScroller` static initialiser **solves** the bisection
|
||||||
|
on the `P1`/`P2` curve and **samples** `SPLINE_POSITION[i]` from the
|
||||||
|
tension curve (`coef * ((1-x) * START_TENSION + x) + x³`); the second
|
||||||
|
half of the loop does the reverse to build `SPLINE_TIME`. iris had both
|
||||||
|
halves solving on the tension curve and sampling `P1`/`P2` -- so its two
|
||||||
|
loops were the *same computation*, and `SPLINE_POSITION == SPLINE_TIME`
|
||||||
|
element for element.
|
||||||
|
2. The lookup then bracketed `t` between **`SPLINE_TIME` entries** and
|
||||||
|
interpolated `SPLINE_POSITION`. AOSP brackets between the even time
|
||||||
|
steps `index / N` and `(index + 1) / N` (`SPLINE_TIME` is used only by
|
||||||
|
`adjustDuration`, which iris has no analogue of). With the two arrays
|
||||||
|
identical, `d_inf + (d_sup - d_inf)(t - t_inf)/(t_sup - t_inf)` reduces
|
||||||
|
to `t_inf + (t - t_inf)` = `t`.
|
||||||
|
|
||||||
|
Every test the calculator had compared it with itself -- monotonic, signed,
|
||||||
|
integrates to the closed form, per-tick deltas non-increasing -- and all of
|
||||||
|
them pass on a linear curve. That is the shape to distrust: `<=` is not
|
||||||
|
deceleration.
|
||||||
|
|
||||||
|
**Sources, read rather than remembered.** `frameworks/base`
|
||||||
|
`core/java/android/widget/OverScroller.java` from
|
||||||
|
`android.googlesource.com` (`?format=TEXT`, base64), and
|
||||||
|
`androidx.compose.animation:animation:1.12.0`'s `SplineBasedDecay.kt` and
|
||||||
|
`FlingCalculator.kt` out of the `-sources.jar` on
|
||||||
|
`dl.google.com/dl/android/maven2` (there is no androidx checkout here and
|
||||||
|
`cs.android.com` is JS-only; `androidx.tech` is now a parked domain serving
|
||||||
|
an unrelated site). The two agree line for line, which is why iris ports
|
||||||
|
one curve rather than two. The formulas, for the record:
|
||||||
|
|
||||||
|
P1 = START_TENSION * INFLEXION = 0.5 * 0.35
|
||||||
|
P2 = 1 - END_TENSION * (1 - INFLEXION) = 1 - 1 * 0.65
|
||||||
|
SPLINE_POSITION[i]: solve coef*((1-x)P1 + xP2) + x³ = i/100 for x,
|
||||||
|
then take coef*((1-x)ST + x) + x³
|
||||||
|
physical_coeff = 9.80665 * 39.37 * density * 160 * 0.84
|
||||||
|
l = ln(0.35 * |v| / (0.015 * physical_coeff))
|
||||||
|
distance = 0.015 * physical_coeff * exp(rate/(rate-1) * l)
|
||||||
|
duration = exp(l / (rate - 1)), rate = ln(0.78)/ln(0.9)
|
||||||
|
at time t: index = floor(100 * t/duration)
|
||||||
|
vcoef = (POS[index+1] - POS[index]) * 100
|
||||||
|
position = distance * (POS[index] + (t/duration - index/100) * vcoef)
|
||||||
|
speed = vcoef * distance / duration
|
||||||
|
|
||||||
|
**What changed.** `iris/src/sense.rs`'s `android_fling_spline` builds one
|
||||||
|
table, indexed by even time steps, and `sample(t)` answers AOSP's
|
||||||
|
`distanceCoef`/`velocityCoef` pair; `FlingCalculator` gained `velocity_at`
|
||||||
|
beside `position_at`. `iris/benches/fling_spline_reference.py` is an
|
||||||
|
independent hand transcription of both sources and prints the numbers the
|
||||||
|
tests assert on -- checked in because "numbers computed by the code under
|
||||||
|
test" is exactly how the last three tests passed through this defect.
|
||||||
|
Tests: `the_spline_matches_aosps_own_table` (the curve is not the
|
||||||
|
identity: 27.4% of the distance at a tenth of the time, 85.8% at half),
|
||||||
|
`a_flick_decelerates_the_way_aosp_says_it_does` (11064px/s at density
|
||||||
|
2.55: 6334px over 1.636s, speed 9202 -> 4733 -> 2650 -> 951px/s), and
|
||||||
|
`tick_fling_applies_shrinking_incremental_deltas` strengthened from
|
||||||
|
"non-increasing" to "the last delta is under 80% of the first". **Negative
|
||||||
|
control run**: with `sample` forced back to returning `t`, exactly those
|
||||||
|
three fail and the other eleven pass.
|
||||||
|
|
||||||
|
**Emulator evidence (API 36 AVD, debug, `force-gles`, 2026-09-07).** A
|
||||||
|
`ui-trace` swipe of 900px in 120ms releases at `v=3750` and the new
|
||||||
|
`iris fling tick:` debug line reports, frame by frame,
|
||||||
|
`speed=-3746 -> -2624 -> -1834 -> -1144 -> -752 -> -449 -> -243 -> -83px/s`
|
||||||
|
over 32 frames ending at `t=0.664s`, with the per-frame `dy` falling
|
||||||
|
`94 -> 34 -> 20 -> 13 -> 8 -> 4.4px`. **The end**: the same flick in the
|
||||||
|
other direction, from a list already at its newest end, produces exactly
|
||||||
|
one tick and stops -- no overshoot. **A finger during a fling**: swipe,
|
||||||
|
then a tap 200ms later, ends the fling at `t=0.248s` and 11 ticks instead
|
||||||
|
of running its full 0.55s.
|
||||||
|
|
||||||
|
**The keyboard: the bench app targeted SDK 34.** `app/build.gradle` said
|
||||||
|
`targetSdk = 34` while `compileSdk` was 37 and the Compose app in `app/`
|
||||||
|
targets 37 -- and that Compose app's keyboard *does* push its transcript up
|
||||||
|
on Iris's phone. Below target 35 a window keeps the legacy behaviour, where
|
||||||
|
`adjustResize` shrinks the window for the IME so `getInsets(ime()).bottom`
|
||||||
|
measures the overlap with an already-shrunk window and is zero;
|
||||||
|
`MainActivity`'s `setDecorFitsSystemWindows(false)` opts out of that and
|
||||||
|
still takes on the API 36 emulator here, which is why every test run showed
|
||||||
|
the push-up working. It is deprecated as of API 35 and Android 17 is where
|
||||||
|
it appears no longer to. Fixed by `targetSdk = 37`, where edge-to-edge is
|
||||||
|
not opt-in.
|
||||||
|
|
||||||
|
That is a *reading*, not a measurement -- no Android 17 device is reachable
|
||||||
|
from here -- so the second half of the change is making the phone able to
|
||||||
|
answer it. `MainActivity` now also registers a
|
||||||
|
`WindowInsetsAnimation.Callback` (`DISPATCH_MODE_CONTINUE_ON_SUBTREE`,
|
||||||
|
`onProgress` forwarding, `onEnd` re-reading `getRootWindowInsets` so an
|
||||||
|
interrupted animation cannot leave a frozen value), which delivers the IME
|
||||||
|
height on devices where only the animation path carries it and, on every
|
||||||
|
device, makes the push-up *animate* with the keyboard: the emulator log now
|
||||||
|
shows `ime_bottom=509, 663, 833, 881, 883` instead of one jump to 883. And
|
||||||
|
`insets::Shared::updates` counts every dispatch, which
|
||||||
|
`AndroidUiState::insets_report()` puts in the **Diagnostics pane**:
|
||||||
|
|
||||||
|
insets: dispatches=27 left=0 top=142 right=0 bottom=63 ime_bottom=0 ime_visible=false
|
||||||
|
|
||||||
|
Screenshot-verified on the emulator. Iris has no logcat, and "the listener
|
||||||
|
never fired" and "it fired with a zero height" look identical on screen;
|
||||||
|
`dispatches=0` prints its own sentence instead of the numbers, since those
|
||||||
|
would be defaults rather than measurements.
|
||||||
|
|
||||||
|
**What to look at on the next build.** Open the keyboard, press
|
||||||
|
`Diagnostics`, screenshot the `insets:` line. `ime_bottom` in the hundreds
|
||||||
|
with the composer risen: fixed. `dispatches` climbing but `ime_bottom=0`:
|
||||||
|
the targetSdk reading was wrong and the window is still being resized.
|
||||||
|
`dispatches=0`: the listener is not being called at all, which is a
|
||||||
|
different fault from either. For the fling, a flick should now visibly
|
||||||
|
slow before it stops rather than running out at speed.
|
||||||
|
|
||||||
|
### The 22:16 phone report, worked 2026-09-06/07
|
||||||
|
|
||||||
|
Iris's four items are listed in docs/IRIS_TODO.md's "From the phone,
|
||||||
|
2026-09-06, 22:16"; this is what was found and what was run. Item 4 was
|
||||||
|
committed on its own (`ba2afba`); items 1-3 and everything below landed
|
||||||
|
together after the emulator evidence.
|
||||||
|
|
||||||
|
**Item 4, text cooked after a resume -- root cause, fixed in `ba2afba`.**
|
||||||
|
Not "the cached text primitives are never redrawn": they *are*.
|
||||||
|
`IrisViewPeer::surface_changed` (`iris/src/android/view.rs`) calls
|
||||||
|
`render.resize(...)` on every surface event including the new-renderer
|
||||||
|
branch, which sets `UiRenderState::resized`, which makes the next
|
||||||
|
`update` take `redraw_all` rather than `redraw_updates` -- so after a
|
||||||
|
resume every widget's `draw` runs again. The stale coordinates come from
|
||||||
|
one cache further in: `TextView::render` (`iris/src/widget/text/mod.rs`)
|
||||||
|
returns its cached `RenderedText` whenever the wrap width, buffer and
|
||||||
|
attrs are unchanged, so `TextData::place` is never reached, no glyph is
|
||||||
|
re-rasterised into the fresh atlas, and the *previous* atlas's
|
||||||
|
`uv_min`/`uv_max`/`layer` go straight back to the GPU. Text whose content
|
||||||
|
changed after the resume -- the diagnostics pane Iris tapped -- re-shapes
|
||||||
|
and is therefore perfect, which is exactly the split in her screenshot.
|
||||||
|
The fix is one mechanism: a `generation` counter on `GlyphAtlas`, bumped
|
||||||
|
by `clear`, recorded on each `RenderedText`, added to `TextView::render`'s
|
||||||
|
cache key, with a `debug_assert_eq!` in `Painter::glyphs` that a submitted
|
||||||
|
quad's generation is the live one. Test
|
||||||
|
`clearing_the_atlas_re_renders_cached_text_instead_of_reusing_it`, run and
|
||||||
|
passing; **still needs phone-side confirmation**, since no emulator here
|
||||||
|
has a Vulkan adapter and the GLES path may not destroy the surface at all.
|
||||||
|
|
||||||
|
**Item 2, the keyboard would not reopen -- fixed and confirmed.**
|
||||||
|
`attr.rs`'s `on_press`, already-focused branch, now calls `focus_gained`
|
||||||
|
on a tap that stays inside `DRAG_SLOP`, which is what Android's own
|
||||||
|
`EditText` does (`showSoftInput` is idempotent). Emulator, 2026-09-07:
|
||||||
|
first tap `mInputShown=true`; back gesture; second tap `mInputShown=true`
|
||||||
|
and the composer rises again. **Negative control run**: with that one call
|
||||||
|
removed and nothing else changed, the second tap leaves
|
||||||
|
`mInputShown=false` -- Iris's report exactly. **The case the fix had no
|
||||||
|
reason to touch**, also run: a horizontal swipe across the focused
|
||||||
|
composer and a vertical swipe out of it both leave `mInputShown=false`, so
|
||||||
|
her earlier "if I swipe over the input bar it brings up the keyboard" has
|
||||||
|
not come back.
|
||||||
|
|
||||||
|
**Item 3, the IME height -- fixed and confirmed.** `MainActivity.java`
|
||||||
|
sends `getInsets(ime()).bottom` *and* `isVisible(ime())` as two separate
|
||||||
|
values (the height used to be sent as the boolean 1/0, which is why
|
||||||
|
nothing could pad by it); `Insets`/`WindowInsets` carry both, and
|
||||||
|
`bench_client.rs` reads the boolean for its state machine and the height
|
||||||
|
for `Composer::set_bottom_inset`. The list follows for free -- it is
|
||||||
|
`.height(rest(1))` in the same `Span` as the composer bar, so the bar
|
||||||
|
growing shrinks the list. Emulator, 2026-09-07:
|
||||||
|
`iris insets: ... bottom=883 ime_bottom=883 ime_visible=true`, and the
|
||||||
|
composer's box moves from `31,2277..1048,2329` to `31,1457..1048,1509` --
|
||||||
|
820px, which is 883 less the 63px navigation bar it was already clearing.
|
||||||
|
Screenshot checked: the transcript ends above the composer, which sits on
|
||||||
|
the keyboard.
|
||||||
|
|
||||||
|
**Item 1, the fling -- two more defects behind the first, all three
|
||||||
|
fixed here; the phone is what settles it.** The velocity half is what the
|
||||||
|
report predicted: `on_touch_event` read only each `MotionEvent`'s final
|
||||||
|
position, so a batched 120Hz flick fed the tracker one sample and
|
||||||
|
`velocity()` answered 0.0. It now replays every historical sample
|
||||||
|
(`getHistoricalAxisValue`/`getHistoricalEventTimeNanos`) through the
|
||||||
|
sensor pass, `CursorState` carries the sample's *own* time (so a replay
|
||||||
|
loop's speed cannot become the measured velocity), and the press itself is
|
||||||
|
a sample, as Android's own `VelocityTracker` does with `ACTION_DOWN`.
|
||||||
|
`iris drag release: samples=… span=…ms v=… outcome=…` logs the decision.
|
||||||
|
Then the emulator showed the two the report could not have known about:
|
||||||
|
|
||||||
|
1. **Nothing ever advanced a fling.** `List::fling` sets the state;
|
||||||
|
`tick_fling` moves it; and `tick_fling`'s only caller in the workspace
|
||||||
|
was `bench_client.rs`'s own fling phase, which drives it in a loop.
|
||||||
|
So the benchmark flung and a finger never did -- and the earlier
|
||||||
|
"verified flinging on the emulator with `render()` counts" was that
|
||||||
|
benchmark measuring itself. Measured before the fix: frames stop on
|
||||||
|
the same millisecond as `iris drag release`. iris now has one
|
||||||
|
animation mechanism -- `Widget::tick(now) -> bool`, ids registered
|
||||||
|
with `UiData::animate`, drained each frame by
|
||||||
|
`UiData::tick_animations`, which both backends call before the draw
|
||||||
|
and re-request a frame from while it answers true. `List::tick` is
|
||||||
|
`tick_fling`; `Selection::drag` registers on `Released(Some(v))`.
|
||||||
|
Test: `a_registered_fling_is_driven_by_tick_animations_and_then_
|
||||||
|
unregisters`, confirmed to fail without the registration.
|
||||||
|
2. **The fling lasted 45 seconds.** Visible only once flings animated at
|
||||||
|
all. Two causes, both in `FlingCalculator`: `List::fling` hardcoded
|
||||||
|
`FlingCalculator::new(1.0)` while the velocity it is fed is in
|
||||||
|
physical pixels (`List` reads `painter.density()` now), and
|
||||||
|
`physical_coefficient` multiplied by `FLING_FRICTION` (0.015) where
|
||||||
|
AOSP multiplies by its own tuning constant **0.84** -- a coefficient
|
||||||
|
56x too small, put through `exp(ln(…)/(rate-1))`. Every existing test
|
||||||
|
compared the calculator with itself (monotonic, signed, integrates to
|
||||||
|
the closed form) and so passed throughout;
|
||||||
|
`a_flick_lasts_what_aosps_own_formula_says_it_does` pins the absolute
|
||||||
|
numbers against AOSP's formula worked by hand. Emulator after both:
|
||||||
|
release at `v=11064`, frames for **1.62s**, then none -- against
|
||||||
|
AOSP's own 1.586s for that velocity at density 2.75.
|
||||||
|
|
||||||
|
**What Iris should look for on the phone**: `adb logcat | grep "iris
|
||||||
|
drag release"`. `samples=1` or `span=0.0ms` means the historical
|
||||||
|
replay is not reaching the tracker on her device; a sensible
|
||||||
|
`samples`/`span` with `v=` in the thousands and `outcome=Released(Some
|
||||||
|
(…))` means the gesture is measured correctly and anything still wrong
|
||||||
|
is downstream of it. `outcome=Tapped` means the flick never crossed
|
||||||
|
the slop.
|
||||||
|
|
||||||
|
**Two things found on the way, both pre-existing at `ba2afba`.**
|
||||||
|
|
||||||
|
- **`MOVE_CHAIN_LIMIT` was 16 and the composer's chain is 17.** Tapping
|
||||||
|
the composer in any debug build aborted on `resolve_move_chain`'s
|
||||||
|
assert; in a release build (what Iris runs) the walk simply stops
|
||||||
|
summing, on the CPU *and* in shader.wgsl, so a widget past the bound
|
||||||
|
draws and hit-tests short by whatever the outer slots held, with
|
||||||
|
nothing on screen to say so. Both constants are 64 now, and the assert
|
||||||
|
prints the chain (`64(0, 0) -> 63(0, 0) -> … -> 0(0, 0)`) so a cycle
|
||||||
|
and an honestly-deep tree can be told apart -- which is how this one
|
||||||
|
was: 17 distinct slots.
|
||||||
|
- **`minSdk` is 29**, up from 26. `getEventTimeNanos` and
|
||||||
|
`getHistoricalEventTimeNanos` are API 29, and a missing JNI method
|
||||||
|
there is a hard crash on the first touch rather than a degraded fling.
|
||||||
|
`build-apk.sh`'s `cargo ndk -P` matches.
|
||||||
|
|
||||||
|
Still open and **pre-existing**: the composer bar's grey background is not
|
||||||
|
drawn on the `transcript-screen bench` build, so the transcript shows
|
||||||
|
through where the bar should be (`Stack{StackSize::Child(1)}` is the thing
|
||||||
|
to look at). Unchanged by any of the above.
|
||||||
|
|
||||||
### Task A, closed 2026-09-06: the composer scrolls on a finger
|
### Task A, closed 2026-09-06: the composer scrolls on a finger
|
||||||
|
|
||||||
`iris/transcript-ui/src/composer.rs` is `field.scrollable().masked()` now.
|
`iris/transcript-ui/src/composer.rs` is `field.scrollable().masked()` now.
|
||||||
|
|||||||
@@ -33,3 +33,14 @@ one in place when it turns out to need a decision.
|
|||||||
that would work today, for Claude sessions, and it is the option that was
|
that would work today, for Claude sessions, and it is the option that was
|
||||||
not chosen.
|
not chosen.
|
||||||
|
|
||||||
|
|
||||||
|
## From Iris's phone log export, 2026-09-07 (Compose app)
|
||||||
|
|
||||||
|
- [ ] **Crash on 2026-09-03 11:40, `IllegalArgumentException: Reversed
|
||||||
|
range is not supported`** at `ToolInput.kt:200` (`highlighted`, inside
|
||||||
|
`ToolInputView` -> `RawBlock` -> `ToolCard`). An `AnnotatedString`
|
||||||
|
range was built with end before start while highlighting a tool
|
||||||
|
input. Found in the per-package system log she exported; the tool
|
||||||
|
input that triggered it is not in the log. Reproduce by fuzzing
|
||||||
|
`highlighted` with inputs whose token boundaries collapse, and guard
|
||||||
|
the range construction.
|
||||||
Generated
+37
-16
@@ -965,9 +965,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[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",
|
||||||
|
|||||||
+10
-2
@@ -89,13 +89,21 @@ 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
|
||||||
# workspace, since it has no `[workspace]` table of its own and this
|
# workspace, since it has no `[workspace]` table of its own and this
|
||||||
# exclusion stops it inheriting this one): `cd android-app && cargo ndk
|
# exclusion stops it inheriting this one): `cd android-app && cargo ndk
|
||||||
# -t x86_64 -P 26 build`.
|
# -t x86_64 -P 29 build`.
|
||||||
exclude = ["android-app"]
|
exclude = ["android-app"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
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"
|
||||||
|
|||||||
@@ -13,8 +13,37 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "dev.iris.android.demo"
|
applicationId = "dev.iris.android.demo"
|
||||||
minSdk = 26
|
// 29, not 26: `iris::android::view`'s touch handler dates each
|
||||||
targetSdk = 34
|
// sample with `MotionEvent.getEventTimeNanos` and
|
||||||
|
// `getHistoricalEventTimeNanos`, both API 29, and a missing JNI
|
||||||
|
// method there is a hard crash on the first touch rather than a
|
||||||
|
// degraded fling. Raised deliberately rather than guarded at
|
||||||
|
// runtime: nothing this app is built for runs below 29, and an
|
||||||
|
// untested fallback path is its own defect. `build-apk.sh`'s
|
||||||
|
// `cargo ndk -P` is kept at the same number.
|
||||||
|
minSdk = 29
|
||||||
|
// 37, matching `compileSdk` and the Compose app in `app/` -- which
|
||||||
|
// is the one part of this that is measured rather than reasoned:
|
||||||
|
// that app targets 37 and its keyboard does push the transcript up
|
||||||
|
// on Iris's phone, and this one targeted 34 and does not
|
||||||
|
// (2026-09-07). The emulator here is API 36 and the push-up works
|
||||||
|
// there at either target, so the target is the only difference the
|
||||||
|
// two devices do not share.
|
||||||
|
//
|
||||||
|
// The mechanism, stated as the reading it is: below targetSdk 35
|
||||||
|
// a window keeps the legacy behaviour, where `adjustResize` shrinks
|
||||||
|
// the window for the IME and `getInsets(ime()).bottom` therefore
|
||||||
|
// measures the overlap with an already-shrunk window -- zero, with
|
||||||
|
// nothing left to push up. `MainActivity`'s
|
||||||
|
// `setDecorFitsSystemWindows(false)` opts out of that, and on API
|
||||||
|
// 36 it still takes; Android 16 deprecated it and Android 17 is
|
||||||
|
// where it appears not to. At 35+ edge-to-edge is not opt-in, so
|
||||||
|
// the app is handed the real overlap without relying on a
|
||||||
|
// deprecated call. If the phone still reports `ime_bottom=0` with
|
||||||
|
// a nonzero `dispatches` in the Diagnostics pane, this reading was
|
||||||
|
// wrong and the `WindowInsetsAnimation.Callback` in
|
||||||
|
// `MainActivity` is the other half to look at.
|
||||||
|
targetSdk = 37
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ public final class IrisView extends RustView {
|
|||||||
protected native long newViewPeer(Context context);
|
protected native long newViewPeer(Context context);
|
||||||
|
|
||||||
native void applyWindowInsetsNative(
|
native void applyWindowInsetsNative(
|
||||||
long peer, int left, int top, int right, int bottom, int imeBottom);
|
long peer, int left, int top, int right, int bottom, int imeBottom, int imeVisible);
|
||||||
|
|
||||||
native void unregisterInsetsNative(long peer);
|
native void unregisterInsetsNative(long peer);
|
||||||
|
|
||||||
@@ -35,8 +35,9 @@ public final class IrisView extends RustView {
|
|||||||
super(context);
|
super(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
void applyWindowInsets(int left, int top, int right, int bottom, int imeBottom) {
|
void applyWindowInsets(
|
||||||
applyWindowInsetsNative(mViewPeer, left, top, right, bottom, imeBottom);
|
int left, int top, int right, int bottom, int imeBottom, int imeVisible) {
|
||||||
|
applyWindowInsetsNative(mViewPeer, left, top, right, bottom, imeBottom, imeVisible);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import android.app.Activity;
|
|||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.view.WindowInsets;
|
import android.view.WindowInsets;
|
||||||
|
import android.view.WindowInsetsAnimation;
|
||||||
import android.widget.FrameLayout;
|
import android.widget.FrameLayout;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The android-view backend's demo activity (RUST.md's I2): one IrisView
|
* The android-view backend's demo activity (RUST.md's I2): one IrisView
|
||||||
@@ -50,36 +52,88 @@ public final class MainActivity extends Activity {
|
|||||||
getWindow().setDecorFitsSystemWindows(false);
|
getWindow().setDecorFitsSystemWindows(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **The keyboard's height arrives twice, over two different
|
||||||
|
// paths, and the phone needs the second one** (Iris, 2026-09-07:
|
||||||
|
// the emulator pushed the composer up and her Pixel did not).
|
||||||
|
// `setOnApplyWindowInsetsListener` is the platform's *settled*
|
||||||
|
// answer; `WindowInsetsAnimation.Callback` is the running one, and
|
||||||
|
// an IME that animates in delivers every intermediate height
|
||||||
|
// through the callback with the static dispatch arriving only at
|
||||||
|
// the ends -- on some devices only at `onEnd`. Registering both
|
||||||
|
// means neither device depends on the other's timing, and it is
|
||||||
|
// also what makes the push-up *animate* with the keyboard rather
|
||||||
|
// than jump when it lands.
|
||||||
|
//
|
||||||
|
// The two do not disagree, because they are the same call with the
|
||||||
|
// same numbers read out of whichever `WindowInsets` is current.
|
||||||
|
// `DISPATCH_MODE_CONTINUE_ON_SUBTREE` so this view consuming
|
||||||
|
// nothing keeps the ordinary dispatch running underneath.
|
||||||
|
// `onEnd` re-reads the root's insets rather than trusting the last
|
||||||
|
// `onProgress`: an animation interrupted mid-flight never delivers
|
||||||
|
// its final frame, which is exactly the fault the Compose app hit
|
||||||
|
// (AGENTS.md, "the composer can get stuck floating above the
|
||||||
|
// bottom of the screen").
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
view.setWindowInsetsAnimationCallback(new WindowInsetsAnimation.Callback(
|
||||||
|
WindowInsetsAnimation.Callback.DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
|
||||||
|
@Override
|
||||||
|
public WindowInsets onProgress(
|
||||||
|
WindowInsets insets, List<WindowInsetsAnimation> running) {
|
||||||
|
sendInsets(view, insets);
|
||||||
|
return insets;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onEnd(WindowInsetsAnimation animation) {
|
||||||
|
WindowInsets settled = view.getRootWindowInsets();
|
||||||
|
if (settled != null) {
|
||||||
|
sendInsets(view, settled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
view.setOnApplyWindowInsetsListener((v, insets) -> {
|
view.setOnApplyWindowInsetsListener((v, insets) -> {
|
||||||
int left = insets.getSystemWindowInsetLeft();
|
sendInsets((IrisView) v, insets);
|
||||||
int top = insets.getSystemWindowInsetTop();
|
|
||||||
int right = insets.getSystemWindowInsetRight();
|
|
||||||
int bottom = insets.getSystemWindowInsetBottom();
|
|
||||||
// The manifest declares adjustResize (AGENTS.md: without it the
|
|
||||||
// keyboard pans the whole window instead of resizing it), and
|
|
||||||
// under adjustResize the window itself shrinks to make room for
|
|
||||||
// the keyboard -- which is exactly the condition under which
|
|
||||||
// WindowInsets.Type.ime()'s own *inset amount* reports zero: it
|
|
||||||
// measures how much of the window the keyboard overlaps, and
|
|
||||||
// resize already made that overlap zero by construction. That
|
|
||||||
// numeric inset is not a usable "is the keyboard open" signal
|
|
||||||
// here (found while root-causing why bench_client.rs's keyboard
|
|
||||||
// phase and auto-diagnostics never fired on the emulator despite
|
|
||||||
// the keyboard visibly opening -- RUST.md's P0 box). What does
|
|
||||||
// survive adjustResize is the boolean isVisible() answer, set
|
|
||||||
// from the platform's own start/end of the transition over a
|
|
||||||
// different path than the inset amount -- the same fact
|
|
||||||
// AGENTS.md's "Things that have bitten" already names for the
|
|
||||||
// Compose side's identical trap. Passed through as a 0/1 stand-
|
|
||||||
// in for the ime_bottom pixel amount, since nothing on the Rust
|
|
||||||
// side reads it as a real pixel value -- only `> 0.0`.
|
|
||||||
int imeBottom = 0;
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
|
|
||||||
&& insets.isVisible(WindowInsets.Type.ime())) {
|
|
||||||
imeBottom = 1;
|
|
||||||
}
|
|
||||||
((IrisView) v).applyWindowInsets(left, top, right, bottom, imeBottom);
|
|
||||||
return insets;
|
return insets;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Read one `WindowInsets` and hand it to the Rust side. The only
|
||||||
|
* place that reads these fields, so the static dispatch and the
|
||||||
|
* animation callback above cannot come to report different things. */
|
||||||
|
private static void sendInsets(IrisView view, WindowInsets insets) {
|
||||||
|
int left = insets.getSystemWindowInsetLeft();
|
||||||
|
int top = insets.getSystemWindowInsetTop();
|
||||||
|
int right = insets.getSystemWindowInsetRight();
|
||||||
|
int bottom = insets.getSystemWindowInsetBottom();
|
||||||
|
// **Two separate answers, because they are separate questions**
|
||||||
|
// (Iris's phone, 2026-09-06: "message box does not push up the
|
||||||
|
// scroll area"). `isVisible(ime())` says whether the keyboard is
|
||||||
|
// up; `getInsets(ime()).bottom` says how tall it is. An earlier
|
||||||
|
// pass sent the boolean *as* the height (0 or 1) because under
|
||||||
|
// plain `adjustResize` the window shrinks to make room and the ime
|
||||||
|
// inset therefore measures a zero overlap by construction -- true
|
||||||
|
// then, and no longer true now that this is an edge-to-edge window
|
||||||
|
// (`targetSdk` 35+, plus the `setDecorFitsSystemWindows` call
|
||||||
|
// above for the devices below that), which is exactly the case
|
||||||
|
// where the system stops resizing and hands the app the real
|
||||||
|
// overlap instead. Sending 1 for it left the Rust side padding the
|
||||||
|
// composer by one physical pixel, so the keyboard covered the bar
|
||||||
|
// and the transcript alike.
|
||||||
|
//
|
||||||
|
// The visibility is still sent in its own right rather than
|
||||||
|
// inferred from `height > 0`: the two disagree during the
|
||||||
|
// keyboard's slide-in and -out (visible, height still climbing),
|
||||||
|
// and "is the IME up" drives the bench's own state machine
|
||||||
|
// (`bench_client.rs`'s `ime_state`) where a half-open frame
|
||||||
|
// reading as "closed" is a miscount.
|
||||||
|
int imeBottom = 0;
|
||||||
|
int imeVisible = 0;
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
imeBottom = insets.getInsets(WindowInsets.Type.ime()).bottom;
|
||||||
|
imeVisible = insets.isVisible(WindowInsets.Type.ime()) ? 1 : 0;
|
||||||
|
}
|
||||||
|
view.applyWindowInsets(left, top, right, bottom, imeBottom, imeVisible);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -59,9 +59,9 @@ export ANDROID_NDK_HOME="$NDK_DIR"
|
|||||||
rm -rf app/src/main/jniLibs
|
rm -rf app/src/main/jniLibs
|
||||||
echo "build-apk.sh: cargo ndk -t $ABI build ${BUILD_TYPE:+(${BUILD_TYPE})} --features \"$FEATURES\""
|
echo "build-apk.sh: cargo ndk -t $ABI build ${BUILD_TYPE:+(${BUILD_TYPE})} --features \"$FEATURES\""
|
||||||
if [ "$BUILD_TYPE" = "release" ]; then
|
if [ "$BUILD_TYPE" = "release" ]; then
|
||||||
cargo ndk -t "$ABI" -P 26 -o app/src/main/jniLibs/ build --release --features "$FEATURES"
|
cargo ndk -t "$ABI" -P 29 -o app/src/main/jniLibs/ build --release --features "$FEATURES"
|
||||||
else
|
else
|
||||||
cargo ndk -t "$ABI" -P 26 -o app/src/main/jniLibs/ build --features "$FEATURES"
|
cargo ndk -t "$ABI" -P 29 -o app/src/main/jniLibs/ build --features "$FEATURES"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
GRADLE_TASK="assembleDebug"
|
GRADLE_TASK="assembleDebug"
|
||||||
|
|||||||
@@ -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}"))
|
||||||
@@ -408,7 +371,12 @@ impl AndroidAppState for BenchClient {
|
|||||||
.set_bottom_inset(rsc, insets.bottom.max(insets.ime_bottom));
|
.set_bottom_inset(rsc, insets.bottom.max(insets.ime_bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
let ime_visible = insets.ime_bottom > 0.0;
|
// The platform's own answer, not `ime_bottom > 0.0` -- see
|
||||||
|
// `iris::android::WindowInsets::ime_bottom`. The height is still
|
||||||
|
// climbing while the keyboard slides in, so a frame or two of a
|
||||||
|
// real opening reads as "closed" when the boolean is inferred from
|
||||||
|
// it, and `shown_events`/`hidden_events` below count transitions.
|
||||||
|
let ime_visible = insets.ime_visible;
|
||||||
|
|
||||||
let mut ime = self.ime_state.lock().unwrap();
|
let mut ime = self.ime_state.lock().unwrap();
|
||||||
if ime_visible && !ime.visible {
|
if ime_visible && !ime.visible {
|
||||||
@@ -541,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);
|
||||||
}
|
}
|
||||||
@@ -569,10 +536,15 @@ impl BenchClient {
|
|||||||
Some(stats) => format!("{stats}"),
|
Some(stats) => format!("{stats}"),
|
||||||
None => "no frames recorded yet".to_string(),
|
None => "no frames recorded yet".to_string(),
|
||||||
};
|
};
|
||||||
match &self.android_state().renderer {
|
let renderer = match &self.android_state().renderer {
|
||||||
Some(renderer) => renderer.diagnostics_report(&font, &frame_report),
|
Some(renderer) => renderer.diagnostics_report(&font, &frame_report),
|
||||||
None => "iris diagnostics: no renderer yet (no surface)".to_string(),
|
None => "iris diagnostics: no renderer yet (no surface)".to_string(),
|
||||||
}
|
};
|
||||||
|
// The insets line goes in the pane, not just the log: Iris has no
|
||||||
|
// logcat on her phone, and "the keyboard does not push the
|
||||||
|
// composer up" cannot be told from "the listener never fired"
|
||||||
|
// without it (`AndroidUiState::insets_report`).
|
||||||
|
format!("{renderer}\n{}", self.android_state().insets_report())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The keyboard's own diagnostics capture -- see `on_insets_changed`'s
|
/// The keyboard's own diagnostics capture -- see `on_insets_changed`'s
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""AOSP's fling spline, transcribed independently of the Rust port.
|
||||||
|
|
||||||
|
This exists so the numbers in `sense.rs`'s `the_spline_matches_aosps_own_table`
|
||||||
|
and `a_flick_decelerates_the_way_aosp_says_it_does` are not the Rust code
|
||||||
|
grading its own homework. Every test iris's fling had before 2026-09-07
|
||||||
|
compared the curve with itself -- monotonic, signed, integrates to the closed
|
||||||
|
form -- and all of them passed while `distance_fraction(t)` was returning
|
||||||
|
exactly `t` (see `android_fling_spline`'s doc comment). Numbers checked into a
|
||||||
|
test have to come from somewhere else, and this is the somewhere else.
|
||||||
|
|
||||||
|
Transcribed by hand from, and only from:
|
||||||
|
|
||||||
|
* frameworks/base `core/java/android/widget/OverScroller.java`,
|
||||||
|
`SplineOverScroller`'s static initialiser, `getSplineDeceleration`,
|
||||||
|
`getSplineFlingDistance`, `getSplineFlingDuration` and `update`.
|
||||||
|
* androidx.compose.animation:animation:1.12.0 `SplineBasedDecay.kt`
|
||||||
|
(`computeSplineInfo`, `AndroidFlingSpline.flingPosition`) and
|
||||||
|
`FlingCalculator.kt` (`computeDeceleration`, `flingDistance`,
|
||||||
|
`flingDuration`, `FlingInfo.position`/`velocity`). The two agree line for
|
||||||
|
line, which is why iris ports one curve rather than two.
|
||||||
|
|
||||||
|
Run it with no arguments; it prints the table entries and the (velocity,
|
||||||
|
density, t) points the Rust tests assert on.
|
||||||
|
"""
|
||||||
|
|
||||||
|
NB_SAMPLES = 100
|
||||||
|
INFLEXION = 0.35
|
||||||
|
START_TENSION = 0.5
|
||||||
|
END_TENSION = 1.0
|
||||||
|
P1 = START_TENSION * INFLEXION
|
||||||
|
P2 = 1.0 - END_TENSION * (1.0 - INFLEXION)
|
||||||
|
|
||||||
|
# ViewConfiguration.getScrollFriction(), and SplineOverScroller's own
|
||||||
|
# "look and feel tuning" constant -- a different number in a different place
|
||||||
|
# of the same formula, which is the pair iris got the wrong way round once.
|
||||||
|
SCROLL_FRICTION = 0.015
|
||||||
|
TUNING = 0.84
|
||||||
|
GRAVITY_EARTH = 9.80665
|
||||||
|
INCHES_PER_METER = 39.37
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
DECELERATION_RATE = math.log(0.78) / math.log(0.9)
|
||||||
|
|
||||||
|
|
||||||
|
def spline_positions():
|
||||||
|
"""SPLINE_POSITION: distance fraction at each of 101 even time steps."""
|
||||||
|
position = [0.0] * (NB_SAMPLES + 1)
|
||||||
|
x_min = 0.0
|
||||||
|
for i in range(NB_SAMPLES):
|
||||||
|
alpha = i / NB_SAMPLES
|
||||||
|
x_max = 1.0
|
||||||
|
while True:
|
||||||
|
x = x_min + (x_max - x_min) / 2.0
|
||||||
|
coef = 3.0 * x * (1.0 - x)
|
||||||
|
# Solved on the P1/P2 curve...
|
||||||
|
tx = coef * ((1.0 - x) * P1 + x * P2) + x * x * x
|
||||||
|
if abs(tx - alpha) < 1e-5:
|
||||||
|
break
|
||||||
|
if tx > alpha:
|
||||||
|
x_max = x
|
||||||
|
else:
|
||||||
|
x_min = x
|
||||||
|
# ...and sampled on the tension curve.
|
||||||
|
position[i] = coef * ((1.0 - x) * START_TENSION + x * END_TENSION) + x * x * x
|
||||||
|
position[NB_SAMPLES] = 1.0
|
||||||
|
return position
|
||||||
|
|
||||||
|
|
||||||
|
POSITION = spline_positions()
|
||||||
|
|
||||||
|
|
||||||
|
def fling_sample(t):
|
||||||
|
"""(distance fraction, velocity fraction) at time fraction `t`."""
|
||||||
|
t = min(max(t, 0.0), 1.0)
|
||||||
|
index = int(t * NB_SAMPLES)
|
||||||
|
if index >= NB_SAMPLES:
|
||||||
|
return 1.0, 0.0
|
||||||
|
t_inf = index / NB_SAMPLES
|
||||||
|
t_sup = (index + 1) / NB_SAMPLES
|
||||||
|
velocity_coef = (POSITION[index + 1] - POSITION[index]) / (t_sup - t_inf)
|
||||||
|
return POSITION[index] + (t - t_inf) * velocity_coef, velocity_coef
|
||||||
|
|
||||||
|
|
||||||
|
def physical_coefficient(density):
|
||||||
|
return GRAVITY_EARTH * INCHES_PER_METER * density * 160.0 * TUNING
|
||||||
|
|
||||||
|
|
||||||
|
def deceleration(velocity, density):
|
||||||
|
return math.log(
|
||||||
|
INFLEXION * abs(velocity) / (SCROLL_FRICTION * physical_coefficient(density))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fling_distance(velocity, density):
|
||||||
|
l = deceleration(velocity, density)
|
||||||
|
return (
|
||||||
|
SCROLL_FRICTION
|
||||||
|
* physical_coefficient(density)
|
||||||
|
* math.exp(DECELERATION_RATE / (DECELERATION_RATE - 1.0) * l)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fling_duration_s(velocity, density):
|
||||||
|
l = deceleration(velocity, density)
|
||||||
|
return math.exp(l / (DECELERATION_RATE - 1.0))
|
||||||
|
|
||||||
|
|
||||||
|
def position_at(velocity, density, t_seconds):
|
||||||
|
d = fling_duration_s(velocity, density)
|
||||||
|
return fling_distance(velocity, density) * fling_sample(t_seconds / d)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def velocity_at(velocity, density, t_seconds):
|
||||||
|
d = fling_duration_s(velocity, density)
|
||||||
|
return fling_sample(t_seconds / d)[1] * fling_distance(velocity, density) / d
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("SPLINE_POSITION at a few indices (index: value)")
|
||||||
|
for i in (0, 1, 10, 25, 50, 75, 99, 100):
|
||||||
|
print(f" {i:3}: {POSITION[i]:.6f}")
|
||||||
|
print()
|
||||||
|
print("distance/velocity fraction at time fractions")
|
||||||
|
for t in (0.0, 0.1, 0.25, 0.5, 0.75, 0.9, 1.0):
|
||||||
|
d, v = fling_sample(t)
|
||||||
|
print(f" t={t:<5} distance={d:.6f} velocity={v:.6f}")
|
||||||
|
print()
|
||||||
|
# 2.55 is Iris's Pixel 9 Pro XL (docs/bench/iris-phone-v2-2026-09-06.md);
|
||||||
|
# 2.75 is this checkout's emulator.
|
||||||
|
for density in (2.55, 2.75):
|
||||||
|
for velocity in (5000.0, 11064.0):
|
||||||
|
dur = fling_duration_s(velocity, density)
|
||||||
|
print(
|
||||||
|
f"density={density} v={velocity}: "
|
||||||
|
f"distance={fling_distance(velocity, density):.3f}px "
|
||||||
|
f"duration={dur:.4f}s"
|
||||||
|
)
|
||||||
|
# Deliberately not round fractions. The velocity coefficient is
|
||||||
|
# piecewise *constant* across each of the 100 samples, so it
|
||||||
|
# steps at t = k/100 and a test asserting on 0.75 is asserting
|
||||||
|
# on which side of a discontinuity the last float landed --
|
||||||
|
# which is genuinely different between Python and Rust and says
|
||||||
|
# nothing about the curve.
|
||||||
|
for frac in (0.125, 0.335, 0.505, 0.755):
|
||||||
|
t = frac * dur
|
||||||
|
print(
|
||||||
|
f" t={frac:>4} of duration ({t:.4f}s): "
|
||||||
|
f"pos={position_at(velocity, density, t):.3f}px "
|
||||||
|
f"vel={velocity_at(velocity, density, t):.3f}px/s"
|
||||||
|
)
|
||||||
@@ -80,11 +80,17 @@ var<storage> masks: array<Mask>;
|
|||||||
@group(3) @binding(1)
|
@group(3) @binding(1)
|
||||||
var<storage> move_offsets: array<MoveOffset>;
|
var<storage> move_offsets: array<MoveOffset>;
|
||||||
|
|
||||||
// A move chain more than this deep means something else is wrong (an
|
// The bound on the parent walk, kept in step with `MOVE_CHAIN_LIMIT` in
|
||||||
// accidental cycle) -- kept in step with `MOVE_CHAIN_LIMIT` in
|
// render_state.rs, which walks the identical chain on the CPU side for
|
||||||
// render_state.rs, which walks the identical bound on the CPU side for
|
// hit-testing. Bounded so a malformed chain (a cyclic `parent`) cannot
|
||||||
// hit-testing. Bounded so a malformed chain cannot hang the GPU.
|
// hang the GPU -- not a claim about how deep a real tree gets. It was 16
|
||||||
const MOVE_CHAIN_LIMIT: u32 = 16u;
|
// and that was too small: the transcript screen's composer field sits 17
|
||||||
|
// slots below the root, measured 2026-09-07 on this checkout's emulator
|
||||||
|
// by tapping it (the CPU walk's own debug assert names the chain now).
|
||||||
|
// Past the bound both walks simply stop summing, so the widget draws and
|
||||||
|
// hit-tests short by whatever the outer slots held, with nothing on
|
||||||
|
// screen to say so.
|
||||||
|
const MOVE_CHAIN_LIMIT: u32 = 64u;
|
||||||
|
|
||||||
/// Sums the pixel delta along the parent chain starting at `idx`, shared by
|
/// Sums the pixel delta along the parent chain starting at `idx`, shared by
|
||||||
/// the vertex stage (a primitive's own corners) and the fragment stage (its
|
/// the vertex stage (a primitive's own corners) and the fragment stage (its
|
||||||
|
|||||||
@@ -24,6 +24,46 @@ pub struct UiData {
|
|||||||
/// id (never reallocated), so a retained descendant's `parent` index
|
/// id (never reallocated), so a retained descendant's `parent` index
|
||||||
/// never goes stale -- see LAYOUT.md section 2.
|
/// never goes stale -- see LAYOUT.md section 2.
|
||||||
pub move_offsets: TrackedArena<MoveOffset, u32>,
|
pub move_offsets: TrackedArena<MoveOffset, u32>,
|
||||||
|
/// Every widget whose [`crate::Widget::tick`] should run before the
|
||||||
|
/// next frame -- today, a `List` coasting through a fling. Added by
|
||||||
|
/// [`Self::animate`] when the animation starts and removed by
|
||||||
|
/// [`Self::tick_animations`] the frame its `tick` answers `false`, so
|
||||||
|
/// a stopped animation costs nothing and a dropped widget cannot be
|
||||||
|
/// ticked (`get_dyn_mut` answers `None` and it is dropped the same
|
||||||
|
/// way).
|
||||||
|
animating: Vec<WidgetId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UiData {
|
||||||
|
/// Ask for `id`'s [`crate::Widget::tick`] to run every frame until it
|
||||||
|
/// says it is done. Idempotent -- registering an already-animating
|
||||||
|
/// widget is the ordinary case (a second fling before the first
|
||||||
|
/// settled) and must not tick it twice per frame.
|
||||||
|
pub fn animate(&mut self, id: WidgetId) {
|
||||||
|
if !self.animating.contains(&id) {
|
||||||
|
self.animating.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tick every registered widget to `now`, drop the ones that finished,
|
||||||
|
/// and say whether any is still going -- which is a backend's cue to
|
||||||
|
/// ask for another frame. Called once per frame *before* the draw, so
|
||||||
|
/// what the frame draws is this instant's position rather than the
|
||||||
|
/// previous one's.
|
||||||
|
pub fn tick_animations(&mut self, now: std::time::Instant) -> bool {
|
||||||
|
// Taken out and put back rather than iterated in place: `tick`
|
||||||
|
// needs `&mut` on the widget arena this list lives beside, and a
|
||||||
|
// widget is free to register another one while ticking.
|
||||||
|
let mut registered = std::mem::take(&mut self.animating);
|
||||||
|
registered.retain(|&id| match self.widgets.get_dyn_mut(id) {
|
||||||
|
Some(widget) => widget.tick(now),
|
||||||
|
None => false,
|
||||||
|
});
|
||||||
|
for id in registered {
|
||||||
|
self.animate(id);
|
||||||
|
}
|
||||||
|
!self.animating.is_empty()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait UiRsc {
|
pub trait UiRsc {
|
||||||
|
|||||||
@@ -60,10 +60,17 @@ pub struct UiRenderState {
|
|||||||
pub(super) shape_count: u64,
|
pub(super) shape_count: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A move chain more than this deep would mean something else is wrong
|
/// The bound on the parent walk -- see `resolve_move` in shader.wgsl,
|
||||||
/// (an accidental cycle) -- see `resolve_move` in shader.wgsl, which walks
|
/// which walks the identical chain and must be kept in step with this
|
||||||
/// the identical bound and must be kept in step with this constant.
|
/// constant. It exists so a cyclic `parent` link cannot hang either walk,
|
||||||
pub const MOVE_CHAIN_LIMIT: usize = 16;
|
/// not as a statement about how deep a real tree gets: it was 16, and the
|
||||||
|
/// transcript screen's composer field turned out to sit **17** slots below
|
||||||
|
/// the root (measured 2026-09-07 on this checkout's emulator, by tapping
|
||||||
|
/// the composer in a debug build -- the assert in `resolve_move_chain`
|
||||||
|
/// prints the chain). A chain past the bound is not reported anywhere at
|
||||||
|
/// run time; both walks just stop summing, so the widget is drawn and hit
|
||||||
|
/// tested short by whatever the outer slots held.
|
||||||
|
pub const MOVE_CHAIN_LIMIT: usize = 64;
|
||||||
|
|
||||||
impl UiRenderState {
|
impl UiRenderState {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
@@ -708,26 +715,56 @@ impl UiRenderState {
|
|||||||
/// pixel delta along the parent chain starting at `slot`. Both walks
|
/// pixel delta along the parent chain starting at `slot`. Both walks
|
||||||
/// share `MOVE_CHAIN_LIMIT` as their bound so the two cannot disagree
|
/// share `MOVE_CHAIN_LIMIT` as their bound so the two cannot disagree
|
||||||
/// about where the chain ends.
|
/// about where the chain ends.
|
||||||
fn resolve_move_chain(&self, mut slot: MoveIdx, rsc: &dyn UiRsc) -> Vec2 {
|
fn resolve_move_chain(&self, slot: MoveIdx, rsc: &dyn UiRsc) -> Vec2 {
|
||||||
let offsets = &rsc.ui().move_offsets;
|
let offsets = &rsc.ui().move_offsets;
|
||||||
let mut delta = Vec2::ZERO;
|
let mut delta = Vec2::ZERO;
|
||||||
|
let mut at = slot;
|
||||||
for i in 0..MOVE_CHAIN_LIMIT {
|
for i in 0..MOVE_CHAIN_LIMIT {
|
||||||
let entry = &offsets[slot.idx()];
|
let entry = &offsets[at.idx()];
|
||||||
delta.x += entry.delta[0];
|
delta.x += entry.delta[0];
|
||||||
delta.y += entry.delta[1];
|
delta.y += entry.delta[1];
|
||||||
if entry.parent == MoveOffset::NONE_PARENT {
|
if entry.parent == MoveOffset::NONE_PARENT {
|
||||||
return delta;
|
return delta;
|
||||||
}
|
}
|
||||||
slot = Id::preset(entry.parent);
|
at = Id::preset(entry.parent);
|
||||||
|
// The chain itself, not just the fact that it was too long: a
|
||||||
|
// cycle and a tree genuinely nested deeper than the shader can
|
||||||
|
// follow are different faults with different fixes, and the
|
||||||
|
// slot numbers are the only thing that tells them apart.
|
||||||
debug_assert!(
|
debug_assert!(
|
||||||
i + 1 < MOVE_CHAIN_LIMIT,
|
i + 1 < MOVE_CHAIN_LIMIT,
|
||||||
"move offset chain exceeded MOVE_CHAIN_LIMIT; a widget's `parent` link is \
|
"move offset chain exceeded MOVE_CHAIN_LIMIT ({MOVE_CHAIN_LIMIT}): {chain} -- a \
|
||||||
probably cyclic"
|
repeated slot means a `parent` link is cyclic, all-distinct slots mean the tree \
|
||||||
|
nests deeper than shader.wgsl's own walk of the same bound",
|
||||||
|
chain = Self::move_chain_debug(slot, offsets)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
delta
|
delta
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The parent chain from `slot`, as `slot(dx, dy) -> ...`, walked twice
|
||||||
|
/// `MOVE_CHAIN_LIMIT` so a cycle shows up as a repeated slot number
|
||||||
|
/// rather than as a chain that merely stops. Only ever called from the
|
||||||
|
/// failed assertion above.
|
||||||
|
fn move_chain_debug(slot: MoveIdx, offsets: &[MoveOffset]) -> String {
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
let mut at = slot;
|
||||||
|
for _ in 0..MOVE_CHAIN_LIMIT * 2 {
|
||||||
|
let entry = &offsets[at.idx()];
|
||||||
|
parts.push(format!(
|
||||||
|
"{}({}, {})",
|
||||||
|
at.idx(),
|
||||||
|
entry.delta[0],
|
||||||
|
entry.delta[1]
|
||||||
|
));
|
||||||
|
if entry.parent == MoveOffset::NONE_PARENT {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
at = Id::preset(entry.parent);
|
||||||
|
}
|
||||||
|
parts.join(" -> ")
|
||||||
|
}
|
||||||
|
|
||||||
pub fn window_region(&self, id: &impl IdLike, rsc: &dyn UiRsc) -> Option<PixelRegion> {
|
pub fn window_region(&self, id: &impl IdLike, rsc: &dyn UiRsc) -> Option<PixelRegion> {
|
||||||
let region = self.resolved_region(id, rsc)?;
|
let region = self.resolved_region(id, rsc)?;
|
||||||
Some(region.to_px(self.output_size))
|
Some(region.to_px(self.output_size))
|
||||||
|
|||||||
@@ -41,6 +41,25 @@ pub trait Widget: Any {
|
|||||||
fn access_role(&self) -> accesskit::Role {
|
fn access_role(&self) -> accesskit::Role {
|
||||||
accesskit::Role::Unknown
|
accesskit::Role::Unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Advance whatever this widget is animating to `now`, and say whether
|
||||||
|
/// it is still animating afterwards. Default: nothing is, so a widget
|
||||||
|
/// opts in by overriding this *and* by something calling
|
||||||
|
/// [`crate::UiData::animate`] with its id when the animation starts --
|
||||||
|
/// which is that animation's path out, since the driver
|
||||||
|
/// ([`crate::UiData::tick_animations`]) drops every id whose `tick`
|
||||||
|
/// answers `false`.
|
||||||
|
///
|
||||||
|
/// Called once per frame, before the frame's draw, by whichever
|
||||||
|
/// backend owns the surface; a `true` answer is what makes that
|
||||||
|
/// backend ask for another frame. So this is the only thing in iris
|
||||||
|
/// that moves without an input event, and a widget that animates
|
||||||
|
/// without registering simply never moves -- which is exactly how a
|
||||||
|
/// finger fling looked on Iris's phone before this existed.
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
fn tick(&mut self, now: std::time::Instant) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Widget for () {
|
impl Widget for () {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -45,16 +45,38 @@ pub struct Insets {
|
|||||||
pub top: i32,
|
pub top: i32,
|
||||||
pub right: i32,
|
pub right: i32,
|
||||||
pub bottom: i32,
|
pub bottom: i32,
|
||||||
/// The keyboard's own inset (`WindowInsetsCompat.Type.ime()`), separate
|
/// The keyboard's own inset (`WindowInsets.Type.ime()`), in physical
|
||||||
/// from `bottom` (the system bars): a layout wants to know about the
|
/// pixels, separate from `bottom` (the system bars): a layout wants to
|
||||||
/// keyboard specifically, since it usually means "make room" rather
|
/// know about the keyboard specifically, since it usually means "make
|
||||||
/// than "stay clear of a corner".
|
/// room" rather than "stay clear of a corner".
|
||||||
pub ime_bottom: i32,
|
pub ime_bottom: i32,
|
||||||
|
/// `WindowInsets.isVisible(ime())` -- whether the keyboard is up, which
|
||||||
|
/// is **not** the same question as `ime_bottom > 0` and is why the two
|
||||||
|
/// are carried separately. They disagree for the frames the keyboard
|
||||||
|
/// spends sliding: visible, with a height still on its way to the full
|
||||||
|
/// one. Anything asking "make how much room" reads `ime_bottom`;
|
||||||
|
/// anything asking "is the keyboard up" reads this. See
|
||||||
|
/// `MainActivity.java`'s comment for the history -- the height used to
|
||||||
|
/// be sent *as* this boolean, which is what left the composer padded by
|
||||||
|
/// one pixel on Iris's phone.
|
||||||
|
pub ime_visible: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct Shared {
|
pub struct Shared {
|
||||||
pub insets: Insets,
|
pub insets: Insets,
|
||||||
|
/// How many times Java has called `applyWindowInsetsNative` for this
|
||||||
|
/// peer, whether or not the numbers changed. Deliberately **not** a
|
||||||
|
/// field of `Insets`, which is compared for equality each frame to
|
||||||
|
/// decide whether to re-run `on_insets_changed`; a counter in there
|
||||||
|
/// would make every dispatch look like a change.
|
||||||
|
///
|
||||||
|
/// It exists because "the keyboard does not push anything up" has two
|
||||||
|
/// completely different causes that look identical on screen -- the
|
||||||
|
/// listener never fired, or it fired with a zero `ime_bottom` -- and
|
||||||
|
/// Iris has no logcat on her phone (docs/IRIS_TODO.md). This number is
|
||||||
|
/// in the `Diagnostics` overlay, so one screenshot separates them.
|
||||||
|
pub updates: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
type SharedMap = HashMap<jlong, SendWrapper<Rc<RefCell<Shared>>>>;
|
type SharedMap = HashMap<jlong, SendWrapper<Rc<RefCell<Shared>>>>;
|
||||||
@@ -89,15 +111,19 @@ extern "system" fn apply_window_insets<'local>(
|
|||||||
right: jint,
|
right: jint,
|
||||||
bottom: jint,
|
bottom: jint,
|
||||||
ime_bottom: jint,
|
ime_bottom: jint,
|
||||||
|
ime_visible: jint,
|
||||||
) {
|
) {
|
||||||
if let Some(shared) = map().lock().unwrap().get(&peer) {
|
if let Some(shared) = map().lock().unwrap().get(&peer) {
|
||||||
shared.borrow_mut().insets = Insets {
|
let mut shared = shared.borrow_mut();
|
||||||
|
shared.insets = Insets {
|
||||||
left,
|
left,
|
||||||
top,
|
top,
|
||||||
right,
|
right,
|
||||||
bottom,
|
bottom,
|
||||||
ime_bottom,
|
ime_bottom,
|
||||||
|
ime_visible: ime_visible != 0,
|
||||||
};
|
};
|
||||||
|
shared.updates += 1;
|
||||||
}
|
}
|
||||||
// Insets can change (the keyboard opening) with no resize and no
|
// Insets can change (the keyboard opening) with no resize and no
|
||||||
// touch, so nothing else here would otherwise ask for a frame.
|
// touch, so nothing else here would otherwise ask for a frame.
|
||||||
@@ -115,7 +141,7 @@ pub fn register_native_methods<'local, 'other_local>(
|
|||||||
&[
|
&[
|
||||||
NativeMethod {
|
NativeMethod {
|
||||||
name: "applyWindowInsetsNative".into(),
|
name: "applyWindowInsetsNative".into(),
|
||||||
sig: "(JIIIII)V".into(),
|
sig: "(JIIIIII)V".into(),
|
||||||
fn_ptr: apply_window_insets as *mut c_void,
|
fn_ptr: apply_window_insets as *mut c_void,
|
||||||
},
|
},
|
||||||
NativeMethod {
|
NativeMethod {
|
||||||
|
|||||||
+136
-10
@@ -7,9 +7,9 @@ use android_view::{
|
|||||||
jni::{
|
jni::{
|
||||||
JNIEnv, JavaVM,
|
JNIEnv, JavaVM,
|
||||||
objects::{GlobalRef, JValue},
|
objects::{GlobalRef, JValue},
|
||||||
sys::jint,
|
sys::{jint, jlong},
|
||||||
},
|
},
|
||||||
ndk::event::{Keycode, MotionAction},
|
ndk::event::{Axis, Keycode, MotionAction},
|
||||||
};
|
};
|
||||||
// `marker::Sized` explicitly: `crate::prelude::*` below also brings in the
|
// `marker::Sized` explicitly: `crate::prelude::*` below also brings in the
|
||||||
// `Sized` *widget* (`widget::position::sized::Sized`), and an unqualified
|
// `Sized` *widget* (`widget::position::sized::Sized`), and an unqualified
|
||||||
@@ -20,7 +20,7 @@ use std::{
|
|||||||
marker::{PhantomData, Sized},
|
marker::{PhantomData, Sized},
|
||||||
rc::Rc,
|
rc::Rc,
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
time::Instant,
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
@@ -130,6 +130,28 @@ impl AndroidUiState {
|
|||||||
pub fn insets(&self) -> Insets {
|
pub fn insets(&self) -> Insets {
|
||||||
self.shared.borrow().insets
|
self.shared.borrow().insets
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The insets state as one line for a diagnostics pane, including how
|
||||||
|
/// many times the platform has delivered any -- see
|
||||||
|
/// `insets::Shared::updates` for why the count is the load-bearing
|
||||||
|
/// part. `dispatches=0` says the listener has never run and the
|
||||||
|
/// numbers beside it are defaults rather than measurements, which is
|
||||||
|
/// the distinction a screenshot otherwise cannot make (UI_RULES.md,
|
||||||
|
/// "design the unknown state first").
|
||||||
|
pub fn insets_report(&self) -> String {
|
||||||
|
let shared = self.shared.borrow();
|
||||||
|
let i = shared.insets;
|
||||||
|
if shared.updates == 0 {
|
||||||
|
return "insets: dispatches=0 -- the platform has never called \
|
||||||
|
onApplyWindowInsets, so nothing below was measured"
|
||||||
|
.to_string();
|
||||||
|
}
|
||||||
|
format!(
|
||||||
|
"insets: dispatches={} left={} top={} right={} bottom={} ime_bottom={} \
|
||||||
|
ime_visible={}",
|
||||||
|
shared.updates, i.left, i.top, i.right, i.bottom, i.ime_bottom, i.ime_visible,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HasRoot for AndroidUiState {
|
impl HasRoot for AndroidUiState {
|
||||||
@@ -195,7 +217,12 @@ pub struct WindowInsets {
|
|||||||
pub top: f32,
|
pub top: f32,
|
||||||
pub right: f32,
|
pub right: f32,
|
||||||
pub bottom: f32,
|
pub bottom: f32,
|
||||||
|
/// How much of the window the keyboard covers, in physical pixels --
|
||||||
|
/// what a layout pads by. See `insets::Insets::ime_visible` for why
|
||||||
|
/// "is the keyboard up" is a separate field rather than this one
|
||||||
|
/// compared against zero.
|
||||||
pub ime_bottom: f32,
|
pub ime_bottom: f32,
|
||||||
|
pub ime_visible: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WindowInsets {
|
impl WindowInsets {
|
||||||
@@ -206,6 +233,7 @@ impl WindowInsets {
|
|||||||
right: insets.right as f32,
|
right: insets.right as f32,
|
||||||
bottom: insets.bottom as f32,
|
bottom: insets.bottom as f32,
|
||||||
ime_bottom: insets.ime_bottom as f32,
|
ime_bottom: insets.ime_bottom as f32,
|
||||||
|
ime_visible: insets.ime_visible,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -284,6 +312,12 @@ pub struct IrisViewPeer<State: AndroidAppState> {
|
|||||||
pub(super) render: UiRenderState,
|
pub(super) render: UiRenderState,
|
||||||
pub(super) state: State,
|
pub(super) state: State,
|
||||||
task_recv: TaskMsgReceiver<AndroidRsc<State>>,
|
task_recv: TaskMsgReceiver<AndroidRsc<State>>,
|
||||||
|
/// `(an Instant, the input-event nanosecond stamp it was taken at)`,
|
||||||
|
/// captured from the first `MotionEvent` this view receives and never
|
||||||
|
/// changed after -- how `on_touch_event` dates every touch sample. Its
|
||||||
|
/// path out is the peer's own drop: it holds nothing but two numbers
|
||||||
|
/// and is meaningless to any other view.
|
||||||
|
input_clock: Option<(Instant, jlong)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<State: 'static, I: RscIdx<AndroidRsc<State>>> std::ops::Index<I> for AndroidRsc<State> {
|
impl<State: 'static, I: RscIdx<AndroidRsc<State>>> std::ops::Index<I> for AndroidRsc<State> {
|
||||||
@@ -307,12 +341,13 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Common tail for every callback that might have changed the cursor,
|
/// One pointer sample through the sensors, plus the platform calls a
|
||||||
/// the text focus, or the widget tree: run the sensors that touch
|
/// handler can only ask for by raising a flag. Split out of
|
||||||
/// input feeds, then ask for a frame if the result needs drawing.
|
/// [`Self::after_input`] because a batched `MotionEvent` carries
|
||||||
/// Mirrors `default::DefaultApp::window_event`'s tail, split across
|
/// several samples that all belong to the same *frame*
|
||||||
/// android-view's several entry points instead of winit's one.
|
/// (`on_touch_event`): each one is a real input frame the widgets must
|
||||||
pub(super) fn after_input(&mut self, ctx: &mut CallbackCtx) {
|
/// see, but only the last one ends the frame and asks for a redraw.
|
||||||
|
fn run_input_frame(&mut self, ctx: &mut CallbackCtx) {
|
||||||
let window_size = self.window_size();
|
let window_size = self.window_size();
|
||||||
let ui_state = self.state.android_state_mut();
|
let ui_state = self.state.android_state_mut();
|
||||||
let cursor = ui_state.cursor.clone();
|
let cursor = ui_state.cursor.clone();
|
||||||
@@ -332,6 +367,15 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
|||||||
if let Some(url) = ui_state.pending_open_url.take() {
|
if let Some(url) = ui_state.pending_open_url.take() {
|
||||||
super::platform::open_url(&mut ctx.env, &ctx.view, &url);
|
super::platform::open_url(&mut ctx.env, &ctx.view, &url);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Common tail for every callback that might have changed the cursor,
|
||||||
|
/// the text focus, or the widget tree: run the sensors that touch
|
||||||
|
/// input feeds, then ask for a frame if the result needs drawing.
|
||||||
|
/// Mirrors `default::DefaultApp::window_event`'s tail, split across
|
||||||
|
/// android-view's several entry points instead of winit's one.
|
||||||
|
pub(super) fn after_input(&mut self, ctx: &mut CallbackCtx) {
|
||||||
|
self.run_input_frame(ctx);
|
||||||
|
|
||||||
// RUST.md's P0 box, "doesn't enter it until I hit space, and also
|
// RUST.md's P0 box, "doesn't enter it until I hit space, and also
|
||||||
// doesn't move cursor forward": Gboard needs `updateSelection`
|
// doesn't move cursor forward": Gboard needs `updateSelection`
|
||||||
@@ -383,12 +427,14 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
|||||||
// is actually fed have to reach the log -- "the composer
|
// is actually fed have to reach the log -- "the composer
|
||||||
// floats at launch" is unanswerable from a screenshot alone.
|
// floats at launch" is unanswerable from a screenshot alone.
|
||||||
log::info!(
|
log::info!(
|
||||||
"iris insets: left={} top={} right={} bottom={} ime_bottom={} window={:?}",
|
"iris insets: left={} top={} right={} bottom={} ime_bottom={} \
|
||||||
|
ime_visible={} window={:?}",
|
||||||
physical.left,
|
physical.left,
|
||||||
physical.top,
|
physical.top,
|
||||||
physical.right,
|
physical.right,
|
||||||
physical.bottom,
|
physical.bottom,
|
||||||
physical.ime_bottom,
|
physical.ime_bottom,
|
||||||
|
physical.ime_visible,
|
||||||
self.window_size(),
|
self.window_size(),
|
||||||
);
|
);
|
||||||
self.state.android_state_mut().last_insets = current_insets;
|
self.state.android_state_mut().last_insets = current_insets;
|
||||||
@@ -414,6 +460,12 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
|||||||
// both count. See `iris_core::FrameReport`'s own doc for exactly
|
// both count. See `iris_core::FrameReport`'s own doc for exactly
|
||||||
// what this does and does not measure.
|
// what this does and does not measure.
|
||||||
let frame_start = Instant::now();
|
let frame_start = Instant::now();
|
||||||
|
// Anything moving on its own -- today a `List` coasting through a
|
||||||
|
// fling -- is advanced here, before the draw, and asks for the
|
||||||
|
// next frame at the end of this one. See
|
||||||
|
// `UiData::tick_animations`; `default/mod.rs`'s
|
||||||
|
// `RedrawRequested` arm is the same two lines for winit.
|
||||||
|
let animating = self.rsc.ui.tick_animations(frame_start);
|
||||||
let ui_state = self.state.android_state_mut();
|
let ui_state = self.state.android_state_mut();
|
||||||
self.render.update(&ui_state.root, &mut self.rsc);
|
self.render.update(&ui_state.root, &mut self.rsc);
|
||||||
let ui_state = self.state.android_state_mut();
|
let ui_state = self.state.android_state_mut();
|
||||||
@@ -446,6 +498,12 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
|||||||
.android_state_mut()
|
.android_state_mut()
|
||||||
.frame_report
|
.frame_report
|
||||||
.record_split(frame_start.elapsed(), submit_to_present);
|
.record_split(frame_start.elapsed(), submit_to_present);
|
||||||
|
// A frame callback is one-shot, so an animation that wants
|
||||||
|
// another frame has to say so every frame -- unlike `after_input`,
|
||||||
|
// which only has to ask when input dirtied something.
|
||||||
|
if animating {
|
||||||
|
ctx.view.post_frame_callback(&mut ctx.env);
|
||||||
|
}
|
||||||
let ui_state = self.state.android_state();
|
let ui_state = self.state.android_state();
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"render(): after update active={} root_px={:?}",
|
"render(): after update active={} root_px={:?}",
|
||||||
@@ -554,7 +612,67 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
|||||||
// -- see `AndroidUiState::content_scale`'s field comment.
|
// -- see `AndroidUiState::content_scale`'s field comment.
|
||||||
let x = event.x(&mut ctx.env);
|
let x = event.x(&mut ctx.env);
|
||||||
let y = event.y(&mut ctx.env);
|
let y = event.y(&mut ctx.env);
|
||||||
|
// The event's own clock, converted through one anchor taken on the
|
||||||
|
// first touch this view ever sees. Android reports sample times in
|
||||||
|
// the `SystemClock.uptimeMillis()` base, which is the same
|
||||||
|
// `CLOCK_MONOTONIC` an `Instant` reads, so a single
|
||||||
|
// `(Instant, nanos)` pair converts every later sample exactly.
|
||||||
|
// Anchoring **once** rather than per event is what keeps the times
|
||||||
|
// ordered: a fresh `Instant::now()` per event, minus each sample's
|
||||||
|
// age inside it, can date a later event's first historical sample
|
||||||
|
// before the previous event's last one whenever delivery jitters by
|
||||||
|
// more than the batch spans -- and `VelocityTracker::add_sample`'s
|
||||||
|
// debug assert would rightly fire on that. See `CursorState::time`.
|
||||||
|
let event_time = event.event_time_nanos(&mut ctx.env);
|
||||||
|
let (anchor_at, anchor_nanos) =
|
||||||
|
*self.input_clock.get_or_insert((Instant::now(), event_time));
|
||||||
|
let at = |sample_time: jlong| {
|
||||||
|
anchor_at + Duration::from_nanos(sample_time.saturating_sub(anchor_nanos).max(0) as u64)
|
||||||
|
};
|
||||||
|
|
||||||
|
// **Historical samples first.** A flick on a 120Hz screen is
|
||||||
|
// delivered as one or two `MotionEvent`s with the intermediate
|
||||||
|
// positions batched inside them, so reading only `x()`/`y()` threw
|
||||||
|
// away every sample but the last: the velocity tracker saw one
|
||||||
|
// `Pan` for the whole gesture, `VelocityTracker::velocity` answers
|
||||||
|
// 0.0 below two samples, and the release therefore flung at zero --
|
||||||
|
// Iris's phone, twice ("fling still doesn't work"), while a
|
||||||
|
// `ui-trace` swipe, which is many evenly-spaced events, flung fine.
|
||||||
|
// Replayed one at a time through the sensors rather than summarised,
|
||||||
|
// so the arbiter, the tracker and any other sensor all see the same
|
||||||
|
// motion the finger actually made; only the last sample ends the
|
||||||
|
// frame (`after_input`).
|
||||||
|
if matches!(action, MotionAction::Move) {
|
||||||
|
let history = event.history_size(&mut ctx.env);
|
||||||
|
// Android documents the historical samples as oldest first and
|
||||||
|
// the event's own sample as the newest of the batch; everything
|
||||||
|
// downstream (`VelocityTracker`, `DragArbiter`'s long-press
|
||||||
|
// clock) assumes it, so say so here rather than at each reader.
|
||||||
|
let mut previous = anchor_nanos;
|
||||||
|
for pos in 0..history {
|
||||||
|
let hx = event.historical_axis(&mut ctx.env, Axis::X, 0, pos);
|
||||||
|
let hy = event.historical_axis(&mut ctx.env, Axis::Y, 0, pos);
|
||||||
|
let ht = event.historical_event_time_nanos(&mut ctx.env, pos);
|
||||||
|
debug_assert!(
|
||||||
|
ht >= previous,
|
||||||
|
"historical sample {pos} of {history} is dated {ht}ns, before the {previous}ns \
|
||||||
|
sample ahead of it -- the input clock is not what this assumes"
|
||||||
|
);
|
||||||
|
previous = ht;
|
||||||
|
let ui_state = self.state.android_state_mut();
|
||||||
|
ui_state.cursor.pos = vec2(hx, hy);
|
||||||
|
ui_state.cursor.time = at(ht);
|
||||||
|
self.run_input_frame(ctx);
|
||||||
|
}
|
||||||
|
debug_assert!(
|
||||||
|
event_time >= previous,
|
||||||
|
"the event's own sample is dated {event_time}ns, before its last historical \
|
||||||
|
sample at {previous}ns"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let ui_state = self.state.android_state_mut();
|
let ui_state = self.state.android_state_mut();
|
||||||
|
ui_state.cursor.time = at(event_time);
|
||||||
match action {
|
match action {
|
||||||
MotionAction::Down => {
|
MotionAction::Down => {
|
||||||
ui_state.cursor.pos = vec2(x, y);
|
ui_state.cursor.pos = vec2(x, y);
|
||||||
@@ -564,6 +682,13 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
|||||||
MotionAction::Move => {
|
MotionAction::Move => {
|
||||||
ui_state.cursor.pos = vec2(x, y);
|
ui_state.cursor.pos = vec2(x, y);
|
||||||
}
|
}
|
||||||
|
// `Cancel` ends the gesture the same way `Up` does, and must:
|
||||||
|
// a release that never arrives leaves whichever widget took
|
||||||
|
// pointer capture holding it forever, with every later touch
|
||||||
|
// delivered to a drag nobody is performing. Confirmed present
|
||||||
|
// before this pass rather than assumed -- it was one of the
|
||||||
|
// three suspects listed for the phone's missing fling, and it
|
||||||
|
// is not the cause.
|
||||||
MotionAction::Up | MotionAction::Cancel => {
|
MotionAction::Up | MotionAction::Cancel => {
|
||||||
ui_state.cursor.pos = vec2(x, y);
|
ui_state.cursor.pos = vec2(x, y);
|
||||||
ui_state.cursor.buttons.left.update(false);
|
ui_state.cursor.buttons.left.update(false);
|
||||||
@@ -894,6 +1019,7 @@ pub fn new_peer<'local, State: AndroidAppState>(
|
|||||||
render,
|
render,
|
||||||
state,
|
state,
|
||||||
task_recv,
|
task_recv,
|
||||||
|
input_clock: None,
|
||||||
};
|
};
|
||||||
let id = android_view::register_view_peer(peer);
|
let id = android_view::register_view_peer(peer);
|
||||||
super::insets::register(id, shared);
|
super::insets::register(id, shared);
|
||||||
|
|||||||
+29
-4
@@ -18,9 +18,14 @@ pub trait FocusHost {
|
|||||||
/// side effect the way a real double-click timer does.
|
/// side effect the way a real double-click timer does.
|
||||||
fn recent_click(&mut self) -> bool;
|
fn recent_click(&mut self) -> bool;
|
||||||
fn set_focus(&mut self, id: Option<WeakWidget<TextEdit>>);
|
fn set_focus(&mut self, id: Option<WeakWidget<TextEdit>>);
|
||||||
/// Called after a `TextEdit` becomes the focus target, with the region
|
/// Called on every tap that should put the IME on `id`: the tap that
|
||||||
/// it was hit in (`None` when the widget could not be located, which
|
/// *makes* a `TextEdit` the focus target, and any later tap on one that
|
||||||
/// happens for one it was just deselected from).
|
/// already is. `region` is where it was hit (`None` when the widget
|
||||||
|
/// could not be located, which happens for one it was just deselected
|
||||||
|
/// from). Implementations must be idempotent -- both backends' calls
|
||||||
|
/// (`showSoftInput`, `set_ime_cursor_area`) already are, which is what
|
||||||
|
/// lets the repeat tap be handled by the same call rather than by a
|
||||||
|
/// second "re-show" entry point beside it.
|
||||||
fn focus_gained(&mut self, region: Option<PixelRegion>);
|
fn focus_gained(&mut self, region: Option<PixelRegion>);
|
||||||
/// Whether `id` is the current focus target -- what [`select`] uses to
|
/// Whether `id` is the current focus target -- what [`select`] uses to
|
||||||
/// tell a fresh press (which must wait to see whether it becomes a tap
|
/// tell a fresh press (which must wait to see whether it becomes a tap
|
||||||
@@ -155,10 +160,30 @@ fn on_press(
|
|||||||
ctx.text.press_origin = None;
|
ctx.text.press_origin = None;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if matches!(sense, CursorSense::PressEnd(_)) {
|
let ended = matches!(sense, CursorSense::PressEnd(_));
|
||||||
|
if ended {
|
||||||
ctx.text.press_origin = None;
|
ctx.text.press_origin = None;
|
||||||
}
|
}
|
||||||
ctx.select(pos, size, true, false);
|
ctx.select(pos, size, true, false);
|
||||||
|
// A tap on a field that is *already* focused asks for the
|
||||||
|
// keyboard again (Iris's phone, 2026-09-06: "I can't reopen
|
||||||
|
// keyboard by tapping on message box after it already
|
||||||
|
// happened once"). Dismissing the IME -- back gesture, or
|
||||||
|
// its own hide button -- takes the keyboard away but leaves
|
||||||
|
// the field focused, so without this the one branch that
|
||||||
|
// requests it (the unfocused one below) never runs again
|
||||||
|
// and the field is permanently unable to summon it.
|
||||||
|
// Android's own `EditText` does exactly this: every tap on
|
||||||
|
// a focused field calls `showSoftInput`, which is a no-op
|
||||||
|
// when the keyboard is already up.
|
||||||
|
//
|
||||||
|
// Gated on the same tap-vs-drag test the unfocused branch
|
||||||
|
// uses, not on `PressEnd` alone, so a drag-to-select that
|
||||||
|
// happens to finish inside the field does not summon a
|
||||||
|
// keyboard the reader was not asking for.
|
||||||
|
if ended && dx.abs() <= DRAG_SLOP && dy.abs() <= DRAG_SLOP {
|
||||||
|
state.focus_gained(render.window_region(&id, &*rsc));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+21
-17
@@ -1,4 +1,10 @@
|
|||||||
|
// `CursorState::time` is the sample's own time on every backend. winit
|
||||||
|
// carries no timestamp on a pointer event, so the moment it is handed to
|
||||||
|
// us is the closest measurement available here -- which is also what the
|
||||||
|
// drag code used to do for itself with `Instant::now()`, before Android's
|
||||||
|
// batched samples made the difference matter (see `sense::CursorState`).
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
|
use std::time::Instant;
|
||||||
use winit::{
|
use winit::{
|
||||||
event::{MouseButton, MouseScrollDelta, WindowEvent},
|
event::{MouseButton, MouseScrollDelta, WindowEvent},
|
||||||
keyboard::{Key, NamedKey},
|
keyboard::{Key, NamedKey},
|
||||||
@@ -11,18 +17,19 @@ 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();
|
||||||
}
|
}
|
||||||
WindowEvent::MouseInput { state, button, .. } => {
|
WindowEvent::MouseInput { state, button, .. } => {
|
||||||
|
self.cursor.time = Instant::now();
|
||||||
let buttons = &mut self.cursor.buttons;
|
let buttons = &mut self.cursor.buttons;
|
||||||
let pressed = state.is_pressed();
|
let pressed = state.is_pressed();
|
||||||
match button {
|
match button {
|
||||||
@@ -35,15 +42,14 @@ 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;
|
||||||
delta.y = 0.0;
|
delta.y = 0.0;
|
||||||
}
|
}
|
||||||
self.cursor.scroll_delta = delta;
|
self.cursor.scroll_delta = delta;
|
||||||
|
self.cursor.time = Instant::now();
|
||||||
}
|
}
|
||||||
WindowEvent::CursorLeft { .. } => {
|
WindowEvent::CursorLeft { .. } => {
|
||||||
self.cursor.exists = false;
|
self.cursor.exists = false;
|
||||||
@@ -74,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 {
|
||||||
|
|||||||
+54
-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() {
|
||||||
@@ -267,9 +306,21 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
|
|||||||
match &event {
|
match &event {
|
||||||
WindowEvent::CloseRequested => event_loop.exit(),
|
WindowEvent::CloseRequested => event_loop.exit(),
|
||||||
WindowEvent::RedrawRequested => {
|
WindowEvent::RedrawRequested => {
|
||||||
|
// Before the draw, so this frame shows this instant's
|
||||||
|
// position (`UiData::tick_animations`' own doc), and the
|
||||||
|
// window is asked for another frame while anything is
|
||||||
|
// still moving -- the winit half of what
|
||||||
|
// `IrisViewPeer::render`'s `post_frame_callback` does on
|
||||||
|
// Android. Nothing else in iris moves without an input
|
||||||
|
// event.
|
||||||
|
let animating = rsc.ui_mut().tick_animations(std::time::Instant::now());
|
||||||
|
let ui_state = state.default_state_mut();
|
||||||
render.update(&ui_state.root, rsc);
|
render.update(&ui_state.root, rsc);
|
||||||
ui_state.renderer.update(&mut rsc.ui, render);
|
ui_state.renderer.update(&mut rsc.ui, render);
|
||||||
ui_state.renderer.draw();
|
ui_state.renderer.draw();
|
||||||
|
if animating {
|
||||||
|
ui_state.window.request_redraw();
|
||||||
|
}
|
||||||
// I4 (RUST.md): only produces a `TreeUpdate` when the named
|
// I4 (RUST.md): only produces a `TreeUpdate` when the named
|
||||||
// set actually changed this frame -- see `AccessTree`'s doc
|
// set actually changed this frame -- see `AccessTree`'s doc
|
||||||
// comment. `render` reflects the draw that just happened,
|
// comment. `render` reflects the draw that just happened,
|
||||||
|
|||||||
+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;
|
||||||
|
|||||||
+454
-77
@@ -95,12 +95,39 @@ impl CursorSense {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Clone)]
|
#[derive(Clone)]
|
||||||
pub struct CursorState {
|
pub struct CursorState {
|
||||||
pub pos: Vec2,
|
pub pos: Vec2,
|
||||||
pub exists: bool,
|
pub exists: bool,
|
||||||
pub buttons: CursorButtons,
|
pub buttons: CursorButtons,
|
||||||
pub scroll_delta: Vec2,
|
pub scroll_delta: Vec2,
|
||||||
|
/// When this pointer state was *sampled*, from the platform's own
|
||||||
|
/// input clock -- not when the handler reading it happened to run.
|
||||||
|
///
|
||||||
|
/// It exists because Android batches touch samples: a flick on a
|
||||||
|
/// 120Hz screen arrives as one or two `MotionEvent`s carrying the
|
||||||
|
/// intermediate positions as *historical* samples
|
||||||
|
/// (`getHistoricalX`/`getHistoricalEventTime`), which
|
||||||
|
/// `IrisViewPeer::on_touch_event` replays through the sensor pass one
|
||||||
|
/// at a time. Every one of those replays happens within the same few
|
||||||
|
/// microseconds, so a gesture timing itself with `Instant::now()`
|
||||||
|
/// would see a span of nearly zero across the whole flick and divide
|
||||||
|
/// by it -- the velocity would be an artefact of how fast we looped,
|
||||||
|
/// which is exactly the inferred-as-measured number UI_RULES.md
|
||||||
|
/// forbids. Carrying the sample's own time makes the span real.
|
||||||
|
pub time: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CursorState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
pos: Vec2::ZERO,
|
||||||
|
exists: false,
|
||||||
|
buttons: CursorButtons::default(),
|
||||||
|
scroll_delta: Vec2::ZERO,
|
||||||
|
time: Instant::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Clone)]
|
#[derive(Default, Clone)]
|
||||||
@@ -732,6 +759,18 @@ impl DragGesture {
|
|||||||
match sense {
|
match sense {
|
||||||
CursorSense::PressStart(_) => {
|
CursorSense::PressStart(_) => {
|
||||||
self.velocity.reset();
|
self.velocity.reset();
|
||||||
|
// The press itself is a sample: nothing has moved yet, but
|
||||||
|
// *when* the finger went down is real and measured, and
|
||||||
|
// without it a gesture whose whole motion arrives in one
|
||||||
|
// frame has a single sample and therefore no time span to
|
||||||
|
// divide by -- `velocity` answers 0.0 and the release does
|
||||||
|
// not fling. Batched touch delivery makes that shape
|
||||||
|
// ordinary rather than rare (see `CursorState::time`), and
|
||||||
|
// `VELOCITY_WINDOW` trims this entry back out the moment
|
||||||
|
// the gesture is long enough not to need it, so a slow
|
||||||
|
// drag's velocity is still its recent motion and not its
|
||||||
|
// whole history.
|
||||||
|
self.velocity.add_sample(0.0, now);
|
||||||
self.arbiter.press_start(pos_window, now, already_selected);
|
self.arbiter.press_start(pos_window, now, already_selected);
|
||||||
self.dispatch(render, id, pos_window, now)
|
self.dispatch(render, id, pos_window, now)
|
||||||
}
|
}
|
||||||
@@ -743,6 +782,20 @@ impl DragGesture {
|
|||||||
} else {
|
} else {
|
||||||
GestureOutcome::Released(None)
|
GestureOutcome::Released(None)
|
||||||
};
|
};
|
||||||
|
// The one line that settles "why did that flick not fling"
|
||||||
|
// from a logcat, which is the only instrument available on
|
||||||
|
// Iris's phone (this-machine-android: system tracing does
|
||||||
|
// not work there). Every input to the decision is here, so
|
||||||
|
// a zero velocity can be told apart from a gesture that
|
||||||
|
// never reached `Panning` at all -- the two look identical
|
||||||
|
// on screen and had to be guessed between twice.
|
||||||
|
log::info!(
|
||||||
|
"iris drag release: samples={} span={:.1}ms v={:.0} outcome={:?}",
|
||||||
|
self.velocity.sample_count(),
|
||||||
|
self.velocity.span().as_secs_f32() * 1000.0,
|
||||||
|
self.velocity.velocity(),
|
||||||
|
outcome,
|
||||||
|
);
|
||||||
self.arbiter.release();
|
self.arbiter.release();
|
||||||
render.release_pointer();
|
render.release_pointer();
|
||||||
outcome
|
outcome
|
||||||
@@ -752,6 +805,7 @@ impl DragGesture {
|
|||||||
// landed outside whichever hit region first noticed it.
|
// landed outside whichever hit region first noticed it.
|
||||||
_ if self.arbiter.is_idle() => {
|
_ if self.arbiter.is_idle() => {
|
||||||
self.velocity.reset();
|
self.velocity.reset();
|
||||||
|
self.velocity.add_sample(0.0, now);
|
||||||
self.arbiter.press_start(pos_window, now, already_selected);
|
self.arbiter.press_start(pos_window, now, already_selected);
|
||||||
self.dispatch(render, id, pos_window, now)
|
self.dispatch(render, id, pos_window, now)
|
||||||
}
|
}
|
||||||
@@ -835,6 +889,22 @@ impl VelocityTracker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many samples are currently inside the window, and how long they
|
||||||
|
/// span. Reported beside the velocity in `DragGesture`'s release log,
|
||||||
|
/// because a `v=0` on its own cannot say whether the gesture was slow
|
||||||
|
/// or whether the tracker was simply never fed -- which is exactly the
|
||||||
|
/// distinction the phone's missing fling turned on.
|
||||||
|
pub fn sample_count(&self) -> usize {
|
||||||
|
self.samples.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn span(&self) -> Duration {
|
||||||
|
match (self.samples.front(), self.samples.back()) {
|
||||||
|
(Some(&(first, _)), Some(&(last, _))) => last.duration_since(first),
|
||||||
|
_ => Duration::ZERO,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The estimated speed, in units-per-second, over whatever samples
|
/// The estimated speed, in units-per-second, over whatever samples
|
||||||
/// currently fall inside the tracking window: total motion divided by
|
/// currently fall inside the tracking window: total motion divided by
|
||||||
/// the elapsed time between the oldest and newest sample still held.
|
/// the elapsed time between the oldest and newest sample still held.
|
||||||
@@ -844,34 +914,45 @@ impl VelocityTracker {
|
|||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
let total: f32 = self.samples.iter().map(|&(_, d)| d).sum();
|
let total: f32 = self.samples.iter().map(|&(_, d)| d).sum();
|
||||||
let span = self
|
let span = self.span().as_secs_f32();
|
||||||
.samples
|
|
||||||
.back()
|
|
||||||
.unwrap()
|
|
||||||
.0
|
|
||||||
.duration_since(self.samples.front().unwrap().0)
|
|
||||||
.as_secs_f32();
|
|
||||||
if span <= 0.0 { 0.0 } else { total / span }
|
if span <= 0.0 { 0.0 } else { total / span }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Android's fling deceleration curve, ported from AOSP's
|
/// Android's fling deceleration curve, ported from AOSP's
|
||||||
/// `android.widget.OverScroller.SplineOverScroller` (the same curve
|
/// `android.widget.OverScroller.SplineOverScroller` (the same curve
|
||||||
/// Compose's `androidx.compose.ui.gestures.AndroidFlingSpline` and
|
/// Compose's `androidx.compose.animation.AndroidFlingSpline` and
|
||||||
/// `androidx.compose.foundation.gestures.FlingCalculator` reuse) so a
|
/// `androidx.compose.animation.FlingCalculator` reuse) so a fling here
|
||||||
/// fling here travels the same distance a Compose `LazyColumn`'s own
|
/// travels the same distance a Compose `LazyColumn`'s own
|
||||||
/// `ScrollableDefaults.flingBehavior()` would for the same initial
|
/// `ScrollableDefaults.flingBehavior()` would for the same initial
|
||||||
/// velocity -- RUST.md's "Benchmark v2" box asked the two apps' fling
|
/// velocity -- RUST.md's "Benchmark v2" box asked the two apps' fling
|
||||||
/// phase to be comparable, and IRIS_TODO.md's "swiping has no momentum"
|
/// phase to be comparable, and IRIS_TODO.md's "swiping has no momentum"
|
||||||
/// asked for the same physics a reader's muscle memory already expects
|
/// asked for the same physics a reader's muscle memory already expects
|
||||||
/// from every other Android scroll view.
|
/// from every other Android scroll view. Both sources were read at
|
||||||
|
/// `frameworks/base`'s `core/java/android/widget/OverScroller.java` and
|
||||||
|
/// `androidx.compose.animation:animation:1.12.0`'s `SplineBasedDecay.kt`
|
||||||
|
/// (2026-09-07); they agree line for line.
|
||||||
///
|
///
|
||||||
/// The curve is a cubic-Bezier-derived spline sampled into two lookup
|
/// **One table, indexed by even steps of *time*.** `SPLINE_POSITION[i]`
|
||||||
/// tables at start-up (`SPLINE`, built once via [`std::sync::OnceLock`]):
|
/// is the fraction of the total distance covered at time fraction
|
||||||
/// `SPLINE_POSITION[i]`/`SPLINE_TIME[i]` give the fraction of total
|
/// `i / NB_SAMPLES`, so a lookup brackets `t` between `index / N` and
|
||||||
/// distance/time elapsed at the `i`th of 100 even steps along the curve's
|
/// `(index + 1) / N` -- never between table entries. AOSP builds a second
|
||||||
/// own parameter. A lookup at an arbitrary time fraction interpolates
|
/// table, `SPLINE_TIME`, purely for `adjustDuration` (re-timing a fling
|
||||||
/// between the two bracketing samples.
|
/// whose target moved), which nothing here has; it is deliberately not
|
||||||
|
/// built, so there is one array and one indexing rule rather than two of
|
||||||
|
/// each to pick the wrong one from.
|
||||||
|
///
|
||||||
|
/// The wrong one was picked, and this is what it cost. Until 2026-09-07
|
||||||
|
/// the two halves of AOSP's build loop were transposed -- the bisection
|
||||||
|
/// solved the tension curve and the sample evaluated the `P1`/`P2` one,
|
||||||
|
/// where AOSP does the opposite -- which made this table and `SPLINE_TIME`
|
||||||
|
/// *identical*, and the old lookup, which bracketed `t` between
|
||||||
|
/// `SPLINE_TIME` entries, then returned exactly `t` for every `t`. A fling
|
||||||
|
/// coasted at constant speed for its whole duration and stopped dead:
|
||||||
|
/// Iris's phone report of 2026-09-07, "just linear velocity with an abrupt
|
||||||
|
/// stop", verbatim out of the arithmetic. Every test it had compared the
|
||||||
|
/// curve with itself, so none of them could see it;
|
||||||
|
/// `the_spline_matches_aosps_own_table` pins the absolute numbers now.
|
||||||
mod android_fling_spline {
|
mod android_fling_spline {
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
@@ -884,24 +965,30 @@ mod android_fling_spline {
|
|||||||
const P1: f32 = START_TENSION * INFLEXION;
|
const P1: f32 = START_TENSION * INFLEXION;
|
||||||
const P2: f32 = 1.0 - END_TENSION * (1.0 - INFLEXION);
|
const P2: f32 = 1.0 - END_TENSION * (1.0 - INFLEXION);
|
||||||
|
|
||||||
pub(super) struct Spline {
|
/// What a lookup answers: how far along the fling is, and how fast it
|
||||||
position: [f32; NB_SAMPLES + 1],
|
/// is going there -- AOSP's `distanceCoef`/`velocityCoef` and Compose's
|
||||||
time: [f32; NB_SAMPLES + 1],
|
/// `AndroidFlingSpline.FlingResult`. Both are fractions of the fling's
|
||||||
|
/// *total* distance, the second per unit of its *total* duration, so a
|
||||||
|
/// caller scales them by `distance` and `distance / duration`.
|
||||||
|
pub(super) struct SplineSample {
|
||||||
|
pub(super) distance_fraction: f32,
|
||||||
|
pub(super) velocity_fraction: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build() -> Spline {
|
fn build() -> [f32; NB_SAMPLES + 1] {
|
||||||
let mut position = [0.0f32; NB_SAMPLES + 1];
|
let mut position = [0.0f32; NB_SAMPLES + 1];
|
||||||
let mut time = [0.0f32; NB_SAMPLES + 1];
|
let mut x_min = 0.0f32;
|
||||||
let (mut x_min, mut y_min) = (0.0f32, 0.0f32);
|
for (i, slot) in position.iter_mut().enumerate().take(NB_SAMPLES) {
|
||||||
for i in 0..NB_SAMPLES {
|
|
||||||
let alpha = i as f32 / NB_SAMPLES as f32;
|
let alpha = i as f32 / NB_SAMPLES as f32;
|
||||||
|
|
||||||
let mut x_max = 1.0f32;
|
let mut x_max = 1.0f32;
|
||||||
let (mut x, mut coef);
|
let (mut x, mut coef);
|
||||||
loop {
|
loop {
|
||||||
x = x_min + (x_max - x_min) / 2.0;
|
x = x_min + (x_max - x_min) / 2.0;
|
||||||
coef = 3.0 * x * (1.0 - x);
|
coef = 3.0 * x * (1.0 - x);
|
||||||
let tx = coef * ((1.0 - x) * START_TENSION + x * END_TENSION) + x * x * x;
|
// Solved on the `P1`/`P2` curve and sampled on the tension
|
||||||
|
// one. Transposing these two is the defect this module's
|
||||||
|
// doc comment describes; they are not interchangeable.
|
||||||
|
let tx = coef * ((1.0 - x) * P1 + x * P2) + x * x * x;
|
||||||
if (tx - alpha).abs() < 1e-5 {
|
if (tx - alpha).abs() < 1e-5 {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -911,50 +998,37 @@ mod android_fling_spline {
|
|||||||
x_min = x;
|
x_min = x;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
position[i] = coef * ((1.0 - x) * P1 + x * P2) + x * x * x;
|
*slot = coef * ((1.0 - x) * START_TENSION + x * END_TENSION) + x * x * x;
|
||||||
|
|
||||||
let mut y_max = 1.0f32;
|
|
||||||
let (mut y, mut coef_y);
|
|
||||||
loop {
|
|
||||||
y = y_min + (y_max - y_min) / 2.0;
|
|
||||||
coef_y = 3.0 * y * (1.0 - y);
|
|
||||||
let dy = coef_y * ((1.0 - y) * START_TENSION + y * END_TENSION) + y * y * y;
|
|
||||||
if (dy - alpha).abs() < 1e-5 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if dy > alpha {
|
|
||||||
y_max = y;
|
|
||||||
} else {
|
|
||||||
y_min = y;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
time[i] = coef_y * ((1.0 - y) * P1 + y * P2) + y * y * y;
|
|
||||||
}
|
}
|
||||||
position[NB_SAMPLES] = 1.0;
|
position[NB_SAMPLES] = 1.0;
|
||||||
time[NB_SAMPLES] = 1.0;
|
position
|
||||||
Spline { position, time }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static SPLINE: OnceLock<Spline> = OnceLock::new();
|
static SPLINE_POSITION: OnceLock<[f32; NB_SAMPLES + 1]> = OnceLock::new();
|
||||||
|
|
||||||
/// The fraction of total distance covered at `time_fraction` (0..=1
|
/// Sample the curve at `time_fraction` (0..=1 of the fling's total
|
||||||
/// of the fling's total duration). Finds the bracketing samples in
|
/// duration), exactly as AOSP's `SplineOverScroller.update` and
|
||||||
/// `SPLINE_TIME` and interpolates linearly between their matching
|
/// Compose's `AndroidFlingSpline.flingPosition` do.
|
||||||
/// `SPLINE_POSITION` entries, exactly as AOSP's `SplineOverScroller
|
pub(super) fn sample(time_fraction: f32) -> SplineSample {
|
||||||
/// .flingPosition` does.
|
let position = SPLINE_POSITION.get_or_init(build);
|
||||||
pub(super) fn distance_fraction(time_fraction: f32) -> f32 {
|
|
||||||
let spline = SPLINE.get_or_init(build);
|
|
||||||
let t = time_fraction.clamp(0.0, 1.0);
|
let t = time_fraction.clamp(0.0, 1.0);
|
||||||
let index = ((t * NB_SAMPLES as f32) as usize).min(NB_SAMPLES - 1);
|
let index = (t * NB_SAMPLES as f32) as usize;
|
||||||
let t_inf = spline.time[index];
|
if index >= NB_SAMPLES {
|
||||||
let t_sup = spline.time[index + 1];
|
// The end of the fling: all of the distance covered and
|
||||||
let d_inf = spline.position[index];
|
// nothing left moving. AOSP's `distanceCoef = 1f` /
|
||||||
let d_sup = spline.position[index + 1];
|
// `velocityCoef = 0f` defaults, which its
|
||||||
let span = t_sup - t_inf;
|
// `if (index < NB_SAMPLES)` leaves in place.
|
||||||
if span <= 0.0 {
|
return SplineSample {
|
||||||
d_inf
|
distance_fraction: 1.0,
|
||||||
} else {
|
velocity_fraction: 0.0,
|
||||||
d_inf + (d_sup - d_inf) * (t - t_inf) / span
|
};
|
||||||
|
}
|
||||||
|
let t_inf = index as f32 / NB_SAMPLES as f32;
|
||||||
|
let t_sup = (index + 1) as f32 / NB_SAMPLES as f32;
|
||||||
|
let velocity_fraction = (position[index + 1] - position[index]) / (t_sup - t_inf);
|
||||||
|
SplineSample {
|
||||||
|
distance_fraction: position[index] + (t - t_inf) * velocity_fraction,
|
||||||
|
velocity_fraction,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -964,6 +1038,17 @@ mod android_fling_spline {
|
|||||||
/// friction of `0.84` per frame at 60Hz corresponds to
|
/// friction of `0.84` per frame at 60Hz corresponds to
|
||||||
/// (`ln(0.78)/ln(0.9)`, `SplineOverScroller.DECELERATION_RATE`).
|
/// (`ln(0.78)/ln(0.9)`, `SplineOverScroller.DECELERATION_RATE`).
|
||||||
const FLING_FRICTION: f32 = 0.015;
|
const FLING_FRICTION: f32 = 0.015;
|
||||||
|
/// AOSP's own look-and-feel tuning constant, the argument
|
||||||
|
/// `SplineOverScroller`'s constructor passes to `computeDeceleration` when
|
||||||
|
/// it builds `mPhysicalCoeff` -- *not* the scroll friction, which is a
|
||||||
|
/// different number used a different place in the same formula. This was
|
||||||
|
/// `FLING_FRICTION` here until 2026-09-07, making the coefficient 56x too
|
||||||
|
/// small, which put an `ln` of a 56x-too-large ratio through
|
||||||
|
/// `exp(_/(rate-1))`: an ordinary flick came out lasting **30 seconds**
|
||||||
|
/// instead of 1.6. Nothing could see it while a finger fling never
|
||||||
|
/// animated at all (`List::fling`'s doc), which is why two defects had to
|
||||||
|
/// be fixed before either was visible.
|
||||||
|
const FLING_TUNING: f32 = 0.84;
|
||||||
fn deceleration_rate() -> f32 {
|
fn deceleration_rate() -> f32 {
|
||||||
(0.78f32.ln()) / (0.9f32.ln())
|
(0.78f32.ln()) / (0.9f32.ln())
|
||||||
}
|
}
|
||||||
@@ -975,11 +1060,15 @@ const GRAVITY_EARTH: f32 = 9.80665;
|
|||||||
/// ported the same way Compose's `FlingCalculator` is, including its
|
/// ported the same way Compose's `FlingCalculator` is, including its
|
||||||
/// `density`-dependent physical coefficient (`computeDeceleration`,
|
/// `density`-dependent physical coefficient (`computeDeceleration`,
|
||||||
/// `GravityEarth * 39.37 * density * 160 * friction`). Density and
|
/// `GravityEarth * 39.37 * density * 160 * friction`). Density and
|
||||||
/// velocity/distance units cancel algebraically as long as velocity and
|
/// `density` is physical pixels per `dp`, and the velocity handed in has
|
||||||
/// the returned distance share one pixel space (physical or logical) --
|
/// to be in those same physical pixels -- which is what a touch event
|
||||||
/// [`crate::widget::List::fling`] relies on exactly that cancellation to
|
/// carries. It does **not** cancel out: `duration` is
|
||||||
/// avoid needing a display density of its own, since iris's `List`
|
/// `exp(ln(k*v/C) / (rate-1))` with `C` proportional to density, so the
|
||||||
/// already works in logical (density-independent) pixels throughout.
|
/// wrong density changes how long a fling lasts exponentially rather than
|
||||||
|
/// scaling it. An earlier version of this comment claimed the opposite and
|
||||||
|
/// `List::fling` passed `1.0`; on a 2.75-density screen that gave a
|
||||||
|
/// one-second flick a 45-second coast (measured 2026-09-07). `List` reads
|
||||||
|
/// its density from the painter now.
|
||||||
pub struct FlingCalculator {
|
pub struct FlingCalculator {
|
||||||
physical_coefficient: f32,
|
physical_coefficient: f32,
|
||||||
}
|
}
|
||||||
@@ -987,7 +1076,7 @@ pub struct FlingCalculator {
|
|||||||
impl FlingCalculator {
|
impl FlingCalculator {
|
||||||
pub fn new(density: f32) -> Self {
|
pub fn new(density: f32) -> Self {
|
||||||
Self {
|
Self {
|
||||||
physical_coefficient: GRAVITY_EARTH * 39.37 * density * 160.0 * FLING_FRICTION,
|
physical_coefficient: GRAVITY_EARTH * 39.37 * density * 160.0 * FLING_TUNING,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1027,17 +1116,35 @@ impl FlingCalculator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The signed distance covered by `elapsed` into a fling of this
|
/// The signed distance covered by `elapsed` into a fling of this
|
||||||
/// `velocity` that started at `t0` -- what a per-frame ticker
|
/// `velocity` -- what a per-frame ticker (`List::tick_fling`) calls to
|
||||||
/// (`List::tick_fling`) calls to find how far to have scrolled by now.
|
/// find how far to have scrolled by now. Clamped to the full
|
||||||
/// Clamped to the full `distance()` once `elapsed` reaches
|
/// `distance()` once `elapsed` reaches `duration()`, so a caller need
|
||||||
/// `duration()`, so a caller need not special-case "past the end."
|
/// not special-case "past the end."
|
||||||
pub fn position_at(&self, velocity: f32, elapsed: Duration) -> f32 {
|
pub fn position_at(&self, velocity: f32, elapsed: Duration) -> f32 {
|
||||||
let duration = self.duration(velocity);
|
let duration = self.duration(velocity);
|
||||||
if duration.is_zero() {
|
if duration.is_zero() {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
let fraction = (elapsed.as_secs_f32() / duration.as_secs_f32()).min(1.0);
|
let fraction = elapsed.as_secs_f32() / duration.as_secs_f32();
|
||||||
self.distance(velocity) * android_fling_spline::distance_fraction(fraction)
|
self.distance(velocity) * android_fling_spline::sample(fraction).distance_fraction
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The signed *speed* at `elapsed` into the same fling, in the units
|
||||||
|
/// `velocity` was given in -- AOSP's `mCurrVelocity` and Compose's
|
||||||
|
/// `FlingInfo.velocity`. It falls from roughly `velocity` at the start
|
||||||
|
/// to zero at `duration()`, which is the whole difference between a
|
||||||
|
/// fling and a constant-speed slide, so it is what
|
||||||
|
/// `List::tick_fling`'s debug line reports: successive frames printing
|
||||||
|
/// a shrinking number is the evidence that the curve is being followed
|
||||||
|
/// at all.
|
||||||
|
pub fn velocity_at(&self, velocity: f32, elapsed: Duration) -> f32 {
|
||||||
|
let duration = self.duration(velocity);
|
||||||
|
if duration.is_zero() {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let fraction = elapsed.as_secs_f32() / duration.as_secs_f32();
|
||||||
|
android_fling_spline::sample(fraction).velocity_fraction * self.distance(velocity)
|
||||||
|
/ duration.as_secs_f32()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1147,6 +1254,39 @@ mod fling_calculator_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The absolute numbers, against AOSP's own formula worked by hand --
|
||||||
|
/// the one thing every other test here cannot see, because they all
|
||||||
|
/// compare this calculator with itself (monotonic, signed, integrates
|
||||||
|
/// to the closed form) and so pass just as happily with a coefficient
|
||||||
|
/// 56x out. That is exactly the state this file was in: an ordinary
|
||||||
|
/// flick lasted 30 seconds on the emulator and every test was green.
|
||||||
|
///
|
||||||
|
/// `SplineOverScroller` at ppi = 2.75*160 = 440:
|
||||||
|
/// `mPhysicalCoeff = 9.80665 * 39.37 * 440 * 0.84 = 142,698`;
|
||||||
|
/// `l = ln(0.35 * v / (0.015 * mPhysicalCoeff))`;
|
||||||
|
/// `duration = exp(l / (DECELERATION_RATE - 1))`.
|
||||||
|
/// For v = 3000 px/s that is 0.592s and 621px; for 11444 px/s,
|
||||||
|
/// 1.586s.
|
||||||
|
#[test]
|
||||||
|
fn a_flick_lasts_what_aosps_own_formula_says_it_does() {
|
||||||
|
let calc = FlingCalculator::new(2.75);
|
||||||
|
let slow = calc.duration(3000.0).as_secs_f32();
|
||||||
|
assert!(
|
||||||
|
(slow - 0.592).abs() < 0.02,
|
||||||
|
"3000px/s at density 2.75 should settle in ~0.59s, got {slow}s"
|
||||||
|
);
|
||||||
|
let distance = calc.distance(3000.0);
|
||||||
|
assert!(
|
||||||
|
(distance - 621.5).abs() < 5.0,
|
||||||
|
"3000px/s at density 2.75 should travel ~621px, got {distance}"
|
||||||
|
);
|
||||||
|
let fast = calc.duration(11444.0).as_secs_f32();
|
||||||
|
assert!(
|
||||||
|
(fast - 1.586).abs() < 0.05,
|
||||||
|
"11444px/s at density 2.75 should settle in ~1.59s, got {fast}s"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn position_at_is_monotonic_and_clamped_past_the_end() {
|
fn position_at_is_monotonic_and_clamped_past_the_end() {
|
||||||
let calc = FlingCalculator::new(1.0);
|
let calc = FlingCalculator::new(1.0);
|
||||||
@@ -1169,6 +1309,93 @@ mod fling_calculator_tests {
|
|||||||
total
|
total
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The table itself, against AOSP's own entries. Every number here
|
||||||
|
/// came out of `benches/fling_spline_reference.py`, which is a
|
||||||
|
/// separate hand transcription of `OverScroller.java` and
|
||||||
|
/// `SplineBasedDecay.kt` -- so this is the one test in the file that
|
||||||
|
/// is not the Rust code grading its own homework, and the only kind
|
||||||
|
/// that could have caught the transposed build loop
|
||||||
|
/// `android_fling_spline`'s doc describes.
|
||||||
|
///
|
||||||
|
/// The property that names the old defect directly: the curve is
|
||||||
|
/// **not** the identity. At a tenth of the way through its time a
|
||||||
|
/// fling has covered 27.4% of its distance, and at half its time
|
||||||
|
/// 85.8%. The old table returned 0.100 and 0.500 -- a constant-speed
|
||||||
|
/// slide -- so the two `assert!`s below fail by a factor of three.
|
||||||
|
#[test]
|
||||||
|
fn the_spline_matches_aosps_own_table() {
|
||||||
|
for (t, expected) in [
|
||||||
|
(0.0f32, 0.000023f32),
|
||||||
|
(0.1, 0.274002),
|
||||||
|
(0.25, 0.583811),
|
||||||
|
(0.5, 0.858411),
|
||||||
|
(0.75, 0.971068),
|
||||||
|
(0.9, 0.995811),
|
||||||
|
(1.0, 1.0),
|
||||||
|
] {
|
||||||
|
let got = android_fling_spline::sample(t).distance_fraction;
|
||||||
|
assert!(
|
||||||
|
(got - expected).abs() < 1e-4,
|
||||||
|
"distance fraction at t={t}: got {got}, AOSP says {expected}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Speed falls monotonically to nothing -- the difference between
|
||||||
|
// a fling and a slide, and what the abrupt stop was.
|
||||||
|
let mut last = f32::INFINITY;
|
||||||
|
for step in 0..=100 {
|
||||||
|
let v = android_fling_spline::sample(step as f32 / 100.0).velocity_fraction;
|
||||||
|
assert!(v <= last + 1e-4, "speed rose at t={step}/100: {v} > {last}");
|
||||||
|
last = v;
|
||||||
|
}
|
||||||
|
assert_eq!(android_fling_spline::sample(1.0).velocity_fraction, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same curve carried through `distance`/`duration` at Iris's own
|
||||||
|
/// phone density (2.55, `docs/bench/iris-phone-v2-2026-09-06.md`),
|
||||||
|
/// again with every number from `benches/fling_spline_reference.py`.
|
||||||
|
/// A fling's *speed* a third of the way through is 4733px/s out of an
|
||||||
|
/// initial 11064 -- what a reader sees as deceleration, and the
|
||||||
|
/// quantity that was constant before this.
|
||||||
|
///
|
||||||
|
/// The sample fractions are deliberately not round: the velocity
|
||||||
|
/// coefficient is piecewise constant across each of the 100 samples,
|
||||||
|
/// so `0.75` sits exactly on a step and the assertion would be about
|
||||||
|
/// which side of it the last float landed rather than about the curve.
|
||||||
|
#[test]
|
||||||
|
fn a_flick_decelerates_the_way_aosp_says_it_does() {
|
||||||
|
let calc = FlingCalculator::new(2.55);
|
||||||
|
let velocity = 11064.0f32;
|
||||||
|
let duration = calc.duration(velocity);
|
||||||
|
assert!(
|
||||||
|
(duration.as_secs_f32() - 1.6357).abs() < 0.01,
|
||||||
|
"duration {duration:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(calc.distance(velocity) - 6334.2).abs() < 5.0,
|
||||||
|
"distance {}",
|
||||||
|
calc.distance(velocity)
|
||||||
|
);
|
||||||
|
for (fraction, position, speed) in [
|
||||||
|
(0.125f32, 2123.3f32, 9202.1f32),
|
||||||
|
(0.335, 4458.3, 4733.0),
|
||||||
|
(0.505, 5459.0, 2649.6),
|
||||||
|
(0.755, 6158.7, 950.9),
|
||||||
|
] {
|
||||||
|
let at = duration.mul_f32(fraction);
|
||||||
|
let got_position = calc.position_at(velocity, at);
|
||||||
|
let got_speed = calc.velocity_at(velocity, at);
|
||||||
|
assert!(
|
||||||
|
(got_position - position).abs() < 5.0,
|
||||||
|
"position at {fraction} of the fling: got {got_position}, AOSP says {position}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(got_speed - speed).abs() < 20.0,
|
||||||
|
"speed at {fraction} of the fling: got {got_speed}, AOSP says {speed}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(calc.velocity_at(velocity, duration), 0.0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -1410,3 +1637,153 @@ mod drag_arbiter_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`DragGesture`] end to end, at the shape Android actually delivers a
|
||||||
|
/// flick in. The arbiter and the tracker each behave correctly on their
|
||||||
|
/// own (the two modules above); what these cover is the join between them
|
||||||
|
/// at release, which is where the phone's missing fling lived.
|
||||||
|
#[cfg(test)]
|
||||||
|
mod drag_gesture_tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
static BASE: LazyLock<Instant> = LazyLock::new(Instant::now);
|
||||||
|
|
||||||
|
fn t(ms: u64) -> Instant {
|
||||||
|
*BASE + Duration::from_millis(ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `UiRenderState` with nothing in it. `DragGesture` only ever calls
|
||||||
|
/// `capture_pointer`/`release_pointer` on it, which are bookkeeping on
|
||||||
|
/// a `Cell` and need no widget tree behind them.
|
||||||
|
fn render() -> UiRenderState {
|
||||||
|
UiRenderState::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The id `capture_pointer` records. Any id will do -- nothing here
|
||||||
|
/// resolves it -- so it comes from a real (empty) widget registry
|
||||||
|
/// rather than being fabricated.
|
||||||
|
fn some_id(ui: &mut UiData) -> WidgetId {
|
||||||
|
ui.widgets.add_strong(Rect::new(UiColor::WHITE)).id()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The phone's shape.** A 120Hz flick reaches the app as very few
|
||||||
|
/// `MotionEvent`s, so before `on_touch_event` replayed the historical
|
||||||
|
/// samples inside them a whole gesture could be press, one move past
|
||||||
|
/// the slop, release. That released at `v=0` -- `velocity()` needs two
|
||||||
|
/// samples and the single `Pan` frame was the only one -- so the list
|
||||||
|
/// stopped dead under the finger while the same gesture driven as many
|
||||||
|
/// evenly-spaced `ui-trace` events flung perfectly. The press is a
|
||||||
|
/// sample now, so even this minimum still carries a real speed.
|
||||||
|
#[test]
|
||||||
|
fn a_flick_delivered_as_one_move_frame_still_releases_with_a_velocity() {
|
||||||
|
let mut ui = UiData::default();
|
||||||
|
let id = some_id(&mut ui);
|
||||||
|
let r = render();
|
||||||
|
let mut g = DragGesture::new();
|
||||||
|
|
||||||
|
g.handle(
|
||||||
|
&r,
|
||||||
|
id,
|
||||||
|
CursorSense::PressStart(CursorButton::Left),
|
||||||
|
Vec2::ZERO,
|
||||||
|
t(0),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
g.handle(
|
||||||
|
&r,
|
||||||
|
id,
|
||||||
|
CursorSense::Pressing(CursorButton::Left),
|
||||||
|
Vec2::new(0.0, 100.0),
|
||||||
|
t(8),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
let out = g.handle(
|
||||||
|
&r,
|
||||||
|
id,
|
||||||
|
CursorSense::PressEnd(CursorButton::Left),
|
||||||
|
Vec2::new(0.0, 100.0),
|
||||||
|
t(16),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// (100 - DRAG_SLOP) px over the 8ms between the press and the one
|
||||||
|
// move that arrived: a real measurement of what was delivered, not
|
||||||
|
// an estimate of what the finger "probably" did in between.
|
||||||
|
let expected = (100.0 - DRAG_SLOP) / 0.008;
|
||||||
|
match out {
|
||||||
|
GestureOutcome::Released(Some(v)) => {
|
||||||
|
assert!((v - expected).abs() < 1.0, "expected ~{expected}, got {v}");
|
||||||
|
}
|
||||||
|
other => panic!("expected a released pan, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The other half of the same join, and the case the fix had no
|
||||||
|
/// reason to touch: a press and release with no motion at all is a
|
||||||
|
/// tap, and must not acquire a velocity from the seeded press sample.
|
||||||
|
#[test]
|
||||||
|
fn a_tap_is_still_a_tap_and_flings_nothing() {
|
||||||
|
let mut ui = UiData::default();
|
||||||
|
let id = some_id(&mut ui);
|
||||||
|
let r = render();
|
||||||
|
let mut g = DragGesture::new();
|
||||||
|
|
||||||
|
g.handle(
|
||||||
|
&r,
|
||||||
|
id,
|
||||||
|
CursorSense::PressStart(CursorButton::Left),
|
||||||
|
Vec2::ZERO,
|
||||||
|
t(0),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
let out = g.handle(
|
||||||
|
&r,
|
||||||
|
id,
|
||||||
|
CursorSense::PressEnd(CursorButton::Left),
|
||||||
|
Vec2::ZERO,
|
||||||
|
t(20),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert_eq!(out, GestureOutcome::Tapped);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A long-press selection released while the finger was still moving
|
||||||
|
/// must not fling either -- `Released(None)`, never the tracked
|
||||||
|
/// velocity. Also untouched by the press-seeding above, which is why
|
||||||
|
/// it is checked here rather than assumed.
|
||||||
|
#[test]
|
||||||
|
fn a_selection_release_carries_no_velocity() {
|
||||||
|
let mut ui = UiData::default();
|
||||||
|
let id = some_id(&mut ui);
|
||||||
|
let r = render();
|
||||||
|
let mut g = DragGesture::new();
|
||||||
|
|
||||||
|
g.handle(
|
||||||
|
&r,
|
||||||
|
id,
|
||||||
|
CursorSense::PressStart(CursorButton::Left),
|
||||||
|
Vec2::ZERO,
|
||||||
|
t(0),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
// Held still past LONG_PRESS, which is what starts a selection.
|
||||||
|
g.handle(
|
||||||
|
&r,
|
||||||
|
id,
|
||||||
|
CursorSense::Pressing(CursorButton::Left),
|
||||||
|
Vec2::ZERO,
|
||||||
|
t(0) + LONG_PRESS,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
let out = g.handle(
|
||||||
|
&r,
|
||||||
|
id,
|
||||||
|
CursorSense::PressEnd(CursorButton::Left),
|
||||||
|
Vec2::new(0.0, 50.0),
|
||||||
|
t(0) + LONG_PRESS + Duration::from_millis(10),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert_eq!(out, GestureOutcome::Released(None));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,7 @@ fn cursor_at(pos: Vec2) -> CursorState {
|
|||||||
exists: true,
|
exists: true,
|
||||||
buttons: Default::default(),
|
buttons: Default::default(),
|
||||||
scroll_delta: Vec2::ZERO,
|
scroll_delta: Vec2::ZERO,
|
||||||
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+155
-9
@@ -225,6 +225,21 @@ pub struct List {
|
|||||||
/// (headless tests, a caller driving `tick_fling` by hand as
|
/// (headless tests, a caller driving `tick_fling` by hand as
|
||||||
/// `bench_client.rs`'s scripted phases do).
|
/// `bench_client.rs`'s scripted phases do).
|
||||||
redraw: Option<Arc<dyn RequestRedraw>>,
|
redraw: Option<Arc<dyn RequestRedraw>>,
|
||||||
|
/// Physical pixels per `dp`, copied from the painter on every `draw`
|
||||||
|
/// -- what [`Self::fling`] hands `FlingCalculator`. 1.0 until this
|
||||||
|
/// list has been drawn once, which is also the only state in which a
|
||||||
|
/// fling is impossible (`fling` needs an anchor, and an anchor comes
|
||||||
|
/// from a draw).
|
||||||
|
///
|
||||||
|
/// It has to be the real one: the deceleration constant is
|
||||||
|
/// `GRAVITY * 39.37 * density * 160 * friction`, and the velocity fed
|
||||||
|
/// in is in the same physical pixels the touch events arrive in, so a
|
||||||
|
/// hardcoded 1.0 against a 2.75-density screen does not cancel out --
|
||||||
|
/// it makes the fling last exponentially too long. Measured on this
|
||||||
|
/// checkout's emulator, 2026-09-07, once flings could animate at all:
|
||||||
|
/// a flick that should coast for about a second ran for **45
|
||||||
|
/// seconds**.
|
||||||
|
density: f32,
|
||||||
/// Whether the last `draw` found no more content above the topmost
|
/// Whether the last `draw` found no more content above the topmost
|
||||||
/// visible row (its top edge at or past the viewport's own top, with
|
/// visible row (its top edge at or past the viewport's own top, with
|
||||||
/// no `prev_slot`) -- what `tick_fling` clamps a fling moving toward
|
/// no `prev_slot`) -- what `tick_fling` clamps a fling moving toward
|
||||||
@@ -244,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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,6 +285,7 @@ impl List {
|
|||||||
last_viewport_len: 0.0,
|
last_viewport_len: 0.0,
|
||||||
fling: None,
|
fling: None,
|
||||||
redraw: None,
|
redraw: None,
|
||||||
|
density: 1.0,
|
||||||
at_start: false,
|
at_start: false,
|
||||||
at_end: false,
|
at_end: false,
|
||||||
pending_tap: None,
|
pending_tap: None,
|
||||||
@@ -418,11 +443,32 @@ impl List {
|
|||||||
/// has no idea a finger came back down, and Android's own `Scroller`
|
/// has no idea a finger came back down, and Android's own `Scroller`
|
||||||
/// relies on the view calling `abortAnimation` for the same reason.
|
/// relies on the view calling `abortAnimation` for the same reason.
|
||||||
///
|
///
|
||||||
/// Density cancels out of the underlying spline as long as velocity
|
/// The density handed to `FlingCalculator` is this list's own
|
||||||
/// and the distance it produces share one pixel space (see
|
/// (`self.density`, taken from the painter in `draw`), not `1.0`: it
|
||||||
/// `FlingCalculator`'s own doc) -- `List` works entirely in logical
|
/// does **not** cancel out of the spline -- see `FlingCalculator`'s
|
||||||
/// pixels, so `1.0` here is not a placeholder for "unknown density,"
|
/// doc, which used to claim the opposite, and the 45-second coast that
|
||||||
/// it is the correct density for a self-consistent unit system.
|
/// claim produced.
|
||||||
|
///
|
||||||
|
/// **Sets the fling; it does not drive it.** A fling moves only while
|
||||||
|
/// something calls [`Self::tick_fling`] once per frame, and what does
|
||||||
|
/// that in a running app is `UiData::tick_animations`, over the ids
|
||||||
|
/// `UiData::animate` was given. So a caller starting a fling from a
|
||||||
|
/// gesture registers the list in the same breath:
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// list(ui).fling(-velocity);
|
||||||
|
/// let id = list.id();
|
||||||
|
/// ui.ui_mut().animate(id);
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Split that way because the two halves have different owners: the
|
||||||
|
/// velocity is the list's business, and whether anything animates at
|
||||||
|
/// all is the frame loop's. Missing the second call is what a finger
|
||||||
|
/// fling did on Iris's phone for two builds -- the velocity was right
|
||||||
|
/// and nothing ever advanced it, which looks exactly like a list that
|
||||||
|
/// stops dead under the finger. A caller driving frames itself
|
||||||
|
/// (`bench_client.rs`'s fling phase, the headless tests) calls
|
||||||
|
/// `tick_fling` directly instead and does not register.
|
||||||
pub fn fling(&mut self, velocity_px_per_s: f32) {
|
pub fn fling(&mut self, velocity_px_per_s: f32) {
|
||||||
// A NaN/inf velocity (a `VelocityTracker::velocity()` divide-by-
|
// A NaN/inf velocity (a `VelocityTracker::velocity()` divide-by-
|
||||||
// near-zero span, or a caller passing a raw device value straight
|
// near-zero span, or a caller passing a raw device value straight
|
||||||
@@ -436,9 +482,9 @@ impl List {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.fling = Some(Fling {
|
self.fling = Some(Fling {
|
||||||
calc: FlingCalculator::new(1.0),
|
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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -451,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) {
|
||||||
@@ -472,12 +529,26 @@ 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;
|
||||||
let settled_on_schedule = elapsed >= f.calc.duration(f.velocity);
|
let settled_on_schedule = elapsed >= f.calc.duration(f.velocity);
|
||||||
let velocity = f.velocity;
|
let velocity = f.velocity;
|
||||||
|
// The evidence that the spline is actually being followed, at the
|
||||||
|
// one granularity where a linear coast and a decelerating one look
|
||||||
|
// different: successive `dy` and `speed` shrinking. It was neither
|
||||||
|
// observable nor observed while `distance_fraction` returned `t`
|
||||||
|
// (`android_fling_spline`'s doc), which is why this is here rather
|
||||||
|
// than the total-travel line the release log already carries.
|
||||||
|
log::debug!(
|
||||||
|
"iris fling tick: t={:.3}s dy={:+.1}px speed={:.0}px/s of {:.0} left={:.1}px",
|
||||||
|
elapsed.as_secs_f32(),
|
||||||
|
delta,
|
||||||
|
f.calc.velocity_at(velocity, elapsed),
|
||||||
|
velocity,
|
||||||
|
f.calc.distance(velocity) - target,
|
||||||
|
);
|
||||||
self.scroll(delta);
|
self.scroll(delta);
|
||||||
|
|
||||||
// Clamp: a fling moving toward the start that has already reached
|
// Clamp: a fling moving toward the start that has already reached
|
||||||
@@ -876,8 +947,20 @@ impl List {
|
|||||||
const GENEROUS_PADDING: f32 = 100_000.0;
|
const GENEROUS_PADDING: f32 = 100_000.0;
|
||||||
|
|
||||||
impl Widget for List {
|
impl Widget for List {
|
||||||
|
/// A `List` animates exactly one thing, a fling
|
||||||
|
/// ([`Self::tick_fling`]). The registration that makes this run is
|
||||||
|
/// `UiData::animate` beside the `fling` call -- see `fling`'s own doc.
|
||||||
|
fn tick(&mut self, now: Instant) -> bool {
|
||||||
|
self.tick_fling(now)
|
||||||
|
}
|
||||||
|
|
||||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||||
let axis = self.axis;
|
let axis = self.axis;
|
||||||
|
// Learned from the frame rather than passed in: a fling's
|
||||||
|
// deceleration is a physical quantity and needs the real display
|
||||||
|
// density, and `draw` is where this widget meets the only thing
|
||||||
|
// that knows it. See `fling`.
|
||||||
|
self.density = painter.density();
|
||||||
let output_len = painter.output_size().axis(axis);
|
let output_len = painter.output_size().axis(axis);
|
||||||
self.viewport_len = painter.region().axis(axis).len().to_abs(output_len);
|
self.viewport_len = painter.region().axis(axis).len().to_abs(output_len);
|
||||||
|
|
||||||
@@ -1573,6 +1656,56 @@ mod tests {
|
|||||||
assert!(!rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
|
assert!(!rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The half `fling` itself does not do: a registered list is advanced
|
||||||
|
/// by the frame loop's own driver, and unregisters itself when the
|
||||||
|
/// fling settles. Written against `UiData::tick_animations` rather
|
||||||
|
/// than `tick_fling` because the defect it pins is exactly the gap
|
||||||
|
/// between the two -- a fling with a correct velocity that nothing
|
||||||
|
/// ever advanced, which is what a finger fling did on the phone.
|
||||||
|
#[test]
|
||||||
|
fn a_registered_fling_is_driven_by_tick_animations_and_then_unregisters() {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
|
||||||
|
let before = rsc
|
||||||
|
.ui
|
||||||
|
.widgets
|
||||||
|
.get(&list_weak)
|
||||||
|
.unwrap()
|
||||||
|
.anchor_position_display();
|
||||||
|
|
||||||
|
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(-8000.0);
|
||||||
|
rsc.ui.animate(list_weak.id());
|
||||||
|
|
||||||
|
let start = Instant::now();
|
||||||
|
let mut animating = true;
|
||||||
|
let mut steps = 0;
|
||||||
|
while animating && steps < 600 {
|
||||||
|
animating = rsc
|
||||||
|
.ui
|
||||||
|
.tick_animations(start + std::time::Duration::from_millis(steps * 16));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
steps += 1;
|
||||||
|
}
|
||||||
|
assert!(!animating, "the driver never stopped within 600 frames");
|
||||||
|
assert!(steps > 1, "the fling settled without ever moving");
|
||||||
|
assert!(!rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
|
||||||
|
assert_ne!(
|
||||||
|
before,
|
||||||
|
rsc.ui
|
||||||
|
.widgets
|
||||||
|
.get(&list_weak)
|
||||||
|
.unwrap()
|
||||||
|
.anchor_position_display(),
|
||||||
|
"the list is where it started -- the fling was registered but never applied"
|
||||||
|
);
|
||||||
|
// Nothing left registered, so the next frame costs nothing: the
|
||||||
|
// path out of `animate` is the `false` answer, not a caller
|
||||||
|
// remembering to remove it.
|
||||||
|
assert!(!rsc.ui.tick_animations(start));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fling_distance_is_positive_toward_the_end() {
|
fn fling_distance_is_positive_toward_the_end() {
|
||||||
let mut rsc = TestRsc {
|
let mut rsc = TestRsc {
|
||||||
@@ -1670,6 +1803,19 @@ mod tests {
|
|||||||
w[1]
|
w[1]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Non-increasing is not deceleration: a fling that coasts at a
|
||||||
|
// constant speed and then stops dead satisfies every `<=` above,
|
||||||
|
// and that is exactly what iris shipped until 2026-09-07
|
||||||
|
// (`android_fling_spline`'s doc). Over the samples collected here
|
||||||
|
// -- the earliest part of the curve, since row 0 leaves the loaded
|
||||||
|
// extents soon after -- AOSP's spline has already lost more than
|
||||||
|
// a fifth of its speed.
|
||||||
|
let (first, last) = (deltas[1], *deltas.last().unwrap());
|
||||||
|
assert!(
|
||||||
|
last < first * 0.8,
|
||||||
|
"fling barely slowed across {} ticks: {first} -> {last}",
|
||||||
|
deltas.len()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
// these methods should "not require any context" (require unit) because they're in core
|
// these methods should "not require any context" (require unit) because they're in core
|
||||||
widget_trait! {
|
widget_trait! {
|
||||||
@@ -111,7 +110,7 @@ widget_trait! {
|
|||||||
let id = ctx.widget.id();
|
let id = ctx.widget.id();
|
||||||
let (sense, pos) = (ctx.data.sense, ctx.data.cursor.pos);
|
let (sense, pos) = (ctx.data.sense, ctx.data.cursor.pos);
|
||||||
ctx.widget(rsc)
|
ctx.widget(rsc)
|
||||||
.drag(ctx.data.render, id, sense, pos, Instant::now());
|
.drag(ctx.data.render, id, sense, pos, ctx.data.cursor.time);
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.add(state)
|
.add(state)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -52,7 +52,7 @@ pub mod tool;
|
|||||||
use client_core::transcript_fold::TranscriptRow as FoldedRow;
|
use client_core::transcript_fold::TranscriptRow as FoldedRow;
|
||||||
use iris::prelude::*;
|
use iris::prelude::*;
|
||||||
use selection::Selection;
|
use selection::Selection;
|
||||||
use std::{cell::RefCell, rc::Rc, time::Instant};
|
use std::{cell::RefCell, rc::Rc};
|
||||||
|
|
||||||
pub struct TranscriptScreen {
|
pub struct TranscriptScreen {
|
||||||
/// The transcript's own `List` -- exposed so a caller can read
|
/// The transcript's own `List` -- exposed so a caller can read
|
||||||
@@ -412,7 +412,7 @@ where
|
|||||||
row,
|
row,
|
||||||
ctx.data.cursor.pos,
|
ctx.data.cursor.pos,
|
||||||
ctx.data.sense,
|
ctx.data.sense,
|
||||||
Instant::now(),
|
ctx.data.cursor.time,
|
||||||
ctx.data.render,
|
ctx.data.render,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ use crate::tool::ToolRow;
|
|||||||
use client_core::markdown_blocks::{Block, BlockKind, common_prefix, split_blocks};
|
use client_core::markdown_blocks::{Block, BlockKind, common_prefix, split_blocks};
|
||||||
use client_core::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
|
use client_core::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
|
||||||
use iris::prelude::*;
|
use iris::prelude::*;
|
||||||
use std::{cell::RefCell, rc::Rc, time::Instant};
|
use std::{cell::RefCell, rc::Rc};
|
||||||
|
|
||||||
/// The gap drawn between two markdown blocks of one message. A block used
|
/// The gap drawn between two markdown blocks of one message. A block used
|
||||||
/// to be separated by the blank line `markdown::render_markdown` put in
|
/// to be separated by the blank line `markdown::render_markdown` put in
|
||||||
@@ -237,7 +237,7 @@ where
|
|||||||
Some((key, pos, size)),
|
Some((key, pos, size)),
|
||||||
cursor,
|
cursor,
|
||||||
ctx.data.sense,
|
ctx.data.sense,
|
||||||
Instant::now(),
|
ctx.data.cursor.time,
|
||||||
ctx.data.render,
|
ctx.data.render,
|
||||||
);
|
);
|
||||||
// A *tap*, decided by the same `DragArbiter` the pan and
|
// A *tap*, decided by the same `DragArbiter` the pan and
|
||||||
|
|||||||
@@ -294,7 +294,14 @@ impl Selection {
|
|||||||
// happened to end with the finger still moving, and never a
|
// happened to end with the finger still moving, and never a
|
||||||
// tap/long-press that never left `Undecided` -- exactly what
|
// tap/long-press that never left `Undecided` -- exactly what
|
||||||
// `DragGesture`'s `Some(v)` already encodes.
|
// `DragGesture`'s `Some(v)` already encodes.
|
||||||
GestureOutcome::Released(Some(v)) => list(ui).fling(-v),
|
GestureOutcome::Released(Some(v)) => {
|
||||||
|
list(ui).fling(-v);
|
||||||
|
// The half that actually makes it move -- see
|
||||||
|
// `List::fling`'s doc. Without it the velocity is
|
||||||
|
// computed, stored, and never advanced by anything.
|
||||||
|
let id = list.id();
|
||||||
|
ui.ui_mut().animate(id);
|
||||||
|
}
|
||||||
// A tap is nobody's business here -- `row.rs` reads it from
|
// A tap is nobody's business here -- `row.rs` reads it from
|
||||||
// the returned outcome and follows a link if one was under
|
// the returned outcome and follows a link if one was under
|
||||||
// the finger.
|
// the finger.
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ use crate::selection::Selection;
|
|||||||
use client_core::tool_summary::{ToolInput, parse_tool_input};
|
use client_core::tool_summary::{ToolInput, parse_tool_input};
|
||||||
use client_core::transcript_fold::{ToolState, TranscriptItem};
|
use client_core::transcript_fold::{ToolState, TranscriptItem};
|
||||||
use iris::prelude::*;
|
use iris::prelude::*;
|
||||||
use std::{cell::Cell, cell::RefCell, collections::HashMap, rc::Rc, time::Instant};
|
use std::{cell::Cell, cell::RefCell, collections::HashMap, rc::Rc};
|
||||||
|
|
||||||
/// A card's own fill: Surface 0, what Material's filled `Card` resolves to
|
/// A card's own fill: Surface 0, what Material's filled `Card` resolves to
|
||||||
/// under `Theme.kt`'s scheme. One step *above* the page, so a card reads
|
/// under `Theme.kt`'s scheme. One step *above* the page, so a card reads
|
||||||
@@ -202,7 +202,7 @@ fn on_tap<Rsc: HasEvents>(
|
|||||||
None,
|
None,
|
||||||
ctx.data.cursor.pos,
|
ctx.data.cursor.pos,
|
||||||
ctx.data.sense,
|
ctx.data.sense,
|
||||||
Instant::now(),
|
ctx.data.cursor.time,
|
||||||
ctx.data.render,
|
ctx.data.render,
|
||||||
);
|
);
|
||||||
if outcome == GestureOutcome::Tapped {
|
if outcome == GestureOutcome::Tapped {
|
||||||
|
|||||||
Reference in new issue
Block a user