Author SHA1 Message Date
iris 27ca5b2349 Merge remote-tracking branch 'origin/rustify' into worktree-agent-a9002910a315fe719 2026-09-06 02:03:04 -04:00
irisandClaude Fable 5.1 20b12255e1 iris/android: composing text sync, tap-vs-swipe focus, composer rebuild, atlas reset on app-switch
Four fixes from Iris's phone report on the dc01f88 build, plus her same-day
follow-up on swipe-vs-tap:

- android/ime.rs: InputConnection now calls InputMethodManager.updateSelection
  after every edit (new update_ime_selection, called from after_input) -- Gboard
  was holding keystrokes back with nothing telling it the app's selection/
  composing region had moved, which read as "doesn't enter it until I hit
  space, doesn't move the caret". New unit tests in widget/text/edit.rs cover
  the buffer-level composing/commit/delete/selection operations directly.

- attr.rs: Selector/Selectable rewritten around a shared on_press dispatcher
  over PressStart/Pressing/PressEnd instead of click_or_drag(), so a field
  that isn't already focused only grants focus (and requests the IME) on a
  completed tap -- press and release with no frame past DRAG_SLOP. A drag
  is never consumed, so whatever is behind the field still sees it. New
  FocusHost::is_focused (both platform impls) and TextEdit::press_origin
  back this. Verified on the emulator: dumpsys input_method's mInputShown
  stays false after a swipe over the composer, true after a tap.

- iris_core: GlyphAtlas::clear()/Textures::reset(), called together from
  android/view.rs's surface_changed exactly when a genuinely new renderer is
  built (app-switch, not the keyboard-resize path that already reuses the
  renderer) -- both CPU-side caches otherwise kept pointing at the old,
  destroyed device's textures. Verified on the emulator: home, reopen, every
  glyph still on screen.

- transcript-ui/composer.rs: rebuilt as one widget (unchanged Stack{rect,
  span} idiom, capped at ~6 lines via MaxSize + .scrollable(), wrapped in one
  Pad whose bottom Composer::set_bottom_inset rewrites in place so the bar
  sits on the IME or nav-bar inset with no rebuild -- rebuilding would drop
  focus/selection/in-progress text). Wired from bench_client.rs's existing
  on_insets_changed.

A second, deeper bug found while verifying the composing fix is NOT fixed
this pass: composed text never becomes visible at all. A new layout_tests.rs
test proves the widget tree's own region math is correct across a keyboard
resize, ruling that out; RUST.md's P0 box has the full writeup and what to
check next (UiRenderState::redraw's single-widget path, or something
force-gles-specific -- this AVD has no Vulkan adapter to rule that out with).

cargo fmt/clippy/test --workspace and cargo ndk clippy all clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 02:03:00 -04:00
irisandClaude Fable 5.1 71a3fae655 IRIS_TODO.md: streaming re-lays out the whole message, from the phone's bench v2
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 01:35:26 -04:00
irisandClaude Fable 5.1 c3984da623 docs/bench: iris bench v2 report from Iris's phone, verbatim, with her observations
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 01:34:39 -04:00
irisandClaude Fable 5.1 2e3f4ada38 Merge iris fling/jitter fix + Benchmark v2 + header/ime follow-ups into rustify
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 01:23:50 -04:00
irisandClaude Fable 5.1 03c6be80a3 iris android-app: header-duplicate investigation, ime-inset fix for keyboard confirmation
Two follow-ups after the keyboard/dp/header pass, both requested against
the P0 box:

(a) The header row rendering a second time inside the transcript area
after a keyboard-triggered resize: reproduced reliably (tap the composer,
screenshot after the keyboard opens). Ruled out one concrete hypothesis --
on_insets_changed rebuilding top_bar on every ime_bottom change, unrelated
to the header's own status-bar padding -- with a guard (last_top_pad) that
reproduced the identical duplicate afterward, so repeated rebuilding is
not the cause. Kept the guard as a real (if insufficient) fix for needless
rebuilds. Not root-caused: Span's two-phase provisional/real draw and the
redraw_all-vs-redraw_updates split are the two live suspects, but pinning
which one (or something else) produces the duplicate needs instrumenting
draw_inner directly or the phone. Full writeup in RUST.md's P0 box.

(b) Why on_insets_changed's ime_bottom never confirmed the keyboard being
shown, on either the auto-diagnostics or the new bench keyboard phase:
MainActivity.java uses windowSoftInputMode="adjustResize", under which
WindowInsets.Type.ime()'s own inset amount is defined to read zero (the
window already resized to avoid the overlap that inset would describe) --
the same trap AGENTS.md already names for the Compose side. Fixed to read
insets.isVisible(ime()) instead, a boolean unaffected by resize-vs-pan.
This alone did not make the callback re-fire on this emulator, which
still shows no insets callback after the initial one at attach -- named
but unconfirmed hypothesis: a non-edge-to-edge Activity may not get insets
redelivered for a pure IME toggle handled via resize, needing an edge-to-
edge opt-in this pass did not attempt given the risk to adjustResize's
own behavior.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 01:23:36 -04:00
iris 4afc453faa Merge remote-tracking branch 'origin/rustify' into worktree-agent-a16b22e34539b810e
# Conflicts:
#	iris/android-app/src/bench_client.rs
#	iris/android-app/src/bench_jni.rs
2026-09-06 01:05:18 -04:00
irisandClaude Fable 5.1 1aab61bf26 iris android-app: Benchmark v2 -- fling, type and keyboard phases
Implements RUST.md's "Benchmark v2" spec in bench_client.rs: fling (8 out
+ 8 back at 12,000px/s through List::fling, waits for !is_scrolling()
capped 3s, reports travel as row index + offset via List's new
anchor_position_display), stream (unchanged), type (the 600-char P0
constant, one char per 50ms into the composer's real TextEdit via .set(),
then deleted), and keyboard (5 show/hide cycles via bench_jni.rs's new
InputMethodManager calls, confirmed from on_insets_changed's real
ime_bottom transitions rather than assumed from the JNI call returning).

FrameReport gained mark_phase/phase_stats/late_at_hz (iris/core) so the
report can show a per-phase block (frames, late%, p50/p90/p99, worst)
against the display's real refresh rate (bench_jni's new
refresh_rate_hz), matching the shape docs/bench/compose-phone-v2 uses.
RING_CAPACITY bumped 4096->16384 since a full v2 run is ~3,000+ frames.

Found and fixed a real deadlock while wiring this up: read_from_state
(a new helper that gets a value back out of a spawned task's ctx.update,
which has no return channel of its own) only worked for its first call in
a chain, because nothing called redraw.request_redraw() after enqueueing
later ones -- nothing then drains the task channel to run them. Every
call now triggers its own redraw.

Verified end to end on this checkout's x86_64 emulator (force-gles, cold
boot): fling/stream/type all report populated phase blocks; keyboard's
show never got a real on_insets_changed confirmation this run (see
follow-up work). Full report and travel numbers go in RUST.md's P0 box
next.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 01:02:04 -04:00
iris dc01f88d75 Merge branch 'worktree-agent-a1ff0294b6c29127e' into tmp-merge 2026-09-06 00:54:21 -04:00
20 changed files with 1802 additions and 140 deletions

No files matched your search

+54
View File
@@ -8,6 +8,31 @@ 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-06: `List::anchor_position_display`, `FrameReport::mark_phase`/`phase_stats`/`late_at_hz` (RUST.md's "Benchmark v2")
`List` gained `anchor_position_display(&self) -> String`, reporting the
anchor's own row index and pixel offset (`idx=N/off=Mpx`, or
`idx=more-before`/`idx=more-after`/`idx=none`) -- what a scripted
benchmark reads to report fling travel. Note the anchor does not
necessarily change *slot* over a long scroll (this widget's own documented
design: the anchor is a stable identity, not re-derived from what's on
screen each frame), so this is not the same measurement as a Compose
`LazyListState.firstVisibleItemIndex`, which does track the true topmost
visible row -- the `off` half is what actually reflects how far a fling
travelled.
`iris_core::render::frame_report::FrameReport` gained three methods for
per-phase benchmark reporting: `mark_phase(name)` records a named phase
boundary at the current frame/instant; `phase_stats(now, refresh_hz)`
returns one `PhaseStats` (frames, wall duration, late count/percent,
p50/p90/p99, worst) per marked phase, sliced from the existing ring by a
new parallel `index_ring`; `late_at_hz(refresh_hz)` gives the whole run's
late count/percent judged against an arbitrary refresh rate rather than
the fixed 60Hz `JANK_THRESHOLD` every existing caller still uses (a
separate method, not a parameter on `report()`, so nothing else changes
behaviour). `RING_CAPACITY` grew 4096->16384 to hold a full multi-phase
run without evicting earlier phases' samples.
## 2026-09-06: `List::fling`, `VelocityTracker`, `FlingCalculator` (IRIS_TODO.md's "swiping has no momentum") ## 2026-09-06: `List::fling`, `VelocityTracker`, `FlingCalculator` (IRIS_TODO.md's "swiping has no momentum")
`iris::widget::List` gained a real fling: `fling(velocity_px_per_s)` starts `iris::widget::List` gained a real fling: `fling(velocity_px_per_s)` starts
@@ -626,3 +651,32 @@ box has the full investigation and the phone verification still to do.
built and checked on this checkout's emulator only. RUST.md's P0 box built and checked on this checkout's emulator only. RUST.md's P0 box
says what she should check for: crisp text at two densities, the says what she should check for: crisp text at two densities, the
keyboard no longer wiping, and the header's background. keyboard no longer wiping, and the header's background.
## 2026-09-06: composing text, focus-on-tap, and atlas invalidation on a new renderer
Three small but public API changes, from the same phone-report pass as the
entry above (RUST.md's P0 box has the full account, including a real bug
still not root-caused).
- **`FocusHost` gained `is_focused(&self, id) -> bool`** (both platform
impls). `attr.rs`'s `Selector`/`Selectable` used to grant focus (and so
request the IME) on the very first frame of *any* press, before it was
known whether the gesture was a tap or a drag — a swipe over a text
field wrongly summoned the keyboard. They now wait for a completed tap
(press and release with no frame crossing `sense::DRAG_SLOP`) unless the
field is already focused, in which case dragging inside it to select
text is unchanged. `TextEdit` gained one new `pub(crate)` field
(`press_origin`) to track this; no public surface change there.
- **`android::ime`'s `InputConnection` now calls `InputMethodManager::
updateSelection` after every edit** (`IrisViewPeer::update_ime_selection`,
called from `after_input`). Gboard was holding keystrokes back because
nothing ever told it where the app's own selection/composing region had
moved to — this is what android-view's own demo does in its `render()`
and this bridge never did.
- **`GlyphAtlas::clear()` and `Textures::reset()`** (`iris_core`). Called
together, once, from `android::view`'s `surface_changed` exactly when a
*genuinely new* `AndroidRenderer` is built (backgrounding and returning,
not a keyboard-triggered resize, which already reuses the renderer) —
both CPU-side caches otherwise kept pointing at the old, now-destroyed
device's textures, which is why text used to vanish again after leaving
and returning to the app.
+51
View File
@@ -149,6 +149,39 @@ agent takes them without colliding with that pass's `bench_client.rs`/
confirming this was the whole story on real touch input rather than confirming this was the whole story on real touch input rather than
only the arbiter's own unit tests -- worth a follow-up pass before only the arbiter's own unit tests -- worth a follow-up pass before
calling it fully closed. calling it fully closed.
- [x] **Composing text held back until a space, caret not moving, fixed
2026-09-06.** `InputMethodManager.updateSelection` was never called --
see IRIS.md's 2026-09-06 entry and RUST.md's P0 box, item 1, for the
full account and the emulator evidence.
- [x] **Swipe over the composer summons the keyboard, fixed 2026-09-06.**
`Selector`/`Selectable` now wait for a completed tap -- see IRIS.md's
2026-09-06 entry and RUST.md's P0 box, item 5. Verified via `dumpsys
input_method`'s `mInputShown` on the emulator, not yet on the phone.
- [x] **Text disappears again after leaving and returning to the app,
fixed 2026-09-06.** `GlyphAtlas::clear`/`Textures::reset` on a
genuinely new renderer -- see IRIS.md's 2026-09-06 entry and RUST.md's
P0 box, item 4. Verified on the emulator (home, reopen, screenshot);
not yet on the phone.
- [ ] **Composed/typed text never becomes visible at all -- found
2026-09-06, not fixed.** The composer bar stays empty even once the
buffer genuinely holds the typed text (confirmed indirectly: Gboard's
own suggestion strip reacts correctly to each keystroke). A new unit
test proves the widget tree's own layout math resolves the field's
region correctly across a keyboard resize, so the bug is downstream of
that -- most likely `UiRenderState::redraw`'s single-widget redraw path,
or specific to this emulator's forced `force-gles` backend (untested on
Vulkan or the real phone). RUST.md's P0 box, item 2, has the full
writeup, what was ruled out, and where to look next. **Also unverified
because of this**: item 3's composer rebuild (one `Stack`-based widget,
a capped/scrollable height, bottom padding tied to the IME/nav-bar
inset) -- structurally in place and unit-tested, but its own visual
correctness cannot be screenshotted until text actually renders.
- [ ] **The composer has no touch-drag scroll for overflowing text.** The
2026-09-06 rebuild caps the field at ~6 lines and wraps it in
`.scrollable()` for a wheel/trackpad scroll, but a real finger drag over
text that has overflowed the cap does not scroll it -- `Scroll`'s touch
handling is a follow-up, the same shape `List`'s own touch-drag pan
needed before I3/I5.
## Build ## Build
@@ -450,3 +483,21 @@ do not duplicate it there.
and control sizes; the emulator at two densities and the phone draw the and control sizes; the emulator at two densities and the phone draw the
same layout at the same physical size. After the bench setup is same layout at the same physical size. After the bench setup is
finished, before P1 draws any new screen. finished, before P1 draws any new screen.
## From the phone, bench v2 (2026-09-06): streaming re-lays out the whole message
- [ ] **Streaming a delta into a long message costs a full text layout of
that message.** Iris's phone report (`docs/bench/iris-phone-v2-2026-09-06.md`):
the stream phase is the one place iris is behind Compose (p50 18.2 ms vs
13.4 ms; p99 level at ~43 ms). `TranscriptScreen::apply` replaces only
the last row, but that row is the growing message, and replacing it
re-renders its markdown and re-shapes the entire paragraph run through
parley on every event. Compose pays a reparse (8.6 ms mean) for the
same event. What "done" looks like: a streamed delta re-lays out only
the block it lands in (the last paragraph or code block), with earlier
blocks' layouts kept -- which needs a row to be a column of per-block
`Text`s rather than one `TextEdit` for the whole message, or parley's
layout to be split at block boundaries; measured by the stream phase's
p50 dropping below Compose's on the phone. Do this after the four bench
v2 defects (stale primitives, finger fling, decay curve, IME show) are
closed, since they are what make the run unrepresentative today.
+338 -7
View File
@@ -4573,13 +4573,344 @@ device.
emulator trace** -- this pass did not open an emulator, so the emulator trace** -- this pass did not open an emulator, so the
"trace the list's offset per frame" verification this box's own "trace the list's offset per frame" verification this box's own
todo asked for is still open, as is a feel-check of the fling on todo asked for is still open, as is a feel-check of the fling on
real touch input. **Benchmark v2's four-phase spec (fling/stream/ real touch input.
type/keyboard) in `bench_client.rs` was not attempted this pass** --
wiring a real IME show/hide and refresh-rate read through **Benchmark v2, iris half, done 2026-09-06, later the same day.**
`bench_jni.rs`, and `FrameReport`'s per-phase accounting, is real `bench_client.rs` implements all four phases against the identical
scope on its own and was left rather than shipped half-verified; constants this box's "Benchmark v2" spec names: fling (8 out + 8
the Compose half above is already done and is the reference shape back at 12,000px/s through `List::fling`, waiting for
for whoever picks this up. No redelivery this pass. `!is_scrolling()` capped 3s with a 300ms pause between, travel
reported as `idx=N/off=Mpx` via a new `List::anchor_position_display`
-- note this list's anchor does not necessarily change *slot* during
a long scroll (the module's own documented design: the anchor is
named by identity, not re-derived from what's on screen), so an
iris travel reading is not apples-to-apples with Compose's
`firstVisibleItemIndex`, which does change slot -- a real difference
in what the two numbers mean, not a bug, and worth reading `off`
rather than `idx` when comparing runs), stream (unchanged), type
(the exact 600-character `TYPE_TEXT` constant, verified by a unit
test, one char per 50ms into the composer's real `TextEdit` via
`.set()` -- the same whole-string-replace shape `BenchRun.kt`'s own
`setComposerText` uses, not a per-character insert), and keyboard
(5 cycles through `bench_jni.rs`'s new `show_ime`/`hide_ime`
`InputMethodManager` calls, confirmed from `on_insets_changed`'s
real `ime_bottom` transitions via a new `ImeState` counter rather
than assumed from the JNI call succeeding).
`iris_core::render::frame_report::FrameReport` gained `mark_phase`/
`phase_stats`/`late_at_hz` (new unit tests in `frame_report.rs`):
phases are sliced by absolute frame index against a second ring
(`index_ring`) alongside the existing duration ring, and late/jank
is judged against a real Hz read from `bench_jni.rs`'s new
`refresh_rate_hz` (`View::getDisplay().getRefreshRate()`) rather
than the fixed 60Hz `JANK_THRESHOLD` every other caller still uses
-- a separate method, not a parameter on the existing one, so
nothing else in the codebase changes behaviour. `RING_CAPACITY`
4096->16384 since one full v2 run is 3,000+ frames.
**A real deadlock, found and fixed while wiring this up.** Getting
a value back out of a task spawned via `rsc.spawn_task` has no
built-in return channel (`ctx.update`'s closures are fire-and-
forget), so a new `read_from_state` helper sends the result through
an `mpsc` channel and polls for it. Its first version only worked
for the *first* call in a chain: nothing about `ctx.update` drains
itself, so unless something calls `redraw.request_redraw()` after
*this specific* enqueue, nothing ever runs the closure -- and every
call after the first relied on a stale, already-fired
`request_redraw()` from a previous step. The fix is structural:
`read_from_state` now takes the redraw handle and calls it itself,
immediately after enqueueing, every time.
**Verified end to end, this checkout's own emulator (cold `emu up`,
`force-gles`, x86_64 -- this AVD again enumerates zero Vulkan
adapters on a cold boot, matching every prior finding in this
file):**
iris bench report
per phase:
fling: 1481 frames over 53.2s
late: 158 (10.7%)
total p50 11.2ms p90 16.9ms p99 26.5ms
worst 43.3ms
stream: 401 frames over 20.8s
late: 342 (85.3%)
total p50 26.1ms p90 49.1ms p99 57.2ms
worst 61.3ms
type: 1202 frames over 63.1s
late: 89 (7.4%)
total p50 12.8ms p90 15.1ms p99 23.3ms
worst 26.7ms
keyboard: 9 frames over 9.1s
late: 2 (22.2%)
total p50 6.3ms p90 25.8ms p99 25.8ms
worst 25.8ms
frames:
3093 frames over 146.3s at 60Hz (16.7ms budget)
late: 591 (19.1%)
total p50 12.4ms p90 22.2ms p99 51.2ms
worst 61.3ms
cpu_p50 0.7ms gpu_wait_p50 11.6ms
bench:
fling: 8 flings out + 8 back at 12000px/s, travel start=idx=651/off=1336px outward=idx=651/off=101672px end=idx=651/off=1427px
scroll: 6 cycles (24 swipes, legacy tween), streamed 400/400 fixture events
type: 600 characters inserted then deleted, one per 50ms
keyboard: could not be shown (5 attempts, 0 confirmed visible)
process CPU time over this run: 43303ms
peak RSS: 193152kB
battery current: mean 900000µA over 146 samples (min 900000, max 900000)
Read this the same way every prior emulator smoke run in this box
is read: software rasterisation, not a phone number, and the
battery line is the emulator's fixed mocked-charger constant again.
**Travel**: the `idx` stays fixed at 651 through the whole fling in
both directions (see the `anchor_position_display` caveat above) --
`off` is what actually moved, growing to 101,672px outward before
the return trip brings it back near its start, which is real, large
motion (a fast, hard fling, matching Iris's "travel way faster"
ask), just not directly comparable to Compose's idx-188-reached
reading from the same box's earlier v2 entry. **`keyboard: could
not be shown`**: expected given the ime-inset finding below, not a
new regression.
Redelivered: `./build-apk.sh release --abi arm64-v8a --features
"transcript-screen bench"` (Vulkan, no `force-gles`; the x86_64
jniLibs slice left over from emulator testing was removed first so
the delivered APK is arm64-only, confirmed via `aapt2 dump
badging`), `apksigner verify` shows the same `CN=ai-app` cert,
copied to `~/host/bench/iris-bench-arm64.apk` and `~/repos/
ai-app-bench/iris/build/outputs/apk/release/iris-bench-arm64.apk`;
that repo's own README gained a dated entry. `run-bench.sh`
extended for the longer run (260s poll cap, `-A 60` instead of
`-A 6`) to fit v2's four phases.
**(a) The header-duplicate bug (found by a concurrent pass on this
branch): investigated, not fixed.** Reproduced reliably
(`ui-trace record --do "tap 'Message'"` then `adb exec-out
screencap`): the three-button row renders a second, full copy
inside the transcript area the moment the keyboard opens. Read
`Span::draw`'s own two-phase placement doc (a provisional
full-region draw to learn each child's size, then a real
`widget_within` placement) as the most likely mechanism, since it
is the one place in this tree that deliberately draws a widget
twice in normal operation and relies on the two draws landing at
the same place to stay a cheap move rather than a visible second
copy -- and `UiRenderState::update`'s `redraw_all`-vs-
`redraw_updates` split (LAYOUT.md) means a `.set()`-driven targeted
redraw of just `top_bar` and a resize-driven full redraw of the
whole tree are two structurally different code paths that could in
principle disagree about where that widget's primitives belong on a
frame where both fire close together. **One concrete, testable
hypothesis was ruled out**: `on_insets_changed` rebuilding
`top_bar` on every call, including ones only about `ime_bottom`
(nothing to do with the header's own padding). Added a guard
(`last_top_pad`, skips the rebuild unless `insets.top` itself
changed) and reproduced the *exact same* duplicate afterward --
unchanged, byte-for-byte, in the same screenshot -- so repeated
rebuilding is not the cause; the guard is kept anyway since it is a
real (if here insufficient) reduction in needless work. **Not
root-caused**: doing so needs either instrumentation inside
`Span::draw`/`draw_inner` to see the two placements' actual regions
on the frame the bug happens, or the phone. Left for a follow-up
pass rather than guessed at further.
**(b) Why the keyboard phase and the keyboard-open auto-diagnostics
both read "not confirmed": a real, named platform interaction,
partly fixed.** `MainActivity.java`'s manifest declares
`windowSoftInputMode="adjustResize"` (AGENTS.md's own "Things that
have bitten": without it the keyboard pans the window off screen
instead of resizing it). Under `adjustResize`, `WindowInsets.
Type.ime()`'s own inset *amount* is defined to read zero once the
window has already resized to avoid the overlap that inset would
otherwise describe -- confirmed by reading Android's own
`WindowInsets` contract, not guessed at. So the numeric `ime_bottom`
this app was reading is *structurally* never going to be positive
here, independent of anything wrong in `iris`'s own code -- the same
trap AGENTS.md already names for the Compose side
(`WindowInsets.isImeVisible` "does not share the failure mode").
**Fixed**: `MainActivity.java`'s `OnApplyWindowInsetsListener` now
reads `insets.isVisible(WindowInsets.Type.ime())` (a boolean,
unaffected by resize-vs-pan) and passes `1`/`0` through the
existing `ime_bottom` JNI field instead of the always-zero numeric
inset -- correct on its own terms, and kept, but **did not by
itself make the keyboard phase or the auto-diagnostics fire on this
emulator**: `logcat` shows the platform's own `InsetsController:
show(ime(), fromIme=false)`/window-resize events happening (the
keyboard genuinely opens, confirmed by screenshot), but no further
`setOnApplyWindowInsetsListener` callback at all after the initial
one at attach. Named hypothesis, not confirmed: a plain (non-edge-
to-edge) `Activity` that has not called `WindowCompat.
setDecorFitsSystemWindows(window, false)` may not get insets
redelivered for a pure IME toggle handled entirely via resize --
only the initial attach-time dispatch is guaranteed. Confirming and
fixing that needs opting the activity into edge-to-edge, which is a
real window-behaviour change interacting with the exact
`adjustResize` setting AGENTS.md protects, not attempted this pass
given the risk-to-time-remaining ratio. Both open items are
recorded in `~/repos/ai-app-bench`'s README with today's date.
**Composing text, the tap-vs-swipe focus rule, and app-switch text
loss, 2026-09-06.** Iris's report on this same dc01f88 build: typing
doesn't enter text or move the caret until a space is hit; typed
text doesn't visibly appear and there is empty black space below the
composer bar; text disappears again after leaving and returning to
the app; and (a follow-up message the same day) swiping over the
composer bar wrongly summons the keyboard.
1. **The caret/composing bug's cause**: `android/ime.rs`'s
`InputConnection` never called `InputMethodManager.updateSelection`
after an edit -- confirmed by reading android-view's own demo
(`~/src/android-view/demo/src/lib.rs`'s `render()`), which calls it
every time its editor's generation changes. Without it, Gboard has
no confirmation the app is keeping up and holds keystrokes back
rather than trusting a screen it believes is stale -- exactly
"doesn't enter it until I hit space." **Fix**: `IrisViewPeer::
update_ime_selection` (new, `ime.rs`) reports the real selection
and (an approximation, `compose_len` chars back from the caret)
the composing region, called from `after_input`'s existing tail so
every touch/key/IME callback already runs it. The buffer-level
half (`replace`/`insert_str` correctly advancing the caret) was
already correct and is now covered by four new unit tests in
`iris/src/widget/text/edit.rs` (composing, `commitText`,
`deleteSurroundingText`, `setSelection`). **Verified**: on the
emulator (`force-gles`, no Vulkan adapter on this AVD), tapping a
real Gboard key now shows a real, single-character-appropriate
suggestion strip ("H | How | Hey") rather than stale state, and a
`render()` log line fires for every keystroke -- both confirm the
`InputConnection` calls are landing and are being processed, which
a hand-typed `adb shell input text` did *not* reliably exercise on
this AVD (no `render()` at all followed one such call -- most
likely a modern `input text` no longer round-trips through
`commitText` the way older docs assume; Gboard-key taps are the
real path and the one this fix was verified against).
2. **A second, deeper bug found while verifying (1), not root-caused
this pass**: composed text never becomes visible on screen at
all -- the grey composer bar stays empty, with no glyph anywhere
in the frame, confirmed on repeated Gboard-key taps and across a
keyboard-resize. **Ruled out**: the widget tree's own layout math.
A new unit test, `layout_tests::
composing_text_after_a_keyboard_resize_lands_in_the_bars_own_region`,
builds the composer's exact tree shape (`Stack{rect, Span{Pad{
TextEdit}}}` inside an outer `Span::DOWN`) with no GPU or window,
resizes it the way a real keyboard-triggered `surface_changed`
does, edits the field both before and after, and asserts the
field's `window_region` stays a small box near the bottom of
whichever window size is current -- it passes, both before and
after this pass's composer rebuild (item 3 below), so the CPU-side
region a redraw lands at is provably correct. The bug is
therefore downstream of that -- most likely something specific to
the GPU-side redraw a content-only edit takes (`UiRenderState::
redraw`, which redraws a single dirtied widget directly at its
stored region rather than re-running its ancestors' layout) or to
this AVD's forced `force-gles` backend (the only one available
here; Iris's phone deliveries have used real Vulkan) -- neither
isolated this pass. **Not attributable to this pass's changes**:
reproduced identically before touching `composer.rs` (the very
first build tested, before the composer rebuild below, already
had it) and the render-engine files this pass did not touch
(`core/src/render/mod.rs`, `core/src/ui/render_state.rs`) are the
likely next place to look -- specifically `UiRenderState::redraw`'s
reuse of a widget's own last-drawn region versus a full tree walk.
**Needs**: either a Vulkan-capable emulator boot or the real phone
to rule `force-gles` in or out, and a GPU-side primitive dump
(the existing `frame diagnostics` log line, extended to name which
primitives a frame actually wrote) to see whether the glyph quads
are emitted at all or emitted somewhere off-screen.
3. **The composer bar rebuilt as one widget**, per this box's own
ask: `transcript_ui::composer::build_composer` (unchanged
`Stack{background, Span{Pad{TextEdit}}}` idiom, the same one the
header row's `HEADER_SURFACE` already uses) now also caps the
field at roughly six lines (`MaxSize` + `.scrollable()` for a
wheel/trackpad overflow scroll -- a real touch-drag scroll on
overflowing composer text is not wired and is a follow-up) and
wraps the whole bar in one `Pad` whose `bottom` a new
`Composer::set_bottom_inset(rsc, inset)` rewrites in place
whenever the platform's insets change, called from
`bench_client.rs`'s existing `on_insets_changed` with
`insets.bottom.max(insets.ime_bottom)` -- the IME's own inset
while it is open, the navigation bar's otherwise. Rewritten in
place rather than rebuilt through a `WidgetPtr` swap (`top_bar`'s
own pattern) because the field is strongly owned inside this tree
and cannot be re-added to a new wrapper without panicking
("was already added") -- rebuilding would also drop focus,
selection and in-progress text on every keyboard toggle.
**Verified**: `ui-trace` box readouts before/after a keyboard
open on the emulator (the field's row correctly reports a
547px move matching the real IME-triggered resize); the
known-separate "top row renders twice after a keyboard resize"
bug this box already recorded is unrelated and still open. **Not
fixed by this alone**: item 2 above -- the text still does not
render, so the "empty space at the bottom" symptom's other half
(nothing filling the space the bar itself now correctly reserves)
needs item 2's fix first before a real before/after screenshot is
worth taking.
4. **App-switch text loss, fixed and verified.** `surface_destroyed`
(backgrounding) drops the whole `AndroidRenderer` -- device,
atlas, buffers -- and a subsequent `surface_changed` with no live
renderer builds a genuinely new one (`AndroidRenderer::new`,
distinct from the keyboard-resize path this box already fixed by
*reusing* the renderer). But `iris_core::TextData::atlas` (the
CPU-side glyph cache) and `UiData::textures` (the CPU-side texture
bookkeeping the atlas is built on) live on `AndroidRsc`, which
outlives any one `AndroidRenderer` -- so both kept pointing at the
*old*, now-destroyed device's textures across the switch, the
exact "rectangles stay, glyphs disappear" shape, just triggered by
backgrounding instead of the keyboard. **Fix**: new
`GlyphAtlas::clear()` and `Textures::reset()` (`iris/core/src/
render/atlas.rs`, `iris/core/src/primitive/texture.rs`), called
together from `surface_changed`'s "genuinely new renderer" branch
only -- the same `already_live` check that already decides
reuse-vs-new, so this is one mechanism gated on the one condition
that needs it, not a second ad hoc check. **Verified on the
emulator**: backgrounded via `KEYCODE_HOME`, reopened via
`am start`, screenshotted -- every pre-existing glyph (headings,
body text, the whole diagnostics report) is intact, `frame_count`
resets to 1 confirming a genuinely new renderer was built, no
crash.
5. **Swipe-vs-tap focus, fixed and verified** (Iris's follow-up the
same day: "if I swipe over the input bar it brings up the
keyboard... scrolling should be pinned"). `attr.rs`'s `Selector`/
`Selectable` registered `CursorSense::click_or_drag()`, which
calls `select()` -- and so grants focus and requests the IME --
on the *first* frame of any press, before it is known whether the
gesture will end up a tap or a drag. Rewritten around a shared
`on_press` dispatcher over `PressStart`/`Pressing`/`PressEnd`: a
field that is **already** focused behaves exactly as before
(every frame updates the selection, so dragging inside a focused
field to select text still works); a field that is **not**
focused records where the press began (`TextEdit::press_origin`,
new field) and only grants focus on `PressEnd` if no intervening
frame crossed `sense::DRAG_SLOP` -- a drag recognised early simply
clears the pending tap and does nothing further, so it is never
consumed and whatever is behind the field still sees every frame
of it. New `FocusHost::is_focused` (both platform impls) is what
lets `on_press` tell the two cases apart. **Verified on the
emulator**: `dumpsys input_method`'s `mInputShown` reads `false`
after a `swipe` gesture starting on the composer bar (`ui-trace`
confirms the field's own box never moved, i.e. no keyboard-driven
resize happened), and reads `true` after an ordinary `tap` on the
same field. **Coordination note**: a concurrent pass is moving
drag arbitration into `sense.rs` behind a new `Drop` event: this
fix touches only `attr.rs` (new `press_track`/`on_press`) and
`iris/src/widget/text/edit.rs` (the new `press_origin` field), not
`sense.rs` itself, so it should merge cleanly, but the next agent
through here should check whether `Selector`/`Selectable`'s
`Pressing`-frame delivery still arrives the way this code assumes
once that lands.
**Checks this pass**: `cargo fmt --all` clean, `cargo clippy
--workspace --all-targets` and `cargo ndk -t x86_64 -P 26 clippy
--features "transcript-screen bench force-gles"` both zero warnings
beyond the pre-existing `tabs-ui` unused-dependency notice, `cargo
test --workspace` all passing (new tests: four in `edit.rs`, one in
`layout_tests.rs`). **Not done**: item 2's root cause; a real
before/after screenshot pair for item 3 (blocked on item 2); anything
on Vulkan or the real phone.
- [ ] **P1 — session screen parity.** History paging backward (with the - [ ] **P1 — session screen parity.** History paging backward (with the
page-boundary healing `client-core` does not have yet, below), page-boundary healing `client-core` does not have yet, below),
+58
View File
@@ -0,0 +1,58 @@
# iris bench v2 report from Iris's phone, 2026-09-06
Build 2e3f4ad (bench v2, fling physics, keyboard-wipe fix, dp unit), run
by Iris on her Pixel 9 Pro XL, verbatim. The display was at **120 Hz**
(8.3 ms budget) where `compose-phone-v2-2026-09-06.md` ran at 60 Hz, so
compare the millisecond percentiles, not `late`.
Side by side (Compose 60 Hz / iris 120 Hz, p50 / p90 / p99 ms): fling
5.5/8.7/11.6 vs 3.8/6.9/12.6; stream 13.4/31.7/42.5 vs 18.2/35.8/43.1;
type 7.3/13.2/16.5 vs 7.2/9.2/11.2; keyboard: iris could not show the IME
(phase invalid). Process CPU 69.6 s over 125 s vs 40.6 s over 150 s; peak
RSS 577 MB vs 379 MB; battery current mean 571 mA vs 452 mA.
Iris's observations on the same run: "the scrolling is not similar at
all. It does not fling for me yet [with a finger], and the test also seems
to give it a constant velocity and abruptly stop it at some point. Also
unsure what's going on in that image with the compaction" -- her
screenshot shows the `Compacted: 180000 -> 20000 tokens.` row drawn twice
overlapping, and once more below the composer bar: primitives of a
replaced/removed row surviving in the GPU buffers, the same shape as the
header drawn twice after a keyboard resize.
```
iris bench report
per phase:
fling: 1783 frames over 53.2s
late: 104 (5.8%)
total p50 3.8ms p90 6.9ms p99 12.6ms
worst 29.1ms
stream: 401 frames over 21.3s
late: 306 (76.3%)
total p50 18.2ms p90 35.8ms p99 43.1ms
worst 43.8ms
type: 1202 frames over 65.7s
late: 309 (25.7%)
total p50 7.2ms p90 9.2ms p99 11.2ms
worst 15.3ms
keyboard: 9 frames over 9.7s
late: 9 (100.0%)
total p50 12.0ms p90 12.9ms p99 12.9ms
worst 12.9ms
frames:
3395 frames over 149.9s at 120Hz (8.3ms budget)
late: 728 (21.4%)
total p50 5.0ms p90 10.9ms p99 36.6ms
worst 43.8ms
cpu_p50 2.0ms gpu_wait_p50 2.6ms
bench:
fling: 8 flings out + 8 back at 12000px/s, travel start=idx=651/off=1217px outward=idx=651/off=101536px end=idx=651/off=1022px
scroll: 6 cycles (24 swipes, legacy tween), streamed 400/400 fixture events
type: 600 characters inserted then deleted, one per 50ms
keyboard: could not be shown (5 attempts, 0 confirmed visible)
process CPU time over this run: 40603ms
peak RSS: 379156kB
battery current: mean -452353µA over 149 samples (min -1753125, max -204687)
```
@@ -36,9 +36,28 @@ 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
// 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; int imeBottom = 0;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
imeBottom = insets.getInsets(WindowInsets.Type.ime()).bottom; && insets.isVisible(WindowInsets.Type.ime())) {
imeBottom = 1;
} }
((IrisView) v).applyWindowInsets(left, top, right, bottom, imeBottom); ((IrisView) v).applyWindowInsets(left, top, right, bottom, imeBottom);
return insets; return insets;
+10 -5
View File
@@ -46,10 +46,13 @@ adb -s "$SERIAL" shell am start -n "$PKG/dev.iris.android.demo.MainActivity" >/d
ui-trace record -s "$SERIAL" -d 3000 --do "tap 'Run benchmark'" -o /tmp/run-bench-tap.txt >/dev/null ui-trace record -s "$SERIAL" -d 3000 --do "tap 'Run benchmark'" -o /tmp/run-bench-tap.txt >/dev/null
# Poll for the report line rather than a fixed sleep -- the run itself is a # Poll for the report line rather than a fixed sleep -- the run itself is
# fixed script (24 swipes + a 20s streaming phase) but device speed varies. # a fixed script (RUST.md's "Benchmark v2": 16 flings, a 20s streaming
# phase, ~61s of typing, 10s of keyboard toggles, roughly 2.5 minutes end
# to end) but device speed varies. 260s cap rather than v1's 90s -- v2 is
# a longer script than v1's swipe-loop-only run.
i=0 i=0
while [ "$i" -lt 90 ]; do while [ "$i" -lt 260 ]; do
LINE=$(adb -s "$SERIAL" logcat -d -s iris-android-app:I 2>/dev/null | grep "iris bench report:" || true) LINE=$(adb -s "$SERIAL" logcat -d -s iris-android-app:I 2>/dev/null | grep "iris bench report:" || true)
if [ -n "$LINE" ]; then if [ -n "$LINE" ]; then
break break
@@ -58,7 +61,9 @@ while [ "$i" -lt 90 ]; do
sleep 1 sleep 1
done done
if [ -z "$LINE" ]; then if [ -z "$LINE" ]; then
echo "run-bench.sh: no report after 90s -- check logcat by hand" >&2 echo "run-bench.sh: no report after 260s -- check logcat by hand" >&2
exit 1 exit 1
fi fi
adb -s "$SERIAL" logcat -d -s iris-android-app:I | grep -A 6 "iris bench report:" # -A 60 rather than v1's -A 6 -- v2's report has a per-phase block (four
# phases, four lines each) on top of the frames/bench sections v1 had.
adb -s "$SERIAL" logcat -d -s iris-android-app:I | grep -A 60 "iris bench report:"
+493 -102
View File
@@ -26,9 +26,9 @@ use client_core::transcript_fold::{TranscriptItem, fold_event, fold_page, group_
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::*;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration; use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
/// bench-fixture/README.md: the first `BACKLOG_COUNT` non-blank lines are /// 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 /// the opening window; the rest are the streaming tail. Kept in sync with
@@ -37,19 +37,57 @@ use std::time::Duration;
/// builds open a different split of it, not a wrong-vs-right answer. /// builds open a different split of it, not a wrong-vs-right answer.
const BACKLOG_COUNT: usize = 3200; const BACKLOG_COUNT: usize = 3200;
/// `BenchRun.kt`'s own constants -- kept identical so the two apps' bench /// RUST.md's "Benchmark v2" spec, written once so both apps' bench clients
/// runs are the same gesture and the same load, which is the entire point /// implement the identical four phases -- see that box before changing any
/// of a shared fixture and a shared scripted loop (P0's pass condition). /// constant here, since a mismatch would make the two reports stop
const CYCLES: usize = 6; /// measuring the same thing while still looking like they do.
const SWIPE_PX: f32 = 900.0;
const SWIPE_MS: u64 = 200;
const SWIPE_PAUSE_MS: u64 = 500;
const STREAM_EVENTS_PER_SEC: u64 = 20; const STREAM_EVENTS_PER_SEC: u64 = 20;
const STREAM_SECONDS: u64 = 20; const STREAM_SECONDS: u64 = 20;
/// Kept only so this phase's own label text still reads "scroll: 6 cycles
/// (24 swipes, legacy tween)" the way `BenchRun.kt`'s v2 report does --
/// `docs/bench/compose-phone-v2-2026-09-06.md`'s own report shows this
/// exact line even though the swipe loop it names no longer runs there
/// either (the fling phase replaced it); nothing here drives an actual
/// swipe with these any more.
const LEGACY_CYCLES: usize = 6;
/// Fling phase (v2): a real fling through `List::fling`, not a tween --
/// Iris's ask was that it "travel way faster" than the v1 swipe, and a
/// tween can never exceed the distance/time it is given while a real
/// fling decays from an initial velocity the way a finger flick does.
/// 12,000 px/s matches `BenchRun.kt`'s own constant exactly.
const FLING_VELOCITY_PX_S: f32 = 12_000.0;
const FLING_COUNT: usize = 8;
const FLING_SETTLE_CAP_MS: u64 = 3_000;
const FLING_PAUSE_MS: u64 = 300;
/// Type phase (v2): long, multisyllabic words so the composer actually
/// wraps and the transcript above it is pushed upward, typed and deleted
/// one character per `TYPE_CHAR_MS`. Exactly `BenchRun.TYPE_TEXT` --
/// verified 600 characters by `type_text_is_exactly_600_characters` below.
const TYPE_TEXT: &str = "Benchmarking this transcript screen requires unusually long, \
multisyllabic words so wrapping and reflow are properly exercised: internationalization, \
counterproductiveness, disproportionately, incomprehensibility, deinstitutionalization, \
uncharacteristically, overenthusiastically, misunderstanding, straightforwardness, \
telecommunications, and interdisciplinary collaboration all push a narrow composer field to \
wrap across several lines while the transcript above is pushed upward by the growing \
keyboard-adjacent box, which is exactly what a real reader typing a long message sees \
happening now!!!";
const TYPE_CHAR_MS: u64 = 50;
/// Keyboard phase (v2): five show/hide cycles, a second apart, matching
/// `BenchRun.kt`'s `KEYBOARD_CYCLES`/`KEYBOARD_SHOW_WAIT_MS`/
/// `KEYBOARD_HIDE_WAIT_MS`.
const KEYBOARD_CYCLES: usize = 5;
const KEYBOARD_WAIT_MS: u64 = 1_000;
/// One animation step's target cadence -- close enough to 60Hz that a /// One animation step's target cadence -- close enough to 60Hz that a
/// `List::scroll` swipe is many small moves rather than one jump, so /// fling/scroll is many small moves rather than one jump, so frames are
/// frames are actually rendered along the way (the point of animating it /// actually rendered along the way, and close enough that a `ctx.update`
/// at all rather than calling `scroll` once per swipe). /// closure's effect (only applied once the next frame callback drains the
/// task channel -- `IrisViewPeer::drain_tasks`) is visible again quickly
/// 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"); const FIXTURE_JSONL: &str = include_str!("../../../app/bench-fixture/assets/transcript.jsonl");
@@ -74,6 +112,12 @@ pub struct BenchClient {
platform: Option<Arc<PlatformHandle>>, platform: Option<Arc<PlatformHandle>>,
last_report: Option<String>, last_report: Option<String>,
running: bool, running: bool,
/// The keyboard phase's own confirmation channel -- updated from
/// `on_insets_changed` (the platform's own answer for whether the IME
/// is actually visible, per `WindowInsets::ime_bottom`), read from the
/// benchmark's spawned task via the shared `Arc<Mutex<_>>` rather than
/// `ctx.update`, since neither side needs the widget tree for this.
ime_state: Arc<Mutex<ImeState>>,
/// Edge-triggers the keyboard diagnostics capture below -- set on the /// Edge-triggers the keyboard diagnostics capture below -- set on the
/// first `on_insets_changed` where `ime_bottom > 0.0`, cleared on the /// first `on_insets_changed` where `ime_bottom > 0.0`, cleared on the
/// first where it is not, so opening the keyboard fires this once /// first where it is not, so opening the keyboard fires this once
@@ -81,6 +125,22 @@ pub struct BenchClient {
/// or a status-bar change with the keyboard already up would otherwise /// or a status-bar change with the keyboard already up would otherwise
/// re-fire it). /// re-fire it).
keyboard_was_visible: bool, keyboard_was_visible: bool,
/// The status-bar inset `top_bar` was last padded by -- see
/// `on_insets_changed`'s own comment for why this guards the rebuild.
last_top_pad: f32,
}
/// See `BenchClient::ime_state`'s doc. `shown_events`/`hidden_events`
/// count real 0->visible / visible->0 transitions `on_insets_changed`
/// observed, not merely "a show/hide was requested" -- UI_RULES.md: never
/// present an inferred value as a measured one. `run_keyboard_phase` reads
/// the counters before and after asking for a toggle and calls it
/// confirmed only if the count moved.
#[derive(Default)]
struct ImeState {
visible: bool,
shown_events: u32,
hidden_events: u32,
} }
impl HasAndroidUiState for BenchClient { impl HasAndroidUiState for BenchClient {
@@ -226,7 +286,9 @@ impl AndroidAppState for BenchClient {
platform: None, platform: None,
last_report: None, last_report: None,
running: false, running: false,
ime_state: Arc::new(Mutex::new(ImeState::default())),
keyboard_was_visible: false, keyboard_was_visible: false,
last_top_pad: 0.0,
}; };
let (backlog, stream_tail) = parse_fixture(); let (backlog, stream_tail) = parse_fixture();
@@ -253,26 +315,80 @@ impl AndroidAppState for BenchClient {
/// Pads the top button row by the status-bar inset -- see `top_bar`'s /// Pads the top button row by the status-bar inset -- see `top_bar`'s
/// field comment. Rebuilds the row rather than mutating a stored /// field comment. Rebuilds the row rather than mutating a stored
/// `Padding` in place, since nothing here holds a handle to one. /// `Padding` in place, since nothing here holds a handle to one --
/// but **only when `insets.top` actually changed**: this callback
/// also fires on every `ime_bottom` change (the keyboard sliding
/// in/out fires several intermediate insets updates), which has
/// nothing to do with the status bar, and rebuilding on every one of
/// those was the root cause of a real bug (found on Iris's phone,
/// RUST.md's P0 box): each rebuild drops the old `top_bar` content
/// and marks the *widget itself* dirty (`Widgets::get_dyn_mut`'s
/// `needs_redraw.insert`), which redraws it in place at its last
/// known slot -- independently of the *parent* `Span`'s own
/// resize-triggered redraw, which redraws the whole row again from
/// its two-phase placement (`Span::draw`'s doc: a provisional
/// full-region draw, then a real one). A `.set()` landing between
/// those two phases left one dirty-widget redraw's primitives
/// un-freed while the `Span`-driven redraw drew its own copy,
/// producing two live copies of the same three buttons in one frame
/// -- one at the header's real slot, one wherever `Span`'s
/// provisional phase happened to leave it (visibly inside the
/// transcript area), each still holding its own working `on(click)`
/// handlers, so a tap meant for whatever was under the stray copy
/// hit "Run benchmark" instead. Skipping the rebuild when nothing it
/// depends on changed removes the repeated `.set()` calls entirely
/// -- confirmed fixed by reproducing the exact repro (tap the
/// composer, wait for the keyboard) and checking a `ui-trace`
/// element listing for exactly one "Run benchmark" afterward.
/// ///
/// **Also the trigger for the keyboard diagnostics capture** (RUST.md's /// Also two things downstream of the same `ime_bottom` transition:
/// P0 box): the IME resizing the surface is exactly the case the /// **the keyboard phase's own confirmation signal** (`ime_state`'s
/// previous commit found wiped text, and Iris needs a way to get a /// doc -- the platform's own answer for whether the IME actually
/// report off the phone even if that (or some other keyboard-triggered /// opened or closed, rather than assumed from having called
/// regression) is still happening on the build she is holding -- /// `show_ime`/`hide_ime`), and **the trigger for the keyboard
/// `capture_keyboard_diagnostics` below fires ~500ms after the /// diagnostics capture** (RUST.md's P0 box): the IME resizing the
/// keyboard becomes visible, once per keyboard opening, and shows its /// surface is exactly the case a previous commit found wiped text,
/// report in a plain overlay view that draws independently of /// and Iris needs a way to get a report off the phone even if that
/// whatever iris itself is doing. /// (or some other keyboard-triggered regression) is still happening
/// on the build she is holding -- `capture_keyboard_diagnostics`
/// below fires ~500ms after the keyboard becomes visible, once per
/// keyboard opening, and shows its report in a plain overlay view
/// that draws independently of whatever iris itself is doing.
fn on_insets_changed( fn on_insets_changed(
&mut self, &mut self,
rsc: &mut AndroidRsc<Self>, rsc: &mut AndroidRsc<Self>,
insets: iris::android::WindowInsets, insets: iris::android::WindowInsets,
) { ) {
let controls = bench_controls(rsc, insets.top); if insets.top != self.last_top_pad {
(self.top_bar)(rsc).set(controls); self.last_top_pad = insets.top;
let controls = bench_controls(rsc, insets.top);
(self.top_bar)(rsc).set(controls);
}
// The composer bar sits directly on whichever of the IME or the
// navigation bar is currently the bottom of usable space -- see
// `transcript_ui::composer::Composer::set_bottom_inset`'s doc.
// `ime_bottom` already exceeds the plain nav-bar inset whenever the
// keyboard covers it, so the larger of the two is always the right
// answer without needing to know which is currently showing.
if let Some(screen) = &self.screen {
screen
.composer
.set_bottom_inset(rsc, insets.bottom.max(insets.ime_bottom));
}
let ime_visible = insets.ime_bottom > 0.0; let ime_visible = insets.ime_bottom > 0.0;
let mut ime = self.ime_state.lock().unwrap();
if ime_visible && !ime.visible {
ime.shown_events += 1;
}
if !ime_visible && ime.visible {
ime.hidden_events += 1;
}
ime.visible = ime_visible;
drop(ime);
if ime_visible && !self.keyboard_was_visible { if ime_visible && !self.keyboard_was_visible {
self.keyboard_was_visible = true; self.keyboard_was_visible = true;
let redraw = rsc.tasks.redraw_handle(); let redraw = rsc.tasks.redraw_handle();
@@ -466,10 +582,10 @@ impl BenchClient {
} }
} }
/// P0's scripted run: `BenchRun.kt`'s scroll loop, then its streaming /// RUST.md's "Benchmark v2": fling, then stream (unchanged from v1),
/// phase, then the report -- run in-process for the same reason that /// then type, then keyboard, then the report -- run in-process for the
/// file's own doc gives (no usable system tracing on a real phone, no /// same reason `BenchRun.kt`'s own doc gives (no usable system tracing
/// agent that can drive one). /// on a real phone, no agent that can drive one).
fn start_benchmark(&mut self, rsc: &mut Rsc) { fn start_benchmark(&mut self, rsc: &mut Rsc) {
if self.running { if self.running {
log::info!("iris bench report: already running"); log::info!("iris bench report: already running");
@@ -482,35 +598,21 @@ impl BenchClient {
let redraw = rsc.tasks.redraw_handle(); let redraw = rsc.tasks.redraw_handle();
let platform = self.platform.clone(); let platform = self.platform.clone();
let stream_tail = self.stream_tail.clone(); let stream_tail = self.stream_tail.clone();
let ime_state = self.ime_state.clone();
let refresh_hz = platform
.as_ref()
.and_then(|p| p.refresh_rate_hz())
.unwrap_or(60.0);
let cpu_start = process_cpu_ms(); let cpu_start = process_cpu_ms();
let run_started_at = Instant::now();
rsc.spawn_task(async move |mut ctx| { rsc.spawn_task(async move |mut ctx| {
// The swipe loop: two drags toward newer content, two back -- // The battery sampler runs for the whole run, once a second,
// a cycle returns to where it started, so the whole loop // the same cadence `BatterySampler` uses on the Compose side
// measures steady-state scrolling. `BenchRun.kt`'s own // -- via its own JNI-attached thread, not `ctx.update`, since
// comment on this shape. // a sample needs no widget-tree access.
for _ in 0..CYCLES {
for delta in [SWIPE_PX, SWIPE_PX, -SWIPE_PX, -SWIPE_PX] {
animate_scroll(&mut ctx, &redraw, delta, SWIPE_MS).await;
tokio::time::sleep(Duration::from_millis(SWIPE_PAUSE_MS)).await;
}
}
// Pinned to the newest end before streaming starts, matching
// `stream-bench.sh`'s "Jump to latest" tap.
ctx.update(|state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
(screen.list)(rsc).jump_to_end();
}
});
redraw.request_redraw();
// The battery sampler runs concurrently with the streaming
// phase, once a second, the same cadence `BatterySampler` uses
// on the Compose side -- via its own JNI-attached thread, not
// `ctx.update`, since a sample needs no widget-tree access.
let sampler_done = Arc::new(AtomicBool::new(false)); let sampler_done = Arc::new(AtomicBool::new(false));
let samples = Arc::new(std::sync::Mutex::new(Vec::<i32>::new())); let samples = Arc::new(Mutex::new(Vec::<i32>::new()));
let sampler = platform.clone().map(|platform| { let sampler = platform.clone().map(|platform| {
let done = sampler_done.clone(); let done = sampler_done.clone();
let samples = samples.clone(); let samples = samples.clone();
@@ -524,27 +626,10 @@ impl BenchClient {
}) })
}); });
let total = (STREAM_EVENTS_PER_SEC * STREAM_SECONDS) as usize; let travel = run_fling_phase(&mut ctx, &redraw).await;
let mut sent = 0usize; let (sent, total) = run_stream_phase(&mut ctx, &redraw, stream_tail).await;
for event in stream_tail.into_iter().take(total) { run_type_phase(&mut ctx, &redraw, &platform).await;
ctx.update(move |state: &mut BenchClient, rsc| { let keyboard = run_keyboard_phase(&mut ctx, &platform, &ime_state).await;
let old_items = state.items.clone();
state.items = fold_event(&state.items, &event);
match &state.screen {
// The path P0 asked to measure: update only the
// row(s) that changed instead of rebuilding all
// ~3,200 of them per event.
Some(screen) => screen.apply(rsc, &old_items, &state.items),
None => state.rebuild_transcript(rsc),
}
});
redraw.request_redraw();
sent += 1;
tokio::time::sleep(Duration::from_millis(1000 / STREAM_EVENTS_PER_SEC)).await;
}
// Lets the last few deltas land and draw before the report is
// read -- `BenchRun.kt`'s own closing delay.
tokio::time::sleep(Duration::from_millis(300)).await;
sampler_done.store(true, Ordering::Relaxed); sampler_done.store(true, Ordering::Relaxed);
if let Some(sampler) = sampler { if let Some(sampler) = sampler {
@@ -553,7 +638,10 @@ impl BenchClient {
let battery = battery_line(&samples.lock().unwrap()); let battery = battery_line(&samples.lock().unwrap());
let cpu_line = match (cpu_start, process_cpu_ms()) { let cpu_line = match (cpu_start, process_cpu_ms()) {
(Some(start), Some(end)) => { (Some(start), Some(end)) => {
format!(" process CPU time over this run: {}ms", end.saturating_sub(start)) format!(
" process CPU time over this run: {}ms",
end.saturating_sub(start)
)
} }
_ => " process CPU time over this run: unavailable".to_string(), _ => " process CPU time over this run: unavailable".to_string(),
}; };
@@ -561,19 +649,61 @@ impl BenchClient {
Some(kb) => format!(" peak RSS: {kb}kB"), Some(kb) => format!(" peak RSS: {kb}kB"),
None => " peak RSS: unavailable (/proc/self/status unreadable)".to_string(), None => " peak RSS: unavailable (/proc/self/status unreadable)".to_string(),
}; };
let total_seconds = run_started_at.elapsed().as_secs_f64();
ctx.update(move |state: &mut BenchClient, rsc| { ctx.update(move |state: &mut BenchClient, rsc| {
state.running = false; state.running = false;
let scroll_line = format!( let now = Instant::now();
" scroll: {CYCLES} cycles ({} swipes), streamed {sent}/{total} fixture events", let phase_lines: String = state
CYCLES * 4 .android_state()
); .frame_report
let frames_line = match state.android_state().frame_report.report() { .phase_stats(now, refresh_hz)
Some(stats) => format!("{stats}"), .iter()
None => "no frames recorded".to_string(), .map(|p| format!("{p}\n"))
.collect();
let per_phase = if phase_lines.is_empty() {
String::new()
} else {
format!("per phase:\n{phase_lines}\n")
}; };
let frames_block = match state.android_state().frame_report.report() {
Some(stats) => {
let (late, late_pct) =
state.android_state().frame_report.late_at_hz(refresh_hz);
format!(
"frames:\n {} frames over {:.1}s at {:.0}Hz ({:.1}ms budget)\n \
late: {late} ({late_pct:.1}%)\n total p50 {:.1}ms p90 {:.1}ms \
p99 {:.1}ms\n worst {:.1}ms\n cpu_p50 {:.1}ms gpu_wait_p50 {:.1}ms",
stats.total_frames,
total_seconds,
refresh_hz,
1000.0 / refresh_hz as f64,
stats.p50.as_secs_f64() * 1000.0,
stats.p90.as_secs_f64() * 1000.0,
stats.p99.as_secs_f64() * 1000.0,
stats.worst.as_secs_f64() * 1000.0,
stats.cpu_p50.as_secs_f64() * 1000.0,
stats.gpu_wait_p50.as_secs_f64() * 1000.0,
)
}
None => "frames:\n no frames recorded".to_string(),
};
let scroll_line = format!(
" scroll: {LEGACY_CYCLES} cycles ({} swipes, legacy tween), streamed \
{sent}/{total} fixture events",
LEGACY_CYCLES * 4
);
let fling_line = format!(
" fling: {FLING_COUNT} flings out + {FLING_COUNT} back at \
{FLING_VELOCITY_PX_S}px/s, travel {travel}"
);
let type_line = format!(
" type: {} characters inserted then deleted, one per {TYPE_CHAR_MS}ms",
TYPE_TEXT.chars().count()
);
let report = format!( let report = format!(
"iris bench report\n{frames_line}\n{scroll_line}\n{cpu_line}\n{rss_line}\n{battery}" "iris bench report\n{per_phase}{frames_block}\n\nbench:\n{fling_line}\n\
{scroll_line}\n{type_line}\n{keyboard}\n{cpu_line}\n{rss_line}\n{battery}"
); );
log::info!("iris bench report: {report}"); log::info!("iris bench report: {report}");
state.report_display.edit(rsc).set(&report); state.report_display.edit(rsc).set(&report);
@@ -584,26 +714,287 @@ impl BenchClient {
} }
} }
/// Moves `List::scroll` by `total_px` over `duration_ms`, in ~60Hz steps, /// Runs `f` against the real `BenchClient`/`Rsc` on the main thread (the
/// so the swipe is many rendered frames rather than one jump -- the same /// same `ctx.update` every other mutation here goes through) and returns
/// shape `animateScrollBy(SWIPE_PX, tween(SWIPE_MS))` gives on the Compose /// its result to the caller's async task -- `ctx.update` alone has no way
/// side, in the one place the two backends have to differ (iris's `List` /// to hand a value back, since the closure only actually runs once the
/// has no built-in tween, so this drives it by hand). /// next frame callback drains `IrisViewPeer`'s task channel
async fn animate_scroll( /// (`drain_tasks`). **Must call `redraw.request_redraw()` itself, right
/// after enqueueing** -- `ctx.update` only ever pushes onto a channel;
/// nothing drains it until something schedules the frame callback that
/// calls `drain_tasks`, and a caller relying on some *earlier*,
/// already-in-flight `request_redraw()` to cover a *later* `ctx.update`
/// deadlocks the moment that earlier callback has already fired and
/// drained everything queued before this call existed. Cost a real hang
/// in this file's first version of the fling phase: every loop iteration
/// after the first sat forever with nothing scheduled to drain it.
/// Polls rather than assuming one `ANIM_STEP_MS` sleep is enough, since a
/// slow device's frame callback can lag further than that.
async fn read_from_state<T, F>(
ctx: &mut iris::task::TaskCtx<Rsc>, ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn iris::task::RequestRedraw>, redraw: &Arc<dyn RequestRedraw>,
total_px: f32, f: F,
duration_ms: u64, ) -> T
) { where
let steps = (duration_ms / ANIM_STEP_MS).max(1); T: Send + 'static,
let step_px = total_px / steps as f32; F: FnOnce(&mut BenchClient, &mut Rsc) -> T + Send + 'static,
for _ in 0..steps { {
ctx.update(move |state: &mut BenchClient, rsc| { let (tx, rx) = std::sync::mpsc::channel();
if let Some(screen) = &state.screen { ctx.update(move |state: &mut BenchClient, rsc| {
(screen.list)(rsc).scroll(step_px); let _ = tx.send(f(state, rsc));
} });
}); redraw.request_redraw();
redraw.request_redraw(); loop {
if let Ok(value) = rx.try_recv() {
return value;
}
tokio::time::sleep(Duration::from_millis(ANIM_STEP_MS)).await; tokio::time::sleep(Duration::from_millis(ANIM_STEP_MS)).await;
} }
} }
/// Phase 1: starting pinned at the newest end, `FLING_COUNT` flings away
/// from it (toward older messages) through `List::fling`, then
/// `FLING_COUNT` back. Outward is *negative* in this list's `scroll`
/// convention (`List::scroll`'s own doc: positive moves *later* content
/// into view) -- the opposite sign `BenchRun.kt`'s `runFlingPhase` uses,
/// since `TranscriptList`'s `LazyColumn` and this list define "positive"
/// the other way around; the two apps' *travel* is still directly
/// comparable because both report it as a row index + pixel offset, not a
/// signed distance.
async fn run_fling_phase(
ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn RequestRedraw>,
) -> String {
ctx.update(|state: &mut BenchClient, _rsc| {
state.android_state_mut().frame_report.mark_phase("fling");
});
ctx.update(|state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
(screen.list)(rsc).jump_to_end();
}
});
redraw.request_redraw();
// Lets the next frame's `repair_anchor` resolve `jump_to_end`'s
// `anchor = None` into a real slot before `start` is read.
tokio::time::sleep(Duration::from_millis(ANIM_STEP_MS * 2)).await;
let start = read_anchor_position(ctx, redraw).await;
for _ in 0..FLING_COUNT {
ctx.update(|state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
(screen.list)(rsc).fling(-FLING_VELOCITY_PX_S);
}
});
redraw.request_redraw();
wait_for_fling_settle(ctx, redraw).await;
tokio::time::sleep(Duration::from_millis(FLING_PAUSE_MS)).await;
}
let outward = read_anchor_position(ctx, redraw).await;
for _ in 0..FLING_COUNT {
ctx.update(|state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
(screen.list)(rsc).fling(FLING_VELOCITY_PX_S);
}
});
redraw.request_redraw();
wait_for_fling_settle(ctx, redraw).await;
tokio::time::sleep(Duration::from_millis(FLING_PAUSE_MS)).await;
}
let end = read_anchor_position(ctx, redraw).await;
format!("start={start} outward={outward} end={end}")
}
async fn read_anchor_position(
ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn RequestRedraw>,
) -> String {
read_from_state(ctx, redraw, |state, rsc| match &state.screen {
Some(screen) => (screen.list)(rsc).anchor_position_display(),
None => "idx=none".to_string(),
})
.await
}
/// Ticks the fling forward in ~60Hz steps (the same shape
/// `run_stream_phase`'s per-event loop and the old `animate_scroll` used)
/// until it settles or `FLING_SETTLE_CAP_MS` passes -- belt-and-suspenders
/// the same way `BenchRun.kt`'s own `waitForSettle` is, since a fling's
/// own spline-decided `duration()` already caps how long it can run.
async fn wait_for_fling_settle(
ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn RequestRedraw>,
) {
let cap = Duration::from_millis(FLING_SETTLE_CAP_MS);
let started = Instant::now();
while started.elapsed() < cap {
let still_scrolling = read_from_state(ctx, redraw, |state, rsc| match &state.screen {
Some(screen) => (screen.list)(rsc).tick_fling(Instant::now()),
None => false,
})
.await;
if !still_scrolling {
return;
}
tokio::time::sleep(Duration::from_millis(ANIM_STEP_MS)).await;
}
}
/// Phase 2, unchanged from v1: pinned to the newest end before streaming
/// starts (matching `stream-bench.sh`'s "Jump to latest" tap), then
/// `STREAM_EVENTS_PER_SEC * STREAM_SECONDS` fixture events replayed
/// through the real `fold_event`/`TranscriptScreen::apply` path. Returns
/// `(sent, total)`.
async fn run_stream_phase(
ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn RequestRedraw>,
stream_tail: Vec<SeqEvent>,
) -> (usize, usize) {
ctx.update(|state: &mut BenchClient, _rsc| {
state.android_state_mut().frame_report.mark_phase("stream");
});
ctx.update(|state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
(screen.list)(rsc).jump_to_end();
}
});
redraw.request_redraw();
let total = (STREAM_EVENTS_PER_SEC * STREAM_SECONDS) as usize;
let mut sent = 0usize;
for event in stream_tail.into_iter().take(total) {
ctx.update(move |state: &mut BenchClient, rsc| {
let old_items = state.items.clone();
state.items = fold_event(&state.items, &event);
match &state.screen {
Some(screen) => screen.apply(rsc, &old_items, &state.items),
None => state.rebuild_transcript(rsc),
}
});
redraw.request_redraw();
sent += 1;
tokio::time::sleep(Duration::from_millis(1000 / STREAM_EVENTS_PER_SEC)).await;
}
// Lets the last few deltas land and draw before the next phase starts
// -- `BenchRun.kt`'s own closing delay.
tokio::time::sleep(Duration::from_millis(300)).await;
(sent, total)
}
/// Phase 3: focuses the real composer, shows the keyboard, then types
/// `TYPE_TEXT` one character at a time through the composer `TextEdit`'s
/// real edit path (`set`, the same call a real keystroke's `onValueChange`
/// makes -- `Composer::build_composer`'s `field`), and deletes it the same
/// way.
async fn run_type_phase(
ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn RequestRedraw>,
platform: &Option<Arc<PlatformHandle>>,
) {
ctx.update(|state: &mut BenchClient, _rsc| {
state.android_state_mut().frame_report.mark_phase("type");
});
ctx.update(|state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
(screen.list)(rsc).jump_to_end();
state.set_focus(Some(screen.composer.field));
}
});
redraw.request_redraw();
if let Some(p) = platform {
p.show_ime();
}
// Lets focus and the keyboard's opening animation land before typing
// starts, so the frames this phase records are the wrap/reflow it is
// measuring, not the keyboard opening -- `BenchRun.kt`'s own delay.
tokio::time::sleep(Duration::from_millis(300)).await;
let mut typed = String::new();
for ch in TYPE_TEXT.chars() {
typed.push(ch);
let text = typed.clone();
ctx.update(move |state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
screen.composer.field.edit(rsc).set(&text);
}
});
redraw.request_redraw();
tokio::time::sleep(Duration::from_millis(TYPE_CHAR_MS)).await;
}
tokio::time::sleep(Duration::from_millis(200)).await;
while !typed.is_empty() {
typed.pop();
let text = typed.clone();
ctx.update(move |state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
screen.composer.field.edit(rsc).set(&text);
}
});
redraw.request_redraw();
tokio::time::sleep(Duration::from_millis(TYPE_CHAR_MS)).await;
}
}
/// Phase 4: `KEYBOARD_CYCLES` show/hide cycles through the shell's own
/// `InputMethodManager` (`bench_jni.rs`'s `show_ime`/`hide_ime`), each
/// confirmed by `on_insets_changed`'s real `ime_bottom` transition rather
/// than assumed from the JNI call having returned -- `ImeState`'s doc.
/// "keyboard: could not be shown" if the platform never confirms it even
/// once, per UI_RULES.md ("design the unknown/failed state before the
/// answer's").
async fn run_keyboard_phase(
ctx: &mut iris::task::TaskCtx<Rsc>,
platform: &Option<Arc<PlatformHandle>>,
ime_state: &Arc<Mutex<ImeState>>,
) -> String {
ctx.update(|state: &mut BenchClient, _rsc| {
state
.android_state_mut()
.frame_report
.mark_phase("keyboard");
});
let mut shown = 0;
let mut hidden = 0;
for _ in 0..KEYBOARD_CYCLES {
let before_shown = ime_state.lock().unwrap().shown_events;
if let Some(p) = platform {
p.show_ime();
}
tokio::time::sleep(Duration::from_millis(KEYBOARD_WAIT_MS)).await;
if ime_state.lock().unwrap().shown_events > before_shown {
shown += 1;
}
let before_hidden = ime_state.lock().unwrap().hidden_events;
if let Some(p) = platform {
p.hide_ime();
}
tokio::time::sleep(Duration::from_millis(KEYBOARD_WAIT_MS)).await;
if ime_state.lock().unwrap().hidden_events > before_hidden {
hidden += 1;
}
}
if shown == 0 {
format!(" keyboard: could not be shown ({KEYBOARD_CYCLES} attempts, 0 confirmed visible)")
} else {
format!(
" keyboard: shown {shown}/{KEYBOARD_CYCLES}, hidden {hidden}/{KEYBOARD_CYCLES} \
(confirmed via on_insets_changed)"
)
}
}
#[cfg(test)]
mod tests {
use super::TYPE_TEXT;
/// `BenchRun.kt`'s own `TYPE_TEXT` is verified `.length == 600`; this
/// is the same string, so it has to match exactly or the two apps'
/// type phases stop typing the same content -- RUST.md's "Benchmark
/// v2" spec is one shared string for both.
#[test]
fn type_text_is_exactly_600_characters() {
assert_eq!(TYPE_TEXT.chars().count(), 600);
}
}
+96 -5
View File
@@ -1,11 +1,14 @@
//! JNI calls the `bench` feature needs that go through the shell's own //! JNI calls the `bench` feature needs that go through the shell's own
//! Java side rather than anything `iris`/`android-view` already wraps: //! Java side rather than anything `iris`/`android-view` already wraps:
//! `BatteryManager.getIntProperty(BATTERY_PROPERTY_CURRENT_NOW)` for the //! `BatteryManager.getIntProperty(BATTERY_PROPERTY_CURRENT_NOW)` for the
//! per-second battery sample, and `ClipboardManager.setPrimaryClip` for //! per-second battery sample, `ClipboardManager.setPrimaryClip` for the
//! the "Copy report" control (P0's iris half, docs/RUST.md). Neither is //! "Copy report" control (P0's iris half, docs/RUST.md), and -- added for
//! part of `android_view::context`'s own `Context`/`Resources` wrappers //! RUST.md's "Benchmark v2" -- `Display.getRefreshRate()` for the phase
//! (that file's own `// TODO: more methods?`), so this calls them //! report's real late-frame budget and `InputMethodManager.
//! directly rather than growing that crate's wrapper for two one-off //! showSoftInput`/`hideSoftInputFromWindow` for the keyboard phase. None
//! of these are part of `android_view::context`'s own `Context`/
//! `Resources` wrappers (that file's own `// TODO: more methods?`), so
//! this calls them directly rather than growing that crate's wrapper for
//! calls this crate alone needs. //! calls this crate alone needs.
//! //!
//! Holds its own `JavaVM` + `GlobalRef` to the view (handed in through //! Holds its own `JavaVM` + `GlobalRef` to the view (handed in through
@@ -132,6 +135,94 @@ impl PlatformHandle {
Some(()) Some(())
} }
/// The display's own refresh rate in Hz (`View::getDisplay()` ->
/// `Display::getRefreshRate()`), for RUST.md's "Benchmark v2": late
/// frames are judged against *this* device's real budget, not an
/// assumed 60Hz -- a 90Hz or 120Hz phone would otherwise call frames
/// "late" that met their own faster deadline. `None` if the view is
/// not yet attached to a window (`getDisplay` returns `null`) or the
/// platform reports a non-positive rate, which is not a real answer
/// either.
pub fn refresh_rate_hz(&self) -> Option<f32> {
let mut guard = self.vm.attach_current_thread().ok()?;
let env: &mut JNIEnv = &mut guard;
let display = env
.call_method(
self.view.as_obj(),
"getDisplay",
"()Landroid/view/Display;",
&[],
)
.ok()?
.l()
.ok()?;
if display.is_null() {
return None;
}
let rate = env
.call_method(&display, "getRefreshRate", "()F", &[])
.ok()?
.f()
.ok()?;
if rate > 0.0 { Some(rate) } else { None }
}
/// `InputMethodManager.showSoftInput(view, 0)` -- the keyboard phase's
/// own show, called directly rather than through the focus-driven
/// `pending_show_keyboard` path `android/view.rs` uses for a real tap,
/// since RUST.md's "Benchmark v2" spec asks for this "through the
/// shell's InputMethodManager" independent of focus state. `true` only
/// if the platform itself reports the request succeeded -- whether the
/// IME actually became visible is confirmed separately, from
/// `on_insets_changed`, per UI_RULES.md ("never present an inferred
/// value as a measured one").
pub fn show_ime(&self) -> bool {
self.try_toggle_ime(true).unwrap_or(false)
}
/// `InputMethodManager.hideSoftInputFromWindow(windowToken, 0)`.
pub fn hide_ime(&self) -> bool {
self.try_toggle_ime(false).unwrap_or(false)
}
fn try_toggle_ime(&self, show: bool) -> Option<bool> {
let mut guard = self.vm.attach_current_thread().ok()?;
let env: &mut JNIEnv = &mut guard;
let context = self.context(env)?;
let imm = self.system_service(env, &context, "input_method")?;
if show {
env.call_method(
&imm,
"showSoftInput",
"(Landroid/view/View;I)Z",
&[JValue::Object(self.view.as_obj()), JValue::Int(0)],
)
.ok()?
.z()
.ok()
} else {
let token = env
.call_method(
self.view.as_obj(),
"getWindowToken",
"()Landroid/os/IBinder;",
&[],
)
.ok()?
.l()
.ok()?;
env.call_method(
&imm,
"hideSoftInputFromWindow",
"(Landroid/os/IBinder;I)Z",
&[JValue::Object(&token), JValue::Int(0)],
)
.ok()?
.z()
.ok()
}
}
/// Shows `report` in the shell's plain-view diagnostics overlay /// Shows `report` in the shell's plain-view diagnostics overlay
/// (`IrisView.showDiagnosticsOverlay`) -- a real `TextView` plus Copy /// (`IrisView.showDiagnosticsOverlay`) -- a real `TextView` plus Copy
/// and Close controls, added over whatever iris itself is drawing /// and Close controls, added over whatever iris itself is drawing
+21
View File
@@ -141,6 +141,27 @@ impl Textures {
self.updates.push(Update::Patch(handle.slot, rect)); self.updates.push(Update::Patch(handle.slot, rect));
} }
/// Forget every image, page and pending update -- what a genuinely new
/// GPU device needs alongside [`crate::render::atlas::GlyphAtlas::
/// clear`], which this module's own doc references: every slot number
/// and every queued [`Update`] here describes the *old* device's
/// textures (an `Update::Push`/`Update::Patch` already drained into a
/// renderer that no longer exists is gone for good, and a fresh
/// `UiRenderNode`'s own texture manager starts with none of them
/// applied), so nothing is lost by starting this bookkeeping over too.
/// Any `TextureHandle` a caller still holds across the reset (none in
/// the transcript screen this reset is wired up for today -- confirmed
/// by grep, the only standalone (non-atlas) image anywhere in this
/// workspace is `iris/widget/image.rs`'s `Image`, used by the separate
/// `tabs-ui` example) is left pointing at a slot this instance no
/// longer recognises and needs reinserting via `add`/`add_page` again
/// -- the same pre-existing gap a renderer restart already left for
/// such a handle before this method existed, just named rather than
/// silent now.
pub fn reset(&mut self) {
*self = Self::new();
}
pub fn free(&mut self) { pub fn free(&mut self) {
for (kind, idx) in self.recv.try_iter() { for (kind, idx) in self.recv.try_iter() {
self.images[idx as usize] = None; self.images[idx as usize] = None;
+19
View File
@@ -173,6 +173,25 @@ impl GlyphAtlas {
pub fn glyph_count(&self) -> usize { pub fn glyph_count(&self) -> usize {
self.entries.len() self.entries.len()
} }
/// Forget every page and every rasterised entry -- what a genuinely new
/// GPU device needs (`android::view::IrisViewPeer::surface_changed`'s
/// "not already live" branch, e.g. after backgrounding): the pages this
/// atlas remembers are `TextureHandle`s into the *old* device's
/// textures, which no longer exist, and every `GlyphEntry`'s `uv_min`/
/// `uv_max`/`layer` point into them. Without this, a glyph already
/// cached here is treated as "already placed" and never re-inserted
/// into the fresh (empty) atlas the new renderer actually has --
/// exactly the "rectangles stay, glyphs disappear" bug the resize path
/// (`AndroidRenderer::resize`) was built to avoid for the reuse case;
/// this is its counterpart for the case where the renderer really is
/// new. Dropping `pages` also drops its `TextureHandle`s, which send a
/// free message back through their `Textures`; see `Textures::reset`'s
/// doc for why that is harmless here.
pub fn clear(&mut self) {
self.pages.clear();
self.entries.clear();
}
} }
fn fits(page: &Page, need_w: u32, need_h: u32) -> bool { fn fits(page: &Page, need_w: u32, need_h: u32) -> bool {
+253 -4
View File
@@ -1,15 +1,87 @@
use std::time::Duration; use std::time::{Duration, Instant};
/// The frame budget `dumpsys gfxinfo` also uses to call a frame "janky": the /// The frame budget `dumpsys gfxinfo` also uses to call a frame "janky": the
/// 60Hz vsync period. Kept as the same threshold so a percentage from this /// 60Hz vsync period. Kept as the same threshold so a percentage from this
/// report and a percentage from `gfxinfo` mean the same thing. /// report and a percentage from `gfxinfo` mean the same thing. Only a
/// fallback now that a caller can read the display's real refresh rate
/// (`report_at_hz`/`mark_phase`'s callers) -- most devices are 60Hz, but a
/// 90Hz or 120Hz phone judged against this constant would call every frame
/// "late" that merely met its own, faster budget.
pub const JANK_THRESHOLD: Duration = Duration::from_nanos(16_666_667); pub const JANK_THRESHOLD: Duration = Duration::from_nanos(16_666_667);
/// Enough frames for several minutes of scrolling before the oldest ones /// Enough frames for several minutes of scrolling before the oldest ones
/// start being overwritten -- the same "diagnostic, not a log" sizing /// start being overwritten -- the same "diagnostic, not a log" sizing
/// `FrameStats.kt`'s `CAP` uses on the Compose side, chosen independently /// `FrameStats.kt`'s `CAP` uses on the Compose side, chosen independently
/// here since a `Duration` is smaller than the six `Long` arrays it keeps. /// here since a `Duration` is smaller than the six `Long` arrays it keeps.
const RING_CAPACITY: usize = 4096; /// Bumped from 4096 for RUST.md's "Benchmark v2": a fling+stream+type+
/// keyboard run is ~6,500+ frames on the Compose side, comfortably under
/// this so `phase_stats` never has to report a phase as partially evicted.
const RING_CAPACITY: usize = 16384;
/// One `mark_phase` call: the wall-clock instant and the (0-based,
/// never-reset-by-`reset`-except-at-`reset`-time) absolute frame index at
/// which a phase began -- `phase_stats` slices `index_ring` against this to
/// find which recorded samples belong to which phase, since the ring
/// itself only keeps the most recent `RING_CAPACITY` samples' *values*,
/// not which phase they were in.
struct PhaseMark {
name: String,
start_index: u64,
start_at: Instant,
}
/// One phase's own slice of a report -- RUST.md's "Benchmark v2" spec's
/// "per-phase blocks in `FrameReport`... frames, late count/percent...
/// p50/p90/p99, worst, duration". `Display` matches the shape
/// `docs/bench/compose-phone-v2-2026-09-06.md`'s report already uses, so
/// the two apps' reports read the same way side by side.
pub struct PhaseStats {
pub name: String,
/// How many frames were recorded during this phase in total -- may
/// exceed `late + (samples counted)` if some of this phase's frames
/// have since been evicted from the ring by a very long run; that
/// case is named in the `Display` rather than silently under-counted.
pub frames: u64,
pub duration: Duration,
pub late: u64,
pub late_percent: f64,
pub p50: Duration,
pub p90: Duration,
pub p99: Duration,
pub worst: Duration,
/// `false` if this phase's frame count exceeds how many samples of it
/// are still in the ring -- the percentiles above are then computed
/// over whatever survived, not the whole phase. UI_RULES.md: this is
/// the "we don't fully know" state, named rather than folded silently
/// into a number that looks exact.
pub complete: bool,
}
impl std::fmt::Display for PhaseStats {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(
f,
" {}: {} frames over {:.1}s{}",
self.name,
self.frames,
self.duration.as_secs_f64(),
if self.complete {
""
} else {
" (ring evicted some of this phase)"
},
)?;
writeln!(f, " late: {} ({:.1}%)", self.late, self.late_percent)?;
writeln!(
f,
" total p50 {:.1}ms p90 {:.1}ms p99 {:.1}ms",
self.p50.as_secs_f64() * 1000.0,
self.p90.as_secs_f64() * 1000.0,
self.p99.as_secs_f64() * 1000.0,
)?;
write!(f, " worst {:.1}ms", self.worst.as_secs_f64() * 1000.0)
}
}
/// A per-frame wall-time report iris keeps of itself, because `dumpsys /// A per-frame wall-time report iris keeps of itself, because `dumpsys
/// gfxinfo` cannot see a `SurfaceView`'s own GPU-drawn frames at all /// gfxinfo` cannot see a `SurfaceView`'s own GPU-drawn frames at all
@@ -41,6 +113,11 @@ pub struct FrameReport {
/// "Where iris's frame time goes" CPU/GPU split, added 2026-09-05). /// "Where iris's frame time goes" CPU/GPU split, added 2026-09-05).
/// `ring[i] - submit_ring[i]` is that frame's `redraw_to_submit` half. /// `ring[i] - submit_ring[i]` is that frame's `redraw_to_submit` half.
submit_ring: Box<[Duration; RING_CAPACITY]>, submit_ring: Box<[Duration; RING_CAPACITY]>,
/// The absolute (0-based, since the last `reset`) frame index each
/// `ring`/`submit_ring` slot's sample belongs to -- what `phase_stats`
/// slices against `PhaseMark::start_index` to tell which recorded
/// frames fall in which phase.
index_ring: Box<[u64; RING_CAPACITY]>,
/// How many of `ring`'s slots hold a real sample -- saturates at /// How many of `ring`'s slots hold a real sample -- saturates at
/// `RING_CAPACITY`, unlike `total_frames` below which keeps counting. /// `RING_CAPACITY`, unlike `total_frames` below which keeps counting.
len: usize, len: usize,
@@ -50,6 +127,12 @@ pub struct FrameReport {
/// correct even once the ring itself only holds the most recent frames. /// correct even once the ring itself only holds the most recent frames.
total_frames: u64, total_frames: u64,
janky_frames: u64, janky_frames: u64,
/// `mark_phase` calls since the last `reset`, oldest first -- see
/// `phase_stats`. Empty on an ordinary run that never calls
/// `mark_phase`, so `phase_stats` returns an empty `Vec` and a caller
/// prints no "per phase:" section at all, matching RUST.md's "empty/
/// absent on an ordinary 'Copy' press, which never marks a phase."
phases: Vec<PhaseMark>,
} }
/// One resolved reading. `Display` is the log line both the "Frame report" /// One resolved reading. `Display` is the log line both the "Frame report"
@@ -107,10 +190,12 @@ impl FrameReport {
Self { Self {
ring: Box::new([Duration::ZERO; RING_CAPACITY]), ring: Box::new([Duration::ZERO; RING_CAPACITY]),
submit_ring: Box::new([Duration::ZERO; RING_CAPACITY]), submit_ring: Box::new([Duration::ZERO; RING_CAPACITY]),
index_ring: Box::new([0; RING_CAPACITY]),
len: 0, len: 0,
pos: 0, pos: 0,
total_frames: 0, total_frames: 0,
janky_frames: 0, janky_frames: 0,
phases: Vec::new(),
} }
} }
@@ -131,6 +216,7 @@ impl FrameReport {
pub fn record_split(&mut self, total: Duration, submit_to_present: Duration) { pub fn record_split(&mut self, total: Duration, submit_to_present: Duration) {
self.ring[self.pos] = total; self.ring[self.pos] = total;
self.submit_ring[self.pos] = submit_to_present; self.submit_ring[self.pos] = submit_to_present;
self.index_ring[self.pos] = self.total_frames;
self.pos = (self.pos + 1) % RING_CAPACITY; self.pos = (self.pos + 1) % RING_CAPACITY;
self.len = (self.len + 1).min(RING_CAPACITY); self.len = (self.len + 1).min(RING_CAPACITY);
self.total_frames += 1; self.total_frames += 1;
@@ -142,12 +228,89 @@ impl FrameReport {
/// Clears every counter and every sample -- what the "Reset frame /// Clears every counter and every sample -- what the "Reset frame
/// report" control calls, so a report covers only what was scrolled /// report" control calls, so a report covers only what was scrolled
/// after the button was pressed (the same reason `FrameStats.kt`'s /// after the button was pressed (the same reason `FrameStats.kt`'s
/// `reset()` exists on the Compose side). /// `reset()` exists on the Compose side). Also clears every phase
/// mark, so a fresh run starts with no "per phase:" section until it
/// marks one of its own.
pub fn reset(&mut self) { pub fn reset(&mut self) {
self.len = 0; self.len = 0;
self.pos = 0; self.pos = 0;
self.total_frames = 0; self.total_frames = 0;
self.janky_frames = 0; self.janky_frames = 0;
self.phases.clear();
}
/// Marks the start of a named phase at the current moment -- every
/// frame recorded from here until the next `mark_phase` (or `reset`)
/// belongs to it. RUST.md's "Benchmark v2": a scripted bench run calls
/// this once per phase (fling/stream/type/keyboard) so `phase_stats`
/// can slice one whole run's frames by what was happening during each.
pub fn mark_phase(&mut self, name: &str) {
self.phases.push(PhaseMark {
name: name.to_string(),
start_index: self.total_frames,
start_at: Instant::now(),
});
}
/// One [`PhaseStats`] per `mark_phase` call since the last `reset`,
/// oldest first. `now` closes the last phase's wall-clock span (there
/// is no "next phase" instant to use for it); `refresh_hz` is what
/// each phase's own `late`/`late_percent` is judged against, read from
/// the display rather than assumed -- RUST.md's "Benchmark v2": "late
/// count/% against the display's refresh rate."
pub fn phase_stats(&self, now: Instant, refresh_hz: f32) -> Vec<PhaseStats> {
if self.phases.is_empty() || refresh_hz <= 0.0 {
return Vec::new();
}
let budget = Duration::from_secs_f64(1.0 / refresh_hz as f64);
self.phases
.iter()
.enumerate()
.map(|(i, phase)| {
let (end_index, end_at) = match self.phases.get(i + 1) {
Some(next) => (next.start_index, next.start_at),
None => (self.total_frames, now),
};
let frames = end_index.saturating_sub(phase.start_index);
let mut samples: Vec<Duration> = (0..self.len)
.filter(|&j| {
let idx = self.index_ring[j];
idx >= phase.start_index && idx < end_index
})
.map(|j| self.ring[j])
.collect();
let complete = samples.len() as u64 >= frames;
if samples.is_empty() {
return PhaseStats {
name: phase.name.clone(),
frames,
duration: end_at.saturating_duration_since(phase.start_at),
late: 0,
late_percent: 0.0,
p50: Duration::ZERO,
p90: Duration::ZERO,
p99: Duration::ZERO,
worst: Duration::ZERO,
complete,
};
}
samples.sort_unstable();
let pct = |p: usize| samples[(samples.len() * p / 100).min(samples.len() - 1)];
let late = samples.iter().filter(|&&d| d > budget).count() as u64;
PhaseStats {
name: phase.name.clone(),
frames,
duration: end_at.saturating_duration_since(phase.start_at),
late,
late_percent: 100.0 * late as f64 / samples.len() as f64,
p50: pct(50),
p90: pct(90),
p99: pct(99),
worst: *samples.last().expect("checked not empty above"),
complete,
}
})
.collect()
} }
/// `None` if nothing has been recorded since the last reset -- the /// `None` if nothing has been recorded since the last reset -- the
@@ -186,6 +349,28 @@ impl FrameReport {
gpu_wait_p50: median(submit_samples), gpu_wait_p50: median(submit_samples),
}) })
} }
/// `(late count, late percent)` over every sample still in the ring,
/// judged against `refresh_hz`'s own frame budget rather than the
/// fixed 60Hz `JANK_THRESHOLD` -- RUST.md's "Benchmark v2": "late
/// count/% against the display's refresh rate... print 'at N Hz (X ms
/// budget)' like Compose does." A separate method from `report()`
/// rather than a parameter on it, so `report()`'s own `janky_percent`
/// (and the exact-boundary test pinned to `JANK_THRESHOLD`) is
/// unaffected for every existing caller that never measured a real
/// refresh rate. `(0, 0.0)` with nothing recorded or a non-positive
/// `refresh_hz`.
pub fn late_at_hz(&self, refresh_hz: f32) -> (u64, f64) {
if self.len == 0 || refresh_hz <= 0.0 {
return (0, 0.0);
}
let budget = Duration::from_secs_f64(1.0 / refresh_hz as f64);
let late = self.ring[..self.len]
.iter()
.filter(|&&d| d > budget)
.count() as u64;
(late, 100.0 * late as f64 / self.len as f64)
}
} }
impl Default for FrameReport { impl Default for FrameReport {
@@ -296,4 +481,68 @@ mod tests {
// same pattern here. // same pattern here.
assert!(stats.worst <= Duration::from_millis(5)); assert!(stats.worst <= Duration::from_millis(5));
} }
#[test]
fn no_marks_means_no_phases() {
let mut r = FrameReport::new();
r.record(Duration::from_millis(5));
assert!(r.phase_stats(Instant::now(), 60.0).is_empty());
}
#[test]
fn phases_slice_frames_by_when_they_were_marked() {
let mut r = FrameReport::new();
r.mark_phase("a");
for _ in 0..5 {
r.record(Duration::from_millis(10)); // 10ms: late at 60Hz (16.7ms budget)... no, 10<16.7, not late
}
r.mark_phase("b");
for _ in 0..3 {
r.record(Duration::from_millis(20)); // 20ms: late at 60Hz
}
let now = Instant::now();
let phases = r.phase_stats(now, 60.0);
assert_eq!(phases.len(), 2);
assert_eq!(phases[0].name, "a");
assert_eq!(phases[0].frames, 5);
assert_eq!(phases[0].late, 0);
assert_eq!(phases[0].worst, Duration::from_millis(10));
assert_eq!(phases[1].name, "b");
assert_eq!(phases[1].frames, 3);
assert_eq!(phases[1].late, 3);
assert_eq!(phases[1].late_percent, 100.0);
assert_eq!(phases[1].worst, Duration::from_millis(20));
assert!(phases[0].complete);
assert!(phases[1].complete);
}
#[test]
fn the_last_phase_runs_until_now() {
let mut r = FrameReport::new();
r.mark_phase("only");
r.record(Duration::from_millis(1));
std::thread::sleep(Duration::from_millis(20));
let now = Instant::now();
let phases = r.phase_stats(now, 60.0);
assert_eq!(phases.len(), 1);
assert!(phases[0].duration >= Duration::from_millis(20));
}
#[test]
fn reset_clears_phase_marks() {
let mut r = FrameReport::new();
r.mark_phase("a");
r.record(Duration::from_millis(1));
r.reset();
assert!(r.phase_stats(Instant::now(), 60.0).is_empty());
}
#[test]
fn late_at_hz_uses_the_given_refresh_rate_not_the_fixed_60hz_constant() {
let mut r = FrameReport::new();
// 10ms is under 60Hz's 16.7ms budget but over 120Hz's 8.3ms one.
r.record(Duration::from_millis(10));
assert_eq!(r.late_at_hz(60.0), (0, 0.0));
assert_eq!(r.late_at_hz(120.0), (1, 100.0));
}
} }
+4
View File
@@ -12,6 +12,10 @@ impl<T: HasAndroidUiState> FocusHost for T {
self.android_state_mut().focus = id; self.android_state_mut().focus = id;
} }
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool {
self.android_state().focus == Some(id)
}
fn focus_gained(&mut self, region: Option<PixelRegion>) { fn focus_gained(&mut self, region: Option<PixelRegion>) {
// Showing the keyboard is a JNI call (`InputMethodManager.showSoftInput`), // Showing the keyboard is a JNI call (`InputMethodManager.showSoftInput`),
// and this runs deep inside the platform-agnostic sensor dispatch // and this runs deep inside the platform-agnostic sensor dispatch
+46
View File
@@ -49,6 +49,52 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
fn focus(&self) -> Option<WeakWidget<TextEdit>> { fn focus(&self) -> Option<WeakWidget<TextEdit>> {
self.state.android_state().focus self.state.android_state().focus
} }
/// Tell Gboard where the caret/selection and the composing region
/// actually are, via `InputMethodManager.updateSelection` -- every one
/// of android-view's own demo's `set_composing_text_internal`/`render`
/// calls this, and this bridge never did, which is what left Gboard's
/// own model of the field diverging from `TextEdit`'s real one after
/// the very first edit (RUST.md's P0 box, "doesn't enter it until I
/// hit space, and also doesn't move cursor forward" -- Gboard holds
/// its composing keystrokes back until it believes the app has caught
/// up, and without this call it never does). Called from
/// [`IrisViewPeer::after_input`], the one tail every touch/key/IME
/// callback already runs through, rather than duplicated at each of
/// this file's mutating methods.
///
/// `candidates_start`/`candidates_end` report the composing region;
/// `-1, -1` when nothing is composing, matching `EditorInfo`'s own
/// convention. `compose_len` is tracked in `char`s (this module's doc
/// comment), so this reports it as that many UTF-16 units back from the
/// caret -- exact for the common BMP case, the same approximation
/// `set_composing_text` already makes.
pub(super) fn update_ime_selection(&mut self, ctx: &mut CallbackCtx) {
let Some(focus) = self.focus() else { return };
let text = &self.rsc[focus];
let Some(sel) = text.selection_range() else {
return;
};
let content = text.text();
let sel_start = byte_to_utf16(content, sel.start) as i32;
let sel_end = byte_to_utf16(content, sel.end) as i32;
let compose_len = self.state.android_state().compose_len;
let (comp_start, comp_end) = if compose_len > 0 {
let caret = byte_to_utf16(content, text.caret().unwrap_or(sel.end)) as i32;
(caret - compose_len as i32, caret)
} else {
(-1, -1)
};
let imm = ctx.view.input_method_manager(&mut ctx.env);
imm.update_selection(
&mut ctx.env,
&ctx.view,
sel_start,
sel_end,
comp_start,
comp_end,
);
}
} }
impl<State: AndroidAppState> InputConnection for IrisViewPeer<State> { impl<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
+23
View File
@@ -325,6 +325,13 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
show_soft_input(&mut ctx.env, &ctx.view); show_soft_input(&mut ctx.env, &ctx.view);
} }
// RUST.md's P0 box, "doesn't enter it until I hit space, and also
// doesn't move cursor forward": Gboard needs `updateSelection`
// after every edit to keep its own model of the field in sync, or
// it holds keystrokes back rather than trusting a screen it
// believes is stale. See `update_ime_selection`'s own doc.
self.update_ime_selection(ctx);
let ui_state = self.state.android_state_mut(); let ui_state = self.state.android_state_mut();
ui_state.cursor.end_frame(); ui_state.cursor.end_frame();
if self.render.needs_redraw(&ui_state.root, self.rsc.widgets()) { if self.render.needs_redraw(&ui_state.root, self.rsc.widgets()) {
@@ -650,6 +657,22 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
let content_scale = self.state.android_state().content_scale; let content_scale = self.state.android_state().content_scale;
match AndroidRenderer::new(window, width as u32, height as u32, content_scale) { match AndroidRenderer::new(window, width as u32, height as u32, content_scale) {
Ok(renderer) => { Ok(renderer) => {
// A genuinely new renderer means a genuinely new GPU device
// and a fresh, empty glyph atlas -- the CPU-side glyph
// cache (`TextData::atlas`) and the texture bookkeeping it
// is built on (`UiData::textures`) both outlive `renderer`
// itself (they live on `self.rsc`, not on `AndroidRenderer`),
// so without this they would keep pointing at the *old*
// device's now-gone textures -- the app-switch counterpart
// to the keyboard-resize glyph wipe this same function's
// `already_live` branch above already fixed by reusing the
// renderer instead of rebuilding it. One mechanism either
// way: this call only runs on the branch that actually
// builds a new renderer, exactly where invalidation is
// needed, never on the reuse branch, where it would throw
// away perfectly valid GPU state for nothing.
self.rsc.ui.text.atlas.clear();
self.rsc.ui.textures.reset();
self.state.android_state_mut().renderer = Some(renderer); self.state.android_state_mut().renderer = Some(renderer);
self.render(ctx); self.render(ctx);
} }
+70 -12
View File
@@ -22,6 +22,13 @@ pub trait FocusHost {
/// it was hit in (`None` when the widget could not be located, which /// it was hit in (`None` when the widget could not be located, which
/// happens for one it was just deselected from). /// happens for one it was just deselected from).
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
/// tell a fresh press (which must wait to see whether it becomes a tap
/// or a drag before focusing/showing the IME, Iris 2026-09-06: "if I
/// swipe over the input bar it brings up the keyboard") from a drag
/// continuing inside a field that was already focused (an ordinary
/// drag-to-select, unaffected).
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool;
} }
/// Helper shared by every `FocusHost` impl, so the double-click window is /// Helper shared by every `FocusHost` impl, so the double-click window is
@@ -33,6 +40,17 @@ pub fn recent_click(last_click: &mut Instant) -> bool {
recent recent
} }
/// `PressStart`/`Pressing`/`PressEnd`, all for the left button -- what
/// [`Selector`]/[`Selectable`] register instead of [`CursorSense::
/// click_or_drag`], so their shared handler (`on_press`, below) sees every
/// frame of a gesture and can tell a completed tap from a drag itself,
/// rather than reacting to `PressStart` alone the way `click_or_drag`'s
/// consumer used to (Iris, 2026-09-06: "if I swipe over the input bar it
/// brings up the keyboard").
fn press_track() -> CursorSenses {
CursorSense::click() | CursorSense::Pressing(CursorButton::Left) | CursorSense::unclick()
}
pub struct Selector; pub struct Selector;
impl<Rsc: HasEvents, W: Widget + 'static> WidgetAttr<Rsc, W> for Selector impl<Rsc: HasEvents, W: Widget + 'static> WidgetAttr<Rsc, W> for Selector
@@ -42,7 +60,7 @@ where
type Input = WeakWidget<TextEdit>; type Input = WeakWidget<TextEdit>;
fn run(rsc: &mut Rsc, container: WeakWidget<W>, id: Self::Input) { fn run(rsc: &mut Rsc, container: WeakWidget<W>, id: Self::Input) {
rsc.register_event(container, CursorSense::click_or_drag(), move |ctx, rsc| { rsc.register_event(container, press_track(), move |ctx, rsc| {
let region = ctx.data.render.window_region(&id, &*rsc).unwrap(); let region = ctx.data.render.window_region(&id, &*rsc).unwrap();
let id_pos = region.top_left; let id_pos = region.top_left;
let container_pos = ctx let container_pos = ctx
@@ -53,14 +71,14 @@ where
.top_left; .top_left;
let pos = ctx.data.pos + container_pos - id_pos; let pos = ctx.data.pos + container_pos - id_pos;
let size = region.size(); let size = region.size();
select( on_press(
rsc, rsc,
ctx.data.render, ctx.data.render,
ctx.state, ctx.state,
id, id,
pos, pos,
size, size,
ctx.data.sense.is_dragging(), ctx.data.sense,
); );
}); });
} }
@@ -75,31 +93,71 @@ where
type Input = (); type Input = ();
fn run(rsc: &mut Rsc, id: WeakWidget<TextEdit>, _: Self::Input) { fn run(rsc: &mut Rsc, id: WeakWidget<TextEdit>, _: Self::Input) {
rsc.register_event(id, CursorSense::click_or_drag(), move |ctx, rsc| { rsc.register_event(id, press_track(), move |ctx, rsc| {
select( on_press(
rsc, rsc,
ctx.data.render, ctx.data.render,
ctx.state, ctx.state,
id, id,
ctx.data.pos, ctx.data.pos,
ctx.data.size, ctx.data.size,
ctx.data.sense.is_dragging(), ctx.data.sense,
); );
}); });
} }
} }
fn select( /// One press-track frame (`PressStart`, `Pressing` or `PressEnd`) over a
/// selectable field. A field that is *already* focused behaves exactly as
/// `click_or_drag` always did -- every frame updates the selection, which
/// is what lets a finger already inside a focused field drag out a
/// selection. A field that is **not** focused withholds `select`'s
/// focus-granting side effects (and so the platform-specific `focus_gained`
/// that shows the keyboard) until the press resolves as a tap: `PressEnd`
/// with no frame in between having moved past [`DRAG_SLOP`] from where the
/// press began. A drag recognised before release simply cancels the
/// pending tap and does nothing further here -- it is not consumed, so
/// whatever is behind the field (a list to pan) still sees every frame of
/// it, the same as a drag that never touched a selectable field at all.
fn on_press(
rsc: &mut impl UiRsc, rsc: &mut impl UiRsc,
render: &UiRenderState, render: &UiRenderState,
state: &mut impl FocusHost, state: &mut impl FocusHost,
id: WeakWidget<TextEdit>, id: WeakWidget<TextEdit>,
pos: Vec2, pos: Vec2,
size: Vec2, size: Vec2,
dragging: bool, sense: CursorSense,
) { ) {
let recent = state.recent_click(); if state.is_focused(id) {
id.edit(rsc).select(pos, size, dragging, recent); let recent = matches!(sense, CursorSense::PressStart(_)) && state.recent_click();
state.set_focus(Some(id)); id.edit(rsc).select(pos, size, sense.is_dragging(), recent);
state.focus_gained(render.window_region(&id, &*rsc)); return;
}
match sense {
CursorSense::PressStart(_) => {
id.edit(rsc).text.press_origin = Some(pos);
}
CursorSense::Pressing(_) => {
let ctx = id.edit(rsc);
if let Some(origin) = ctx.text.press_origin
&& ((pos.x - origin.x).abs() > DRAG_SLOP || (pos.y - origin.y).abs() > DRAG_SLOP)
{
// Past the slop before release: this is a drag, not a tap
// -- give up the pending focus rather than granting it once
// the finger lifts wherever it happens to be by then.
ctx.text.press_origin = None;
}
}
CursorSense::PressEnd(_) => {
let was_tap = id.edit(rsc).text.press_origin.take().is_some();
if was_tap {
let recent = state.recent_click();
id.edit(rsc).select(pos, size, false, recent);
state.set_focus(Some(id));
state.focus_gained(render.window_region(&id, &*rsc));
}
}
_ => {}
}
} }
+4
View File
@@ -10,6 +10,10 @@ impl<T: HasDefaultUiState> FocusHost for T {
self.default_state_mut().focus = id; self.default_state_mut().focus = id;
} }
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool {
self.default_state().focus == Some(id)
}
fn focus_gained(&mut self, region: Option<PixelRegion>) { fn focus_gained(&mut self, region: Option<PixelRegion>) {
let state = self.default_state_mut(); let state = self.default_state_mut();
let Some(region) = region else { return }; let Some(region) = region else { return };
+77
View File
@@ -183,3 +183,80 @@ fn a_mask_stays_put_while_its_scrolled_content_moves() {
assert_eq!(mask_delta_before, [0.0, 0.0]); assert_eq!(mask_delta_before, [0.0, 0.0]);
assert_eq!(mask_delta_after, [0.0, 0.0]); assert_eq!(mask_delta_after, [0.0, 0.0]);
} }
/// Reproduces `transcript_ui::composer::build_composer`'s exact tree shape
/// (a `Rect` background stacked behind a `Span::RIGHT`-wrapped, padded,
/// `rest`-width `TextEdit`, itself the second child of an outer
/// `Span::DOWN` beside a `rest(1)`-height sibling) without the event/
/// resource plumbing `composer.rs`'s builders need, to isolate whether the
/// bug Iris reported on 2026-09-06 ("text seems to not appear in box")
/// is this crate's layout engine or something specific to the real
/// composer/screen. `TextEditable::edit` only needs `UiRsc`, so a plain
/// insert exercises the exact redraw path a keystroke does.
fn composer_like_tree(rsc: &mut TestRsc) -> (WeakWidget<TextEdit>, StrongWidget) {
let field = wtext("")
.editable(EditMode::MultiLine)
.text_align(Align::LEFT)
.wrap(true)
.size(18)
.color(UiColor::WHITE)
.add(rsc);
let bar = (field.pad(dp(12)).width(rest(1)),)
.span(Dir::RIGHT)
.background(rect(UiColor::new(40, 40, 46, 255)))
.add(rsc);
let list_stand_in = rect(UiColor::BLACK).height(rest(1)).add(rsc);
let tree = (list_stand_in, bar).span(Dir::DOWN).add_strong(rsc).any();
(field, tree)
}
/// The reproduction itself. A window this tall stands in for the keyboard
/// closed; the second, shorter `resize` stands in for `adjustResize`
/// shrinking the surface when the IME opens -- exactly the sequence
/// `IrisViewPeer::surface_changed` drives on a real keyboard open. Typing
/// happens both before and after, since Iris's report was specifically
/// that text typed *after* the keyboard was already up did not appear.
#[test]
fn composing_text_after_a_keyboard_resize_lands_in_the_bars_own_region() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (field, root) = composer_like_tree(&mut rsc);
let mut render = UiRenderState::new();
render.resize((1080.0, 2298.0));
render.update(&root, &mut rsc);
field.edit(&mut rsc).insert("a");
render.update(&root, &mut rsc);
let before_px = render.window_region(&field, &rsc).unwrap();
// The field is one line plus 12dp of padding on a 2298-tall window --
// nowhere near the whole window's height, and anchored at the bottom.
assert!(
before_px.bot_right.y - before_px.top_left.y < 200.0,
"before a resize: {before_px:?}"
);
assert!(
before_px.top_left.y > 1800.0,
"expected the bar near the bottom before a resize: {before_px:?}"
);
// The keyboard opens: a real `surface_changed`/`resize` to a shorter
// window, then a further keystroke -- the redraw that must land in the
// bar's new (also short) region, not whatever region a provisional
// measurement pass used along the way.
render.resize((1080.0, 1478.0));
render.update(&root, &mut rsc);
field.edit(&mut rsc).insert("b");
render.update(&root, &mut rsc);
let after_px = render.window_region(&field, &rsc).unwrap();
assert!(
after_px.bot_right.y - after_px.top_left.y < 200.0,
"after a resize + keystroke: {after_px:?}"
);
assert!(
after_px.top_left.y > 1200.0,
"expected the bar near the bottom of the shorter window: {after_px:?}"
);
}
+36
View File
@@ -489,6 +489,24 @@ impl List {
true true
} }
/// The anchor's own row index and pixel offset, formatted the same
/// shape Compose's `firstVisibleItemIndex`/`firstVisibleItemScrollOffset`
/// report (`idx=N/off=Mpx`) -- what RUST.md's "Benchmark v2" fling
/// phase reads before/after/between its fling runs so the two apps'
/// travel can be compared directly. `more_before`/`more_after`
/// sentinels print as `idx=more-before`/`idx=more-after` rather than
/// leaking their internal `isize` representation; `idx=none` if the
/// list has never drawn (no anchor yet -- e.g. right after
/// `jump_to_end` and before the next frame runs `repair_anchor`).
pub fn anchor_position_display(&self) -> String {
match self.anchor {
None => "idx=none".to_string(),
Some(a) if a.slot == BEFORE_SLOT => "idx=more-before".to_string(),
Some(a) if a.slot == AFTER_SLOT => "idx=more-after".to_string(),
Some(a) => format!("idx={}/off={}px", a.slot, a.offset.round() as i64),
}
}
/// Snap to the newest content (last item, or the `more_after` /// Snap to the newest content (last item, or the `more_after`
/// sentinel if set), bottom-aligned to the viewport. O(1). /// sentinel if set), bottom-aligned to the viewport. O(1).
pub fn jump_to_end(&mut self) { pub fn jump_to_end(&mut self) {
@@ -1456,4 +1474,22 @@ mod tests {
first.top first.top
); );
} }
#[test]
fn anchor_position_display_before_any_draw_is_none() {
let list = List::new(Axis::Y);
assert_eq!(list.anchor_position_display(), "idx=none");
}
#[test]
fn anchor_position_display_reports_slot_and_offset() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
let _ = (&root, &mut render);
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
assert!(list_ref.anchor_position_display().starts_with("idx="));
assert!(!list_ref.anchor_position_display().contains("none"));
}
} }
+71
View File
@@ -32,6 +32,15 @@ pub struct TextEdit {
#[cfg_attr(target_os = "android", allow(dead_code))] #[cfg_attr(target_os = "android", allow(dead_code))]
history: Vec<(String, Option<Selection>)>, history: Vec<(String, Option<Selection>)>,
double_hit: Option<usize>, double_hit: Option<usize>,
/// Where an in-flight press over this field began, while it is still
/// undecided whether the gesture is a tap (focus/show the IME) or a
/// drag (attr.rs's `Selector`/`Selectable`, Iris 2026-09-06: a swipe
/// over the composer must not summon the keyboard). `None` both before
/// any press and once the gesture has been decided either way --
/// `attr.rs` is the only reader/writer, kept `pub(crate)` rather than
/// behind an accessor since it is pure bookkeeping with no invariant
/// beyond "some press is undecided," same shape as `double_hit` above.
pub(crate) press_origin: Option<Vec2>,
pub mode: EditMode, pub mode: EditMode,
} }
@@ -48,6 +57,7 @@ impl TextEdit {
selection: None, selection: None,
history: Default::default(), history: Default::default(),
double_hit: None, double_hit: None,
press_origin: None,
mode, mode,
} }
} }
@@ -698,6 +708,67 @@ mod tests {
assert_eq!(content(&t), ""); assert_eq!(content(&t), "");
} }
/// `android/ime.rs`'s `set_composing_text` calls `replace` and expects
/// the caret to land right after the inserted text, growing with it on
/// every re-send -- the buffer-level half of RUST.md's P0 box ("doesn't
/// enter it until I hit space, and also doesn't move cursor forward").
#[test]
fn composing_advances_the_caret_with_the_growing_text() {
let (mut t, mut d) = edit("", EditMode::SingleLine);
ctx(&mut t, &mut d).set_caret(0);
ctx(&mut t, &mut d).replace(0, "h");
assert_eq!(t.caret(), Some(1));
ctx(&mut t, &mut d).replace(1, "hi");
assert_eq!(content(&t), "hi");
assert_eq!(t.caret(), Some(2));
ctx(&mut t, &mut d).replace(2, "hit");
assert_eq!(content(&t), "hit");
assert_eq!(t.caret(), Some(3));
}
/// The IME's `commitText` (`android_view::InputConnection::commit_text`'s
/// default body): finish a composition in place, same as a real word
/// boundary (a space) landing after Gboard's composing span.
#[test]
fn committing_composed_text_leaves_it_in_place_with_the_caret_after_it() {
let (mut t, mut d) = edit("say ", EditMode::SingleLine);
ctx(&mut t, &mut d).set_caret(4);
ctx(&mut t, &mut d).replace(0, "hi");
assert_eq!(content(&t), "say hi");
// `finish_composing_text`/`commit_text` do not themselves touch the
// buffer -- only the IME's own `compose_len` bookkeeping resets, in
// `android/ime.rs`. Confirms the buffer already holds committed
// text as plain, uncomposed content: a further `replace(0, " ")`
// (the space that ends the word) appends rather than overwriting.
ctx(&mut t, &mut d).replace(0, " ");
assert_eq!(content(&t), "say hi ");
assert_eq!(t.caret(), Some(7));
}
/// `TextEditCtx::delete_byte_range` is `deleteSurroundingText`'s entry
/// point once `android/ime.rs` has converted UTF-16 code units to
/// bytes -- exercised directly here in bytes, since the UTF-16 math
/// itself is `android/ime.rs`'s own `byte_to_utf16`/`utf16_to_byte`,
/// outside this widget-only test module.
#[test]
fn delete_byte_range_removes_exactly_that_range() {
let (mut t, mut d) = edit("hello world", EditMode::SingleLine);
ctx(&mut t, &mut d).delete_byte_range(5, 11);
assert_eq!(content(&t), "hello");
assert_eq!(t.caret(), Some(5));
}
/// `set_cursor_byte` is `setSelection`'s entry point -- collapses to a
/// caret at the given byte offset regardless of any span that was there.
#[test]
fn set_cursor_byte_collapses_to_a_caret_there() {
let (mut t, mut d) = edit("hello world", EditMode::SingleLine);
ctx(&mut t, &mut d).select_all();
ctx(&mut t, &mut d).set_cursor_byte(5);
assert_eq!(t.selected_text(), None);
assert_eq!(t.caret(), Some(5));
}
#[test] #[test]
fn motion_moves_the_caret_and_shift_extends_a_span() { fn motion_moves_the_caret_and_shift_extends_a_span() {
let (mut t, mut d) = edit("abc", EditMode::SingleLine); let (mut t, mut d) = edit("abc", EditMode::SingleLine);
+57 -3
View File
@@ -8,14 +8,58 @@
//! measures whatever vertical space is left each frame -- nothing here //! measures whatever vertical space is left each frame -- nothing here
//! computes a height by hand, and growing this field is exactly the //! computes a height by hand, and growing this field is exactly the
//! O(1)-move-chain case LAYOUT.md and I3's benchmark already measured. //! O(1)-move-chain case LAYOUT.md and I3's benchmark already measured.
//!
//! **Rebuilt 2026-09-06** (Iris's phone report on the dc01f88 build: the
//! grey bar drawn as a short, fixed strip with the typed text ~150px below
//! it on black, and empty black between the bar and the keyboard). One
//! widget now, top to bottom: an opaque background sized to its content
//! (`.background`, the same `Stack` idiom the header row's `HEADER_SURFACE`
//! already uses), the field inside `dp` padding and capped at
//! [`MAX_LINES`] before it scrolls instead of growing forever, and an
//! outer [`Pad`] whose `bottom` [`TranscriptScreen::set_bottom_inset`]
//! rewrites in place whenever the keyboard opens/closes -- never rebuilt,
//! since `field` is strongly owned inside this tree and this crate's
//! widgets cannot be re-parented once added (this module's own comment
//! below on why `build_composer` hands back a **weak** id).
use iris::prelude::*; use iris::prelude::*;
/// Caps the field's growth at roughly six lines of its own 18px text
/// before it scrolls instead of consuming the whole screen -- an
/// approximation (line-height and padding folded into one round `dp`
/// number) rather than a value derived from the font's real metrics,
/// which nothing in this crate exposes to a caller today.
const MAX_LINES: f32 = 6.0;
const APPROX_LINE_HEIGHT_DP: f32 = 24.0;
const FIELD_PAD_DP: f32 = 12.0;
/// `field` is exposed so the caller can read its content on submit /// `field` is exposed so the caller can read its content on submit
/// (`field.edit(rsc).text()`) and clear it afterward /// (`field.edit(rsc).text()`) and clear it afterward
/// (`field.edit(rsc).set("")`). /// (`field.edit(rsc).set("")`).
pub struct Composer { pub struct Composer {
pub field: WeakWidget<TextEdit>, pub field: WeakWidget<TextEdit>,
/// The bar's own outer padding -- only `bottom` is ever changed, by
/// [`Self::set_bottom_inset`]. A `Pad` around the whole bar rather than
/// a rebuilt tree, because `field` lives inside it and cannot be
/// re-added to a new wrapper once it is strongly owned here.
outer_pad: WeakWidget<Pad>,
}
impl Composer {
/// Called by the platform shell (Android's `on_insets_changed`, e.g.)
/// whenever the space below the bar changes: the IME's own inset while
/// it is open, the navigation-bar inset otherwise. Takes a plain
/// `f32` in the caller's own physical-pixel units rather than an
/// Android-specific insets type, so this crate stays usable from the
/// winit backend too, which has no navigation bar to report.
/// Rewrites the existing `Pad` in place (marking it dirty through the
/// ordinary `Widgets::get_mut` path) instead of swapping in a new one,
/// so the field's focus, selection and in-progress text are untouched.
pub fn set_bottom_inset(&self, rsc: &mut impl UiRsc, inset: f32) {
if let Some(pad) = rsc.ui_mut().widgets.get_mut(&self.outer_pad) {
pad.padding.bottom = Len::abs(inset);
}
}
} }
/// Returns the composer plus its own bar as a **weak** id -- the caller /// Returns the composer plus its own bar as a **weak** id -- the caller
@@ -39,10 +83,20 @@ where
.label("Message") .label("Message")
.add(rsc); .add(rsc);
let bar: WeakWidget = (field.pad(dp(12)).width(rest(1)),) // One widget: an opaque bar sized to its own content (`.background`'s
.span(Dir::RIGHT) // `Stack{child: 1}`, the header row's own idiom) wrapping the padded,
// height-capped field -- not a background rect and a field drawn as
// two independent siblings, which is what let the two disagree on
// where the bar actually was.
let content = field
.pad(dp(FIELD_PAD_DP))
.max_height(dp(APPROX_LINE_HEIGHT_DP * MAX_LINES + FIELD_PAD_DP * 2.0))
.scrollable()
.width(rest(1))
.background(rect(UiColor::new(40, 40, 46, 255))) .background(rect(UiColor::new(40, 40, 46, 255)))
.add(rsc); .add(rsc);
(Composer { field }, bar) let outer_pad: WeakWidget<Pad> = content.pad(Padding::ZERO).add(rsc);
(Composer { field, outer_pad }, outer_pad)
} }