2 Commits
Author SHA1 Message Date
irisandClaude Fable 5.1 ba0f2ea93f docs: the 22:16 report reconciled with what was actually run
RUST.md's "Shell lost" section and IRIS_TODO.md's matching paragraph both
said item 4's fix was written but never built or tested. It was committed
in ba2afba with its test passing, so both were stale the moment that
landed and read as if nothing had been run at all.

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

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

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

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

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

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

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

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

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

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

No files matched your search

+53
View File
@@ -8,6 +8,59 @@ 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: 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
+56 -5
View File
@@ -351,7 +351,13 @@ 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."** *(Three defects fixed 2026-09-07;
open until the phone says so. **The line to look for:**
`adb logcat | grep "iris drag release"` -- `samples=1` or `span=0.0ms`
means the batched samples are not reaching the tracker there, while a
sensible span with `v=` in the thousands and
`outcome=Released(Some(…))` means the gesture was measured right and
anything still wrong is downstream.)* Second report; the emulator's
`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 +370,24 @@ 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 - [x] **"Message box does not push up the scroll area."** *(Fixed
2026-09-07: height and visibility are two JNI values now. Emulator:
`iris insets: … bottom=883 ime_bottom=883 ime_visible=true`, composer
box `31,2277..1048,2329` -> `31,1457..1048,1509`, and the list follows
because it is `rest(1)` in the same `Span`.)* Since
`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 +397,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 +418,41 @@ 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`, with `clearing_the_atlas_re_renders_cached_text_
instead_of_reusing_it` run and passing (2026-09-07). Ticked on the
code; still wants phone-side confirmation** -- no emulator here has a
Vulkan adapter, and the GLES path may not destroy the surface at all,
so the emulator cannot reproduce the state Iris photographed.
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
+197
View File
@@ -43,6 +43,203 @@ 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.
### 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, rig not yet built)
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/src/layout_tests.rs` already builds a tree over `UiRenderState`
with no window, `sense.rs`'s gesture tests feed fabricated
`CursorState`s with their own times, and `List::tick_fling` is
driven by hand. Extend that into one harness that opens
`transcript-ui`'s screen on `app/bench-fixture` (as `bench_client.rs`
does on Android, no server), at the phone's logical size and
`content_scale` from `docs/bench/iris-phone-v2-2026-09-06.md` (2.55,
120Hz -- read, never typed from memory), ticks frames, and feeds a
**replayed touch stream** from a trivial file of `(t_ms, action, x,
y)` lines with `cursor.time` taken from the file. That is what the
emulator cannot do at all: the batched 120Hz flick from the phone
report becomes a deterministic test asserting on scroll offset and on
the `iris drag release:` velocity. Anything about layout, scroll
position, selection, focus or fold state is answered here, with
assertions rather than eyes. Nothing renders; a widget's placed
rectangle is the evidence.
2. **A phone-shaped desktop window under headless sway -- for looking.**
`iris/run-headless.sh` already runs a winit binary under a private
sway and screenshots it with `grim`. Add a `--phone` mode (output and
window at the phone's size and scale, with the scale reaching iris
the way Android's does so dp layout runs at that density) and
touch-shaped mouse input: a left-button drag pans and flings through
`DragArbiter`, long-press selects, no hover. One input path, not a
parallel one (`CODE_RULES`). Colour, spacing, text and anything a
person has to see is answered here. `swaymsg seat - cursor
move/press/release` drives it when a gesture is needed on screen.
3. **The Android emulator -- platform plumbing and the final pass.**
JNI, IME, insets, surface lifecycle, the renderer rebuild, and one
verification run before a build goes to the phone. Not for iterating
on layout.
Pass condition for the rig: `cargo test` runs a fixture-backed headless
transcript screen with a replayed flick and asserts a nonzero release
velocity and a moved scroll offset; one command opens the same screen in
a phone-shaped window and screenshots it. Record the commands here when
it lands.
### The 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.
+1 -1
View File
@@ -95,7 +95,7 @@ members = ["core", "macro", "tabs-ui", "transcript-ui", "desktop-app"]
# 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]
+9 -1
View File
@@ -13,7 +13,15 @@ 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
// 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
targetSdk = 34 targetSdk = 34
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
@@ -55,30 +55,33 @@ public final class MainActivity extends Activity {
int top = insets.getSystemWindowInsetTop(); int top = insets.getSystemWindowInsetTop();
int right = insets.getSystemWindowInsetRight(); int right = insets.getSystemWindowInsetRight();
int bottom = insets.getSystemWindowInsetBottom(); int bottom = insets.getSystemWindowInsetBottom();
// The manifest declares adjustResize (AGENTS.md: without it the // **Two separate answers, because they are separate questions**
// keyboard pans the whole window instead of resizing it), and // (Iris's phone, 2026-09-06: "message box does not push up the
// under adjustResize the window itself shrinks to make room for // scroll area"). `isVisible(ime())` says whether the keyboard is
// the keyboard -- which is exactly the condition under which // up; `getInsets(ime()).bottom` says how tall it is. An earlier
// WindowInsets.Type.ime()'s own *inset amount* reports zero: it // pass sent the boolean *as* the height (0 or 1) because under
// measures how much of the window the keyboard overlaps, and // plain `adjustResize` the window shrinks to make room and the
// resize already made that overlap zero by construction. That // ime inset therefore measures a zero overlap by construction --
// numeric inset is not a usable "is the keyboard open" signal // true then, and no longer true now: `setDecorFitsSystemWindows
// here (found while root-causing why bench_client.rs's keyboard // (false)` above makes this an edge-to-edge window, which is
// phase and auto-diagnostics never fired on the emulator despite // exactly the case where the system stops resizing and hands the
// the keyboard visibly opening -- RUST.md's P0 box). What does // app the real overlap instead. Sending 1 for it left the Rust
// survive adjustResize is the boolean isVisible() answer, set // side padding the composer by one physical pixel, so the
// from the platform's own start/end of the transition over a // keyboard covered the bar and the transcript alike.
// different path than the inset amount -- the same fact //
// AGENTS.md's "Things that have bitten" already names for the // The visibility is still sent in its own right rather than
// Compose side's identical trap. Passed through as a 0/1 stand- // inferred from `height > 0`: the two disagree during the
// in for the ime_bottom pixel amount, since nothing on the Rust // keyboard's slide-in and -out (visible, height still climbing),
// side reads it as a real pixel value -- only `> 0.0`. // 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 imeBottom = 0;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R int imeVisible = 0;
&& insets.isVisible(WindowInsets.Type.ime())) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
imeBottom = 1; imeBottom = insets.getInsets(WindowInsets.Type.ime()).bottom;
imeVisible = insets.isVisible(WindowInsets.Type.ime()) ? 1 : 0;
} }
((IrisView) v).applyWindowInsets(left, top, right, bottom, imeBottom); ((IrisView) v).applyWindowInsets(left, top, right, bottom, imeBottom, imeVisible);
return insets; return insets;
}); });
} }
+2 -2
View File
@@ -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 -1
View File
@@ -408,7 +408,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 {
+11 -5
View File
@@ -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
+40
View File
@@ -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 {
+46 -9
View File
@@ -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))
+19
View File
@@ -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 () {
+17 -5
View File
@@ -45,11 +45,21 @@ 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)]
@@ -89,6 +99,7 @@ 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 { shared.borrow_mut().insets = Insets {
@@ -97,6 +108,7 @@ extern "system" fn apply_window_insets<'local>(
right, right,
bottom, bottom,
ime_bottom, ime_bottom,
ime_visible: ime_visible != 0,
}; };
} }
// Insets can change (the keyboard opening) with no resize and no // Insets can change (the keyboard opening) with no resize and no
@@ -115,7 +127,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 {
+114 -10
View File
@@ -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::{
@@ -195,7 +195,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 +211,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 +290,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 +319,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 +345,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 +405,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 +438,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 +476,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 +590,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 +660,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 +997,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
View File
@@ -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));
}
} }
_ => {} _ => {}
} }
+9
View File
@@ -1,4 +1,10 @@
// `CursorState::time` is the sample's own time on every backend. winit
// carries no timestamp on a pointer event, so the moment it is handed to
// us is the closest measurement available here -- which is also what the
// drag code used to do for itself with `Instant::now()`, before Android's
// batched samples made the difference matter (see `sense::CursorState`).
use crate::prelude::*; use crate::prelude::*;
use std::time::Instant;
use winit::{ use winit::{
event::{MouseButton, MouseScrollDelta, WindowEvent}, event::{MouseButton, MouseScrollDelta, WindowEvent},
keyboard::{Key, NamedKey}, keyboard::{Key, NamedKey},
@@ -21,8 +27,10 @@ impl Input {
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) / scale_factor;
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 {
@@ -44,6 +52,7 @@ impl Input {
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;
+12
View File
@@ -267,9 +267,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,
+276 -14
View File
@@ -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,13 +914,7 @@ 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 }
} }
} }
@@ -964,6 +1028,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 +1050,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 +1066,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,
} }
} }
@@ -1147,6 +1226,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);
@@ -1410,3 +1522,153 @@ mod drag_arbiter_tests {
); );
} }
} }
/// [`DragGesture`] end to end, at the shape Android actually delivers a
/// flick in. The arbiter and the tracker each behave correctly on their
/// own (the two modules above); what these cover is the join between them
/// at release, which is where the phone's missing fling lived.
#[cfg(test)]
mod drag_gesture_tests {
use super::*;
use std::sync::LazyLock;
static BASE: LazyLock<Instant> = LazyLock::new(Instant::now);
fn t(ms: u64) -> Instant {
*BASE + Duration::from_millis(ms)
}
/// A `UiRenderState` with nothing in it. `DragGesture` only ever calls
/// `capture_pointer`/`release_pointer` on it, which are bookkeeping on
/// a `Cell` and need no widget tree behind them.
fn render() -> UiRenderState {
UiRenderState::new()
}
/// The id `capture_pointer` records. Any id will do -- nothing here
/// resolves it -- so it comes from a real (empty) widget registry
/// rather than being fabricated.
fn some_id(ui: &mut UiData) -> WidgetId {
ui.widgets.add_strong(Rect::new(UiColor::WHITE)).id()
}
/// **The phone's shape.** A 120Hz flick reaches the app as very few
/// `MotionEvent`s, so before `on_touch_event` replayed the historical
/// samples inside them a whole gesture could be press, one move past
/// the slop, release. That released at `v=0` -- `velocity()` needs two
/// samples and the single `Pan` frame was the only one -- so the list
/// stopped dead under the finger while the same gesture driven as many
/// evenly-spaced `ui-trace` events flung perfectly. The press is a
/// sample now, so even this minimum still carries a real speed.
#[test]
fn a_flick_delivered_as_one_move_frame_still_releases_with_a_velocity() {
let mut ui = UiData::default();
let id = some_id(&mut ui);
let r = render();
let mut g = DragGesture::new();
g.handle(
&r,
id,
CursorSense::PressStart(CursorButton::Left),
Vec2::ZERO,
t(0),
false,
);
g.handle(
&r,
id,
CursorSense::Pressing(CursorButton::Left),
Vec2::new(0.0, 100.0),
t(8),
false,
);
let out = g.handle(
&r,
id,
CursorSense::PressEnd(CursorButton::Left),
Vec2::new(0.0, 100.0),
t(16),
false,
);
// (100 - DRAG_SLOP) px over the 8ms between the press and the one
// move that arrived: a real measurement of what was delivered, not
// an estimate of what the finger "probably" did in between.
let expected = (100.0 - DRAG_SLOP) / 0.008;
match out {
GestureOutcome::Released(Some(v)) => {
assert!((v - expected).abs() < 1.0, "expected ~{expected}, got {v}");
}
other => panic!("expected a released pan, got {other:?}"),
}
}
/// The other half of the same join, and the case the fix had no
/// reason to touch: a press and release with no motion at all is a
/// tap, and must not acquire a velocity from the seeded press sample.
#[test]
fn a_tap_is_still_a_tap_and_flings_nothing() {
let mut ui = UiData::default();
let id = some_id(&mut ui);
let r = render();
let mut g = DragGesture::new();
g.handle(
&r,
id,
CursorSense::PressStart(CursorButton::Left),
Vec2::ZERO,
t(0),
false,
);
let out = g.handle(
&r,
id,
CursorSense::PressEnd(CursorButton::Left),
Vec2::ZERO,
t(20),
false,
);
assert_eq!(out, GestureOutcome::Tapped);
}
/// A long-press selection released while the finger was still moving
/// must not fling either -- `Released(None)`, never the tracked
/// velocity. Also untouched by the press-seeding above, which is why
/// it is checked here rather than assumed.
#[test]
fn a_selection_release_carries_no_velocity() {
let mut ui = UiData::default();
let id = some_id(&mut ui);
let r = render();
let mut g = DragGesture::new();
g.handle(
&r,
id,
CursorSense::PressStart(CursorButton::Left),
Vec2::ZERO,
t(0),
false,
);
// Held still past LONG_PRESS, which is what starts a selection.
g.handle(
&r,
id,
CursorSense::Pressing(CursorButton::Left),
Vec2::ZERO,
t(0) + LONG_PRESS,
false,
);
let out = g.handle(
&r,
id,
CursorSense::PressEnd(CursorButton::Left),
Vec2::new(0.0, 50.0),
t(0) + LONG_PRESS + Duration::from_millis(10),
false,
);
assert_eq!(out, GestureOutcome::Released(None));
}
}
+1
View File
@@ -51,6 +51,7 @@ fn cursor_at(pos: Vec2) -> CursorState {
exists: true, exists: true,
buttons: Default::default(), buttons: Default::default(),
scroll_delta: Vec2::ZERO, scroll_delta: Vec2::ZERO,
..Default::default()
} }
} }
+99 -1
View File
@@ -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
@@ -261,6 +276,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,
@@ -423,6 +439,26 @@ impl List {
/// `FlingCalculator`'s own doc) -- `List` works entirely in logical /// `FlingCalculator`'s own doc) -- `List` works entirely in logical
/// pixels, so `1.0` here is not a placeholder for "unknown density," /// pixels, so `1.0` here is not a placeholder for "unknown density,"
/// it is the correct density for a self-consistent unit system. /// it is the correct density for a self-consistent unit system.
/// **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,7 +472,7 @@ 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: Instant::now(),
applied: 0.0, applied: 0.0,
@@ -876,8 +912,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 +1621,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 {
+1 -2
View File
@@ -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)
+2 -2
View File
@@ -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,
); );
}, },
+2 -2
View File
@@ -29,7 +29,7 @@ use crate::tool::ToolRow;
use client_core::markdown_blocks::{Block, BlockKind, common_prefix, split_blocks}; use client_core::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
+8 -1
View File
@@ -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.
+2 -2
View File
@@ -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 {