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
iris c589a75fa0 Merge remote-tracking branch 'origin/rustify' into worktree-agent-a1ff0294b6c29127e
# Conflicts:
#	docs/RUST.md
2026-09-06 00:54:06 -04:00
irisandClaude Fable 5.1 4b62cc642e docs/RUST.md: emulator verification results for the keyboard/dp/header fixes
run-bench.sh end to end clean (24/24 swipes, 400/400 events); header
background confirmed by screenshot; the keyboard wipe fix confirmed two
ways (a forced wm size resize and an actual soft-keyboard open, both real
surface_changed triggers, text intact both times).

Also records two things found during this verification and not fixed:
the top button row appears to render a second time, out of place, after
a keyboard-triggered resize, and a tap aimed at the field below can land
on it instead -- and the keyboard diagnostics auto-capture never fired in
this session. Neither is root-caused; explicitly not attributed to this
pass's changes without more evidence, per the standing rule against
blaming ambient failures on your own code without measuring first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 00:51:27 -04:00
irisandClaude Fable 5.1 80c2eadec9 docs: record the keyboard-wipe fix, the dp unit and the header fix
docs/IRIS.md's 2026-09-06 entry (public API), docs/LAYOUT.md's "Density:
Len::dp" design section, IRIS_TODO.md's density-unit item ticked, and
docs/RUST.md's P0 box gets the investigation: the keyboard-wipe
hypothesis and confirmation, the blur root cause and why the dp unit
turned out to be the same fix, the header cause, and what remains
unverified (an emulator screenshot of the keyboard fix, and Iris's real
phone).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 00:40:59 -04:00
irisandClaude Fable 5.1 0b587629e6 iris/android-app bench: auto-capture diagnostics when the keyboard opens
So Iris can get a report off the phone even if the keyboard wipe (or
some other keyboard-triggered regression) is still present on whatever
build she is holding, independent of whether the on-screen Diagnostics
button itself is drawing.

on_insets_changed edge-triggers on ime_bottom becoming non-zero, waits
KEYBOARD_DIAGNOSTICS_DELAY_MS (500ms, long enough for the resize and a
couple of frames to settle) via a spawned task, then
capture_keyboard_diagnostics reuses show_diagnostics's exact report text,
logs it, copies it to the clipboard unprompted, and shows it through a
new PlatformHandle::show_diagnostics_overlay call into
IrisView.showDiagnosticsOverlay -- a plain TextView + Copy/Close panel
added over the existing IrisView (not replacing it, unlike
showRendererError's one-way trip) so it draws independently of whatever
iris's own renderer is doing, and Close returns to the still-running
session underneath.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 00:39:14 -04:00
irisandClaude Fable 5.1 3163256d2c iris/android-app: opaque header background, header sizes onto dp
Iris's phone report (build a9232ac): "the header buttons have nothing
behind them and overlap the transcript text." Only each button's own
rect painted anything, so the gaps between and around them (and the
status-bar strip above) showed CLEAR_COLOR (black) one layer back, and
the row's reserved height was three abs (physical-pixel) button boxes --
smaller, on a dense phone, than the dp-correct size the transcript below
now uses post the previous two commits, which is what reads as overlap
once the two disagree.

Fixed with a HEADER_SURFACE rect stacked behind the whole button row
(not just behind each button), and every non-text size in the header
(button padding, row height, the report field's padding) moved from a
bare number to dp(...), so the row's reserved height in the outer
Span::DOWN matches what is actually painted. The list/report field
already sit below the header in that same Span::DOWN, not behind it --
no stacking change needed there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 00:35:45 -04:00
irisandClaude Fable 5.1 6102e0d4d9 iris: a dp length unit, resolved against density; crisp glyphs at physical size
Iris asked for this 2026-09-06 (IRIS_TODO.md, "a third length kind beside
relative and pixels ... a unit resolved against the display's density at
layout time"): before this, a Len was abs (physical pixels) or rel/rest
(a fraction of the parent), and the only way to make a design size look
the same physical size on a denser display was a single global multiply
applied after layout -- which the previous commit found is also what
made text blurry.

Len gains a `dp` field, resolved against a `density: f32` (physical
pixels per dp) now carried on UiRenderState/Painter
(`UiRenderState::set_density`/`density()`, `Painter::density()`) and
threaded through every `apply_rest`/`to_uivec2` call site. `len_fns::dp`
/ `Len::dp` construct one, exactly parallel to the existing `abs`/`rel`/
`rest`. A bare number is unaffected (still `abs`, physical pixels) --
`dp` is opt-in.

Text: `TextBuffer::shape` now takes `density` and multiplies
`font_size`/`line_height` (and any span override) by it before handing
them to parley, so the size that reaches the shaper and the rasteriser
(`TextData::place`) is the display's real physical size -- the atlas
holds a bitmap at the resolution it is actually shown at, instead of a
low-resolution one stretched afterward. `GlyphKey.size` already keys on
the resolved `font_size`, so a cache entry is naturally per physical size
with no further change. `TextData` also carries its own `density` copy
for `TextEditCtx::layout` (cursor movement/hit-testing), which shapes
text from an input callback with no `Painter` to read it from.

`Span::gap` and `Padding`'s four sides move from bare `f32` to `Len`, so
`.gap(dp(4))`/`.pad(dp(10))` work the same way any other size does; a
bare number still means physical pixels, unchanged.

Migrated transcript-ui's non-text sizes (row gap/padding, composer
padding) and one example to the new unit, per IRIS_TODO.md's "done when"
list. Android's own density (`DisplayMetrics.density`) is wired to both
copies in `new_peer`; the winit backend has no per-monitor density wired
up yet and stays at the default (1.0).

docs/IRIS.md, docs/LAYOUT.md and IRIS_TODO.md updated next.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 00:35:38 -04:00
irisandClaude Fable 5.1 f0da383e28 iris/android: reuse the renderer across a surface resize, fix the keyboard glyph wipe
Hypothesis confirmed by reading the path end to end before changing
anything: surface_changed fires on every SurfaceView size/format change,
not only a genuinely new Surface -- showing the IME under adjustResize
resizes the same surface through this exact callback. The handler
unconditionally dropped AndroidRenderer and rebuilt it via
AndroidRenderer::new, which allocates a brand-new, empty glyph atlas and
fresh GPU buffers, while iris_core's CPU-side glyph cache kept the UV
coordinates it had already handed out against the *old* atlas -- so every
glyph drew from a rectangle pointing into a texture that had just been
recreated empty. Rects never go through the atlas, so they kept drawing:
exactly Iris's report ("rectangles stay; only text disappears").

Fixed by reusing the existing AndroidRenderer (device, atlas, buffers,
bind groups) and only reconfiguring the surface + window uniform via its
existing resize() when a renderer is already live; AndroidRenderer::new
now runs only when surface_changed finds `renderer` already None (a
genuinely new surface, e.g. after surface_destroyed/backgrounding).

While in this path, removed the global logical/physical scale stopgap
(dividing window size, touch coordinates and insets by content_scale)
that the P0 "text too small" fix had added: it is what made text blurry
next (a glyph rasterised small then stretched by the NDC mapping onto the
real physical framebuffer). Window size, touch and insets are physical
pixels throughout now, matching AndroidRenderer's own swapchain
resolution; density is resolved per-length instead (next commit).
LogicalInsets renamed to WindowInsets to match.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 00:35:22 -04:00
irisandClaude Fable 5.1 2d3695a1d3 Merge iris fling/jitter fix into rustify
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 00:20:39 -04:00
irisandClaude Fable 5.1 f06ee259b4 iris: List::fling with Android's spline physics, and fix the drag-slop scroll jitter
Adds VelocityTracker and a port of AOSP SplineOverScroller's fling curve
(FlingCalculator, cited at the definition) to iris::sense, and wires
List::fling/is_scrolling/cancel_fling/tick_fling through
Selection::drag's release path -- a pan's release now decelerates instead
of stopping dead on the finger lifting, matching IRIS_TODO.md's "swiping
has no momentum" ask. Clamped at the loaded content's start/end and
cancelled by the next touch-down.

Also fixes the scroll jitter DragArbiter's slop release caused: crossing
DRAG_SLOP applied the whole pre-threshold drag (measured from press_start)
in one step, since nothing pans while a gesture might still resolve to a
selection. Now only the excess past DRAG_SLOP is applied on that frame,
the same way Android's own touch handling consumes touch slop rather than
replaying it.

Root-caused by reading DragArbiter's state machine and covered by new
unit tests (fling distance against the closed-form spline result within
1%, cancel-on-touch, start/end clamp, the slop-crossing regression); no
emulator was used this pass, so an on-device trace/feel-check is still
open, and Benchmark v2's four-phase bench_client.rs spec was not
attempted. docs/IRIS.md, docs/IRIS_TODO.md and docs/RUST.md's P0 box
record what's done and what's left.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 00:20:24 -04:00
irisandClaude Fable 5.1 560a74caf8 docs: record the phone-report fixes, follow-ups and the bundled-font API
RUST.md's P0 box gets Iris's first real-phone report (no crash) and the
four defects it found (glyph-wipe-on-first-touch, missing bold glyphs,
text far too small, status-bar inset not applied), what was fixed and
how it was verified on the emulator, and what's still open (item 1's
root cause, and the top-row height anomaly noted in the last commit).

IRIS_TODO.md gets a new "From the phone, 2026-09-06" section for the two
items explicitly deferred to a follow-up agent: no scroll momentum/fling,
and occasional jitter scrolling down.

IRIS.md gets the public-API entry for TextData's bundled fonts/
font_diagnostics, UiRenderNode::new/resize's new window_size parameter,
AndroidUiState::content_scale, AndroidAppState::on_insets_changed, and
iris_core::WgpuErrorLog.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:59:40 -04:00
irisandClaude Fable 5.1 fd7e17523d iris/android: fix layout/shader unit mismatch left by the density-scale commit
surface_changed's self.render.resize(...) -- UiRenderState::output_size,
what every widget's absolute PixelRegion (a fixed .height(56), notably)
is computed against -- was still being handed raw physical width/height
after the previous commit switched AndroidRenderer's own size()/resize()/
new() to logical (physical / content_scale) for the shader's window
uniform. That split layout and the shader into two different units:
layout placed a "56"-unit row inside a ~2219-physical-unit-tall canvas
(an absolute box, still exactly 56 units), the shader then divided that
same 56 by a ~845-unit *logical* window dimension -- found on the
emulator by measuring a fresh install's top button row at ~40 physical
px against the ~147px `56 * content_scale` predicts. Proportional
(rest(n)) sizes hid the mismatch by adapting to whichever total they were
given; only fixed sizes exposed it. Now divides by content_scale here
too, matching every other call site.

Verified on this checkout's emulator (EMU_GPU default, force-gles):
run-bench.sh completes end to end (frames=691, 24/24 swipes streamed
400/400 events) and a fresh-install screenshot shows visibly larger
text than before this and the previous commit, with the top row's own
sizing still worth a closer look on a real device -- see RUST.md's P0
box for what remains unverified there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:57:24 -04:00
irisandClaude Fable 5.1 c7682297fa docs/bench: Compose bench v2 report from Iris's phone, verbatim
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:49:02 -04:00
irisandClaude Fable 5.1 27511302f2 iris/android-app: Diagnostics control, top-bar status-bar padding, cargo fmt
Adds a third "Diagnostics" button to the bench screen's top row, filling
the existing benchmark-report TextEdit (so the existing "Copy report"
button and clipboard path work on it unchanged) with adapter identity,
font resolution, atlas view count, wgpu errors seen so far and the frame
report -- RUST.md's P0 box, "a named Diagnostics control ... copy this
and send it to Iris." Logs the same font-resolution summary once at
startup too.

Wires BenchClient::on_insets_changed (the new AndroidAppState hook) to
rebuild the top button row with Padding::top(insets.top), through a
WidgetPtr slot (top_bar) so it can be swapped once the status-bar inset
is known -- fixes RUST.md's P0 box, "the status-bar inset is not
applied," where the two top buttons sat directly under the status bar
because nothing in this file read insets().top at all.

cargo fmt --all across the touched files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:44:43 -04:00
irisandClaude Fable 5.1 184a6c5b33 IRIS_TODO.md: a density-independent length unit, asked for by Iris
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:43:16 -04:00
irisandClaude Fable 5.1 5b2ca039f1 docs/RUST.md: bench v2 spec and the emulator smoke run
Iris's ask (2026-09-06): the fling should travel much faster for
stress-testing, plus typing and keyboard phases. Written once into the
P0 box so the iris agent implements the identical four-phase spec --
constants, ordering and report shape -- rather than a second one that
looks the same but isn't.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:41:03 -04:00
irisandClaude Fable 5.1 a8d24553d5 app: bench v2 -- a real fling, typing and keyboard phases
Iris's ask after using the Compose bench build on her phone: the old
scroll phase used animateScrollBy, which can only ever cover the fixed
distance/time it's given, so it never flings the way a real fast swipe
does. BenchRun.run now has four phases: fling (8 flings out + 8 back
through the list's own FlingBehavior at 12,000px/s), stream (unchanged),
type (600 fixed characters into the real composer TextFieldValue, then
deleted, to exercise wrapping and the transcript being pushed upward),
and keyboard (five show/hide cycles via WindowInsetsControllerCompat,
each confirmed by isImeVisible rather than assumed).

FrameStats.markPhase/phaseLines slice the same FrameMetrics recording
by phase rather than running a second recorder; debugReport gains a
phaseFrames section ahead of the existing whole-run frames/accounting/
work sections, which are otherwise unchanged.

Also fixes a pre-existing, unrelated break in MainActivity.kt's
benchSessionSummary() -- missing several SessionSummary constructor
arguments from an earlier change -- since it blocked compileBenchKotlin
outright.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:40:59 -04:00
irisandClaude Fable 5.1 3b80a88f3b iris/android: content_scale (density), per-frame diagnostics, insets hook
Threads DisplayMetrics.density (read once in new_peer, via the Context
android-view already hands the JNI entry point) through AndroidUiState
as content_scale, and divides by it everywhere a raw device-pixel number
used to reach layout unscaled: AndroidRenderer::size()/resize()/new() now
report logical (physical / density) dimensions to UiRenderNode and to
UiRenderState's own root-layout size, and on_touch_event divides the
incoming MotionEvent coordinates the same way, so touch and layout agree
on units again. This is the fix for RUST.md's P0 box, "text is far too
small" -- a font_size: 16.0 was 16 raw device pixels on a ~3x-density
phone, identical to the desktop fix in the previous commit.

Installs Device::on_uncaptured_error on the Android device (wgpu's
default handler is an unconditional panic outside UiRenderNode::new's
own error scopes) into a new iris_core::WgpuErrorLog, and adds
AndroidRenderer::diagnostics_report() combining adapter identity, font
resolution, atlas view count and the error log into one string for a
future Diagnostics screen. render() now logs a one-line diagnostic
(masks/moves resized, atlas pages grown, image bind-group creates, wgpu
error count) for the first 10 frames after each surface_changed -- the
window RUST.md's P0 box says the glyph-wipe-on-first-touch happens in.

Adds AndroidAppState::on_insets_changed(rsc, LogicalInsets), called from
render() exactly when AndroidUiState::insets() changes (once at startup
for the status bar, again on rotation/IME) -- nothing previously read
insets().top at all, which is why RUST.md's P0 box found the bench
screen's top buttons sitting under the status bar.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:40:30 -04:00
irisandClaude Fable 5.1 d8e6bc6e9b iris: bundle Noto Sans for text rendering, apply density scale on both backends
Bundles Noto Sans/Noto Sans Mono (regular/bold/italic/bold-italic, OFL
licensed) into iris-core and registers them ahead of the platform's own
fonts in the SansSerif/Monospace generic-family fallback lists, so text
no longer depends on the platform's font enumeration succeeding or
resolving weight/style correctly. Iris's phone report showed bold spans
rendering as blank gaps of the correct advance width -- the glyph simply
wasn't rasterised -- while the emulator's system fonts happened to
resolve every style; a bundled static-per-style family removes that
platform-dependent step entirely. TextData::font_diagnostics() reports
what was found/resolved, for the startup log and the Diagnostics page.

Also applies a content/device-pixel scale that neither backend had
before: UiRenderNode::new/resize now take the window size explicitly
(logical units) rather than deriving it from the surface's physical
config, so a 16.0 font size is 16 logical units rather than 16 raw
device pixels. Wired on desktop via window.scale_factor() (input events,
window_size, and the render node's own seed); the Android side (density
via DisplayMetrics, touch coordinates, layout root size) is the next
commit.

Also adds WgpuErrorLog and a per-frame atlas-grow counter
(GpuTextures::take_pages_grown), both plumbing for the Android
diagnostics page in the next commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:36:29 -04:00
irisandClaude Fable 5.1 b887a96765 docs/bench: iris's first phone report, before the phone fixes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:26:09 -04:00
irisandClaude Fable 5.1 2aaa3733c3 docs/bench: the Compose P0 report from Iris's phone, verbatim
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:20:05 -04:00
irisandClaude Fable 5.1 46246ea511 iris: turn the phone bind-group-layout crash into a diagnostic, drop force-gles from phone builds
UiRenderNode::new used to let a wgpu validation error reach the default
uncaptured-error handler and panic, which is what aborted the P0 bench APK
on Iris's phone in AndroidRenderer::new with only "wgpu error: Validation
Error" surviving into the truncated crash report. It now wraps creation in
wgpu error scopes and returns Result<Self, String>; the Android backend
turns a failure into the adapter's identity, the limits/downlevel flags a
layout validates against, and wgpu's own error chain, logged as one logcat
line and shown on screen (IrisView.showRendererError) instead of crashing.

Auditing every bind-group-layout entry against wgpu-core's own validation
source names the likely cause: masks_layout's move_offsets storage buffer
is visible to the vertex stage, which Vulkan grants unconditionally but
GLES gates on the driver's own vertex-stage SSBO support -- and the
delivered APK was built with force-gles, a flag meant only to force the
*emulator* onto GLES for one frame-time measurement, that build-apk.sh's
default feature list applied to every arm64 build regardless of target.
Its default no longer includes force-gles.

Testing the diagnostic (by inducing an artificial validation error) also
found and fixed a real reentrancy bug: calling Activity.setContentView
synchronously from inside a ViewPeer callback re-enters the same peer's
RefCell borrow through onFocusChanged, aborting with "RefCell already
borrowed". Deferred through the same push_dynamic_deferred_callback
mechanism raise_if_enabled already uses.

Full audit, verification, and the named hypothesis are in RUST.md's P0
box ("iris bench crash on the phone, 2026-09-06"); the API change is in
IRIS.md. Nobody on this session has the phone, so this is unconfirmed
against real hardware -- the point of (1) is that the next run says so
either way.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:05:07 -04:00
irisandClaude Fable 5.1 a27fbdb029 docs: close I5's three blocked verifications (24/24-swipe, backend isolation, cold-boot bench)
Ran three clean iris-scroll.sh passes on a cold -gpu host boot (all
24/24 swipes confirmed scrolling via clustered render() timestamps, not
inferred from frame count) and retook the host-GPU table's iris row as
a best-of-three. EMU_GPU=software + force-gles still cannot produce a
GLES number on this hardware -- after the earlier compute-limit crash
was fixed, device creation now aborts on max_storage_buffer_binding_size
instead (SwiftShader ES 3.0 has no SSBOs, and shader.wgsl reads four
var<storage> buffers unconditionally), so the SwiftShader-Vulkan-vs-GLES
question is closed as structurally unanswerable rather than answered.
A fresh cold-boot run-bench.sh reading for P0's bench build is in line
with the earlier warm-AVD readings, closing that box's own caveat too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 22:39:27 -04:00
irisandClaude Fable 5.1 c07d544aeb event-model, client-core, transcript-ui: carry main's LimitReached event
The merge that brought main into rustify added Event::LimitReached to the
server's drivers, but on this branch the enum lives in event-model, which
the merge left without it, so ai-server (and ui-sandbox.sh) did not build.
Definition copied from main's driver.rs; the fold mirrors TranscriptItems.kt's
LimitNote; the iris row shows the epoch until P1 brings a time formatter.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 22:18:38 -04:00
irisandClaude Fable 5.1 46d3a6fd41 docs: record the streaming-rebuild fix, its numbers, and the new scripts
RUST.md's P0 box gets the fix, the before/after streaming-phase numbers
(with their caveats), the build-apk.sh/run-bench.sh scripts, and what the
dropout-fix pass's three remaining verifications are blocked on (the
sandbox ai-server currently fails to build, unrelated to this change).
IRIS.md gets the List::replace_back/clear and TranscriptScreen::apply
API entries. AGENTS.md's rigs section gets one sentence on each script.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 22:14:35 -04:00
irisandClaude Fable 5.1 5655fa8093 iris-android-app: build-apk.sh and run-bench.sh
Wraps the cargo-ndk/Gradle/keystore/apksigner build and the
install/tap-by-label/read-report cycle that P0's work had been retyping
by hand, so it stops costing time and mistakes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 22:14:35 -04:00
irisandClaude Fable 5.1 b3b1d47dd6 iris: streaming a transcript event no longer rebuilds the whole screen
Every client (bench_client, transcript_client, desktop-app) refolded and
rebuilt the ~3,200-row widget tree from scratch per SSE event, which is
the streaming-phase cost the P0 benchmark gate would otherwise measure
against a Compose app that updates one row. iris::widget::List gains
replace_back (swap the last row's widget in place, keeping its slot so a
pinned list stays pinned) and clear (the full-rebuild fallback);
transcript_ui::TranscriptScreen::apply diffs the folded row lists and
picks the cheapest update -- unchanged, append, replace-the-last-row, or
(rare regroup) a full rebuild, counted. TextEditCtx::set_with_spans lets a
row's text and span list land together on a streamed update.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 22:14:27 -04:00
iris 50fe4828a2 Merge branch 'worktree-agent-a27094a7db775552a' into tmp-merge 2026-09-05 21:37:12 -04:00
69 changed files with 6622 additions and 401 deletions

No files matched your search

+7
View File
@@ -261,6 +261,13 @@ Each exists because something was invisible without it.
framework, from `atrace` text output with no trace processor needed. It is
how the cost of a layout node per link was attributed to the framework
rather than guessed at.
- **`iris/android-app/build-apk.sh [debug|release] [--abi ...] [--features
...]`** builds iris-android-app's cdylib (`cargo ndk`) and its APK
(Gradle) in one step and verifies the result (`aapt2`/`apksigner`), and
**`iris/android-app/run-bench.sh [--apk PATH]`** installs it on this
checkout's own emulator, taps "Run benchmark" by label, and prints the
report -- written so the P0 build/install/tap/read-report cycle stops
being retyped by hand each time (docs/RUST.md's P0 box).
### Driving the UI
@@ -3,9 +3,13 @@ package com.example.aiapp
import android.content.Context
import android.os.BatteryManager
import android.os.Process
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.animateScrollBy
import android.view.View
import androidx.compose.foundation.gestures.FlingBehavior
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.ui.focus.FocusRequester
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import java.io.File
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
@@ -19,27 +23,84 @@ import kotlinx.coroutines.launch
* here against [LazyListState] and [BenchFixture] directly. Only reachable from the `bench` build
* (see [SessionSettingsDialog]'s `onRunBenchmark`), but compiled into every build for the reason
* [BenchFixture]'s doc comment gives.
*
* **v2 (2026-09-06)**, asked for by Iris because the v1 fling was too gentle to stress-test the
* scroll path and said nothing about typing or the keyboard. Four phases now, each a slice of the
* same [FrameStats] recording ([FrameStats.markPhase]/[FrameStats.phaseLines] -- one recorder, not
* two): **fling** (real `FlingBehavior`, not `animateScrollBy`), **stream** (unchanged from v1),
* **type** (600 fixed characters into the real composer `TextFieldValue`, then deleted), and
* **keyboard** (five show/hide cycles). The exact constants below are also written into
* `docs/RUST.md`'s P0 box, "Benchmark v2 (2026-09-06)", so the iris half implements the identical
* spec -- changing a number here without updating that box makes the two apps measure different
* things while looking like the same benchmark.
*/
object BenchRun {
/** transcript-bench.sh's default: 6 cycles of 4 swipes each, 900px over 200ms, 500ms apart. */
/** transcript-bench.sh's default: 6 cycles of 4 swipes each, kept as the pre-v2 comparison. */
private const val CYCLES = 6
private const val SWIPE_PX = 900f
private const val SWIPE_MS = 200
private const val SWIPE_PAUSE_MS = 500L
/**
* Fling phase (v2): a real fling through the list's own [FlingBehavior], not `animateScrollBy`
* -- Iris's ask was that it "travel way faster" than the old tween-based swipe, and a tween can
* never exceed the distance it is told to cover in the time it is given, while a real fling
* decays from an initial velocity the way a finger flick does. 12,000 px/s is roughly a hard,
* fast flick on a ~420dp/in device (about 30 dp/ms-equivalent initial speed); chosen well above
* the ~4,500 px/s a moderate `animateScrollBy` swipe implies, so this phase exercises the fast
* end of what the platform's fling decay produces rather than the gentle one v1 measured.
*/
private const val FLING_VELOCITY_PX_S = 12_000f
private const val FLING_COUNT = 8
private const val FLING_SETTLE_CAP_MS = 3_000L
private const val FLING_PAUSE_MS = 300L
/** stream-bench.sh's shape: a real reply arrives as many small deltas, not one big write. */
private const val STREAM_EVENTS_PER_SEC = 20
private const val STREAM_SECONDS = 20
/**
* Scrolls, then streams, then returns the extra report lines P0 asked for (CPU time, peak RSS,
* battery current) -- [FrameStats] and [DebugStats] are reset first, exactly as
* `copyRenderReport` resets them, so the two accountings cover the same stretch of work.
* Type phase (v2): sentences built from long, multisyllabic words so the composer actually
* wraps across lines rather than fitting one, and long enough (600 chars) that the composer's
* own height grows over several frames, pushing the transcript above it upward the same way a
* real long message does. Exactly this string is also in `docs/RUST.md`'s P0 box so the iris
* half types the identical content.
*/
const val TYPE_TEXT =
"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!!!"
private const val TYPE_CHAR_DELAY_MS = 50L
/**
* Keyboard phase (v2): five show/hide cycles, a second apart, is enough to see whether the
* transition is ever actually observed rather than being a one-off fluke either way.
*/
private const val KEYBOARD_CYCLES = 5
private const val KEYBOARD_SHOW_WAIT_MS = 1_000L
private const val KEYBOARD_HIDE_WAIT_MS = 1_000L
/**
* Scrolls, flings, streams, types and toggles the keyboard, then returns the extra report lines
* P0 asked for (per-phase travel/typing/keyboard counts, plus CPU time, peak RSS, battery
* current) -- [FrameStats] and [DebugStats] are reset first, exactly as `copyRenderReport`
* resets them, so the two accountings cover the same stretch of work.
*/
suspend fun run(
context: Context,
scope: CoroutineScope,
listState: LazyListState,
flingBehavior: FlingBehavior,
composerFocus: FocusRequester,
setComposerText: (String) -> Unit,
view: View,
): List<String> {
FrameStats.reset()
DebugStats.reset()
@@ -55,34 +116,10 @@ object BenchRun {
}
}
// The swipe loop: transcript-bench.sh's four swipes per cycle are two drags toward newer
// content and two back, so a cycle returns to where it started and the whole loop measures
// steady-state scrolling rather than travelling somewhere new each time.
repeat(CYCLES) {
repeat(2) {
listState.animateScrollBy(SWIPE_PX, tween(SWIPE_MS))
delay(SWIPE_PAUSE_MS)
}
repeat(2) {
listState.animateScrollBy(-SWIPE_PX, tween(SWIPE_MS))
delay(SWIPE_PAUSE_MS)
}
}
// Pinned to the newest end before streaming starts, the way stream-bench.sh's "Jump to
// latest" tap is -- a reply streamed into a list parked further back arrives off-screen and
// the report would show nothing happened.
listState.scrollToItem(0)
var sent = 0
val total = STREAM_EVENTS_PER_SEC * STREAM_SECONDS
while (sent < total && BenchFixture.remainingStreamEvents() > 0) {
BenchFixture.pushNextLiveEvent()
sent++
delay(1000L / STREAM_EVENTS_PER_SEC)
}
// Lets the last few deltas land and draw before the report is read.
delay(300)
val travel = runFlingPhase(listState, flingBehavior)
val sent = runStreamPhase()
runTypePhase(listState, composerFocus, setComposerText, view)
val keyboard = runKeyboardPhase(context, view)
samplerJob.cancel()
val cpuMs = Process.getElapsedCpuTime() - cpuStartMs
@@ -90,13 +127,158 @@ object BenchRun {
val batteryLine = battery.finish()
return listOf(
" scroll: $CYCLES cycles (${CYCLES * 4} swipes), streamed $sent/$total fixture events",
" fling: $FLING_COUNT flings out + $FLING_COUNT back at" +
" ${FLING_VELOCITY_PX_S.toInt()}px/s, travel $travel",
" scroll: $CYCLES cycles (${CYCLES * 4} swipes, legacy tween), " +
"streamed $sent/${STREAM_EVENTS_PER_SEC * STREAM_SECONDS} fixture events",
" type: ${TYPE_TEXT.length} characters inserted then deleted, one per" +
" ${TYPE_CHAR_DELAY_MS}ms",
keyboard,
" process CPU time over this run: ${cpuMs}ms",
rssLine,
batteryLine,
)
}
/**
* Phase 1: starting pinned at the newest end, [FLING_COUNT] flings away from it (toward older
* messages) through the list's real fling path, then [FLING_COUNT] back. Positive velocity here
* matches this list's existing scroll-offset convention (`TranscriptList`'s `reverseLayout`
* pins index 0 -- the newest item -- at the bottom; a positive scroll offset moves the viewport
* toward higher indices, i.e. away from the newest end and toward older content), the same sign
* the pre-v2 swipe loop below already used for its first two swipes.
*/
private suspend fun runFlingPhase(
listState: LazyListState,
flingBehavior: FlingBehavior,
): String {
FrameStats.markPhase("fling")
listState.scrollToItem(0)
val start = position(listState)
repeat(FLING_COUNT) {
listState.scroll { with(flingBehavior) { performFling(FLING_VELOCITY_PX_S) } }
waitForSettle(listState)
delay(FLING_PAUSE_MS)
}
val outward = position(listState)
repeat(FLING_COUNT) {
listState.scroll { with(flingBehavior) { performFling(-FLING_VELOCITY_PX_S) } }
waitForSettle(listState)
delay(FLING_PAUSE_MS)
}
val back = position(listState)
return "start=$start outward=$outward end=$back"
}
private fun position(listState: LazyListState) =
"idx=${listState.firstVisibleItemIndex}/off=${listState.firstVisibleItemScrollOffset}px"
/** Belt-and-suspenders on top of `performFling` already suspending until its own decay ends. */
private suspend fun waitForSettle(listState: LazyListState) {
val startedAt = System.currentTimeMillis()
while (
listState.isScrollInProgress &&
System.currentTimeMillis() - startedAt < FLING_SETTLE_CAP_MS
) {
delay(16)
}
}
/**
* Phase 2 (unchanged from v1): pinned to the newest end before streaming starts, the way
* stream-bench.sh's "Jump to latest" tap is -- a reply streamed into a list parked further back
* arrives off-screen and the report would show nothing happened.
*/
private suspend fun runStreamPhase(): Int {
FrameStats.markPhase("stream")
var sent = 0
val total = STREAM_EVENTS_PER_SEC * STREAM_SECONDS
while (sent < total && BenchFixture.remainingStreamEvents() > 0) {
BenchFixture.pushNextLiveEvent()
sent++
delay(1000L / STREAM_EVENTS_PER_SEC)
}
// Lets the last few deltas land and draw before the next phase starts.
delay(300)
return sent
}
/**
* Phase 3: focuses the real composer, shows the keyboard if the platform allows it, then types
* [TYPE_TEXT] one character at a time through the same `TextFieldValue` state a real keystroke
* updates, and deletes it the same way -- this is what exercises wrapping and the transcript
* being pushed upward, not a single big write.
*/
private suspend fun runTypePhase(
listState: LazyListState,
composerFocus: FocusRequester,
setComposerText: (String) -> Unit,
view: View,
) {
FrameStats.markPhase("type")
listState.scrollToItem(0)
composerFocus.requestFocus()
showIme(view.context, view)
// 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.
delay(300)
var typed = ""
for (ch in TYPE_TEXT) {
typed += ch
setComposerText(typed)
delay(TYPE_CHAR_DELAY_MS)
}
delay(200)
while (typed.isNotEmpty()) {
typed = typed.dropLast(1)
setComposerText(typed)
delay(TYPE_CHAR_DELAY_MS)
}
}
/**
* Phase 4: [KEYBOARD_CYCLES] show/hide cycles through the same [WindowInsetsControllerCompat]
* path a real IME toggle goes through, reporting how many of each were actually confirmed by
* [android.view.WindowInsets.isVisible] rather than assumed from having asked -- UI_RULES:
* never present an inferred value as a measured one. If the platform never shows it even once,
* this says so in words rather than reporting a phase with no keyboard in it.
*/
private suspend fun runKeyboardPhase(context: Context, view: View): String {
FrameStats.markPhase("keyboard")
var shown = 0
var hidden = 0
repeat(KEYBOARD_CYCLES) {
showIme(context, view)
delay(KEYBOARD_SHOW_WAIT_MS)
if (imeVisible(view)) shown++
hideIme(context, view)
delay(KEYBOARD_HIDE_WAIT_MS)
if (!imeVisible(view)) hidden++
}
return if (shown == 0) {
" keyboard: could not be shown ($KEYBOARD_CYCLES attempts, 0 confirmed visible)"
} else {
" keyboard: shown $shown/$KEYBOARD_CYCLES, hidden $hidden/$KEYBOARD_CYCLES" +
" (confirmed via isImeVisible)"
}
}
private fun controller(context: Context, view: View): WindowInsetsControllerCompat? {
val window = context.activity()?.window ?: return null
return WindowInsetsControllerCompat(window, view)
}
private fun showIme(context: Context, view: View) {
controller(context, view)?.show(WindowInsetsCompat.Type.ime())
}
private fun hideIme(context: Context, view: View) {
controller(context, view)?.hide(WindowInsetsCompat.Type.ime())
}
private fun imeVisible(view: View): Boolean =
ViewCompat.getRootWindowInsets(view)?.isVisible(WindowInsetsCompat.Type.ime()) ?: false
/** VmHWM from /proc/self/status: the process's high-water mark, in kB, since it started. */
private fun peakRssLine(): String {
val kb =
@@ -134,6 +134,13 @@ fun debugReport(
* render-report button reads exactly as it did before this existed.
*/
extra: List<String> = emptyList(),
/**
* Bench v2's per-phase frame accounting ([FrameStats.phaseLines]) --
* fling/stream/type/keyboard, each a slice of the same frames the whole-run sections below
* still cover in full. Empty on every path but the scripted bench run, same reasoning as
* [extra].
*/
phaseFrames: List<String> = emptyList(),
): String = buildString {
appendLine("ai-app render report")
appendLine(device)
@@ -148,6 +155,11 @@ fun debugReport(
appendLine("transcript:")
transcript.forEach { appendLine(it) }
appendLine()
if (phaseFrames.isNotEmpty()) {
appendLine("per phase:")
phaseFrames.forEach { appendLine(it) }
appendLine()
}
appendLine("frames:")
frames.forEach { appendLine(it) }
appendLine()
@@ -42,6 +42,21 @@ object FrameStats {
private val gpu = ArrayList<Long>()
private var since = System.currentTimeMillis()
/**
* Where a named phase of a scripted run (bench v2's fling/stream/type/keyboard) started, as an
* index into [total] and a wall-clock time -- not a second recorder, just a mark on this one,
* so a phase's frames are the same [FrameMetrics] the whole-run report already has, sliced.
*/
private data class PhaseMark(val name: String, val startIndex: Int, val startMs: Long)
private val phaseMarks = ArrayList<PhaseMark>()
/** Call at the start of each named phase of a scripted run; see [BenchRun]. */
@Synchronized
fun markPhase(name: String) {
phaseMarks += PhaseMark(name, total.size, System.currentTimeMillis())
}
@Synchronized
fun add(metrics: FrameMetrics) {
// The first frame after a window opens includes inflating it and is nobody's scroll.
@@ -69,6 +84,7 @@ object FrameStats {
listOf(total, waited, input, animation, layout, draw, sync, issue, swap, gpu).forEach {
it.clear()
}
phaseMarks.clear()
since = System.currentTimeMillis()
}
@@ -95,6 +111,38 @@ object FrameStats {
) + if (gpu.isEmpty()) emptyList() else listOf(phase("gpu ", gpu))
}
/**
* One block per [markPhase] call: how many frames landed between that mark and the next (or the
* end of the run, for the last one), how many were late, the total/p50/p90/p99, the worst
* single frame, and how long the phase actually ran. Marks with no frames between them (a phase
* that finished before a frame was drawn) still get a line rather than being silently dropped
* -- UI_RULES' "say what you don't know" applies to a phase as much as to a single number.
*/
@Synchronized
fun phaseLines(refreshHz: Float): List<String> {
if (phaseMarks.isEmpty()) return emptyList()
val budget = if (refreshHz > 0) 1000.0 / refreshHz else 16.7
val lines = ArrayList<String>()
phaseMarks.forEachIndexed { i, mark ->
val endIndex = if (i + 1 < phaseMarks.size) phaseMarks[i + 1].startIndex else total.size
val endMs =
if (i + 1 < phaseMarks.size) phaseMarks[i + 1].startMs
else System.currentTimeMillis()
val samples = total.subList(mark.startIndex, endIndex)
val seconds = (endMs - mark.startMs) / 1000.0
lines += " ${mark.name}: ${samples.size} frames over ${"%.1f".format(seconds)}s"
if (samples.isEmpty()) {
lines += " no frames recorded in this phase"
} else {
val late = samples.count { it / 1_000_000.0 > budget }
lines += " late: $late (${percent(late, samples.size)})"
lines += " " + phase("total ", samples)
lines += " worst ${"%.1fms".format(samples.max() / 1_000_000.0)}"
}
}
return lines
}
/** How long the frames recorded here spent in their draw phase, and how many there were. */
@Synchronized fun drawPhase(): Pair<Long, Int> = draw.sum() to draw.size
@@ -187,13 +187,20 @@ class MainActivity : ComponentActivity() {
model = null,
keepsOwnTranscript = false,
permissionMode = null,
effort = null,
takesEffort = false,
imported = false,
notify = false,
autoResume = false,
autoResumeMessage = "",
resumeAt = null,
cwd = null,
contextTokens = null,
maxImageEdge = null,
usageProvider = null,
status = "idle",
lastActivity = 0.0,
subagents = 0,
)
// launchMode="singleTop": an enrollment scan, or a notification tapped while the app is open,
@@ -12,6 +12,7 @@ import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
@@ -64,12 +65,15 @@ import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextRange
@@ -354,6 +358,17 @@ fun SessionScreen(
// `rememberSaveable`, and this screen restores by its own anchor instead -- two restores would
// fight over the first frame.
val listState = remember(address) { LazyListState() }
// The list's own fling path -- what a real flick decays through -- captured here so BenchRun's
// fling phase can drive `LazyListState.scroll` through exactly the `FlingBehavior` this
// screen's
// `TranscriptList` already uses by not overriding it (its `LazyColumn` takes no `flingBehavior`
// argument, so this is the same default it gets).
val flingBehavior = ScrollableDefaults.flingBehavior()
// Where BenchRun's type phase focuses before it types, and the view it toggles the keyboard on
// -- both bench-only, but cheap enough (a remembered object, a CompositionLocal read) to hold
// unconditionally rather than behind a second code path only the bench build compiles.
val composerFocus = remember { FocusRequester() }
val view = LocalView.current
// Whether the newest message is on screen right now. The list is reversed, so the newest end is
// the scrolling start: nothing behind you is exactly being at the bottom. Asked of the scroll
// state rather than of item indices, because a zero-height first item makes an index ambiguous.
@@ -1256,6 +1271,10 @@ fun SessionScreen(
FrameStats.drawPhase().let { (nanos, count) -> drawAccounting(nanos, count) },
crash = lastCrash(context),
extra = extra,
// Empty outside a BenchRun.run pass -- copyRenderReport's own reset below clears
// the
// marks along with everything else, so an ordinary copy never has any to show.
phaseFrames = FrameStats.phaseLines(context.refreshHz()),
)
context.copyToClipboard("ai-app render report", report)
// Also to the log, so a session driving the app over adb can read the same report the
@@ -1270,15 +1289,24 @@ fun SessionScreen(
Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT).show()
}
val copyRenderReport = { buildAndCopyReport() }
// Bench build only: P0's scripted scroll-and-stream benchmark (BenchRun.kt), against the
// fixture session opened below instead of a real server. Null everywhere else -- see
// Bench build only: P0's scripted fling/stream/type/keyboard benchmark (BenchRun.kt), against
// the fixture session opened below instead of a real server. Null everywhere else -- see
// [SessionSettingsDialog]'s onRunBenchmark.
val runBenchmark: (() -> Unit)? =
if (BuildConfig.FIXTURE_MODE) {
{
settingsOpen = false
scope.launch {
val extra = BenchRun.run(context, scope, listState)
val extra =
BenchRun.run(
context = context,
scope = scope,
listState = listState,
flingBehavior = flingBehavior,
composerFocus = composerFocus,
setComposerText = { text -> input = atEnd(text) },
view = view,
)
buildAndCopyReport(extra)
}
}
@@ -1777,7 +1805,10 @@ fun SessionScreen(
input = it
saveDraft(context, summary.id, it.text)
},
modifier = Modifier.fillMaxWidth(),
// BenchRun's type phase requests focus on this exact field
// (`composerFocus`)
// so it types through the real composer rather than a stand-in.
modifier = Modifier.fillMaxWidth().focusRequester(composerFocus),
// No longer "(+image)": the images are on screen above this, and a
// placeholder saying so said it in words beside the thing itself.
placeholder = { Text("Message") },
+16
View File
@@ -112,6 +112,13 @@ pub enum TranscriptItem {
ClearedNote {
seq: u64,
},
/// The account's usage limit stopped the turn; `resets_at` is epoch
/// seconds when the dialect said when it lifts (`LimitNote` in
/// `TranscriptItems.kt`).
LimitNote {
seq: u64,
resets_at: Option<f64>,
},
CompactedNote {
seq: u64,
pre_tokens: Option<u64>,
@@ -131,6 +138,7 @@ impl TranscriptItem {
| Self::CommandRow { seq, .. }
| Self::Note { seq, .. }
| Self::ClearedNote { seq }
| Self::LimitNote { seq, .. }
| Self::CompactedNote { seq, .. } => *seq,
Self::QuestionCard(card) => card.seq,
}
@@ -525,6 +533,14 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
items.push(TranscriptItem::ClearedNote { seq });
items
}
Event::LimitReached { resets_at } => {
let mut items = items.to_vec();
items.push(TranscriptItem::LimitNote {
seq,
resets_at: *resets_at,
});
items
}
Event::Compacted {
pre_tokens,
post_tokens,
+20 -14
View File
@@ -264,24 +264,30 @@ marked **DEFERRED** are ones the agent chose not to decide alone.
| app | build | GPU mode | frames | janky % | p50 | p90 | p99 | worst | cpu p50 | gpu-wait p50 |
|---|---|---|---|---|---|---|---|---|---|---|
| Compose (in-app report) | debug | host (virgl) | 1268 | 96.4% late | 20.0ms | 28.4ms | 37.7ms | -- | -- | -- |
| iris (`FrameReport`) | **release**, `force-gles` | host (virgl) | 62 | 41.94% | 15.0ms | 21.8ms | 37.1ms | 37.1ms | 0.2ms | 12.9ms |
| iris (`FrameReport`), **best of three, 2026-09-05** | release, `force-gles` | host (virgl) | 439 | 46.24% | 15.7ms | 23.3ms | 31.2ms | 57.4ms | 1.2ms | 13.2ms |
Under real GPU rendering iris's median frame is *faster* than
Compose's, not the 2-3x-slower shape the software-mode table shows. A
new split inside `FrameReport` (redraw-to-submit vs. submit-to-present,
commit `e2a1fad`) says why: iris's own CPU work per frame is a median
0.2ms -- almost the entire frame is time spent handing the frame to the
~1ms -- almost the entire frame is time spent handing the frame to the
driver, not in iris's layout/text/primitive code. This is consistent
with the earlier software-mode gap being mostly SwiftShader's CPU
rasterisation cost rather than an iris-specific slowness, but is not
proof of it: a same-mode software `force-gles` run to isolate the
backend crashed for an unrelated reason (SwiftShader's GL path reports
itself as OpenGL ES 3.0, which has no compute shaders, and iris's device
request assumes them unconditionally) — real scope to fix, not done
here — and the two apps' frame populations still differ in kind the same
way the software-mode caveats describe. A real intermittent touch-
scroll dropout was also reproduced this pass (six consecutive swipes
produced zero redraws while taps kept working; an identical retry then
succeeded) and is not explained. RUST.md's I5 box, "Where iris's frame
time goes, 2026-09-05, the `-gpu host` pass," has the full account. The
iris-vs-Masonry choice itself is still Iris's to make.
rasterisation cost rather than an iris-specific slowness. **Still not
proof, and now closed as unanswerable rather than merely untaken**: a
same-mode software `force-gles` run to isolate the backend was retried
2026-09-05 after fixing the compute-limit crash the first attempt hit,
and hit a second, structural wall instead — SwiftShader's ES 3.0 GL
path has no storage-buffer capacity at all, and `shader.wgsl` reads
`var<storage>` buffers unconditionally, so reaching that path needs a
shader rewrite, not a limits fix (RUST.md's I5 box, "The three
remaining I5 verifications, closed 2026-09-05," item 2). The
intermittent touch-scroll dropout this pass also reproduced is
root-caused and fixed as of the same date (a missed `ACTION_DOWN` on a
row's padding/header left `DragArbiter` stuck in `Idle`); three clean
`iris-scroll.sh` runs post-fix each scrolled all 24/24 swipes, replacing
the single-attempt 62-frame reading this table used to carry. RUST.md's
I5 box, "Where iris's frame time goes, 2026-09-05, the `-gpu host`
pass," and "The three remaining I5 verifications, closed 2026-09-05,"
have the full account. The iris-vs-Masonry choice itself is still
Iris's to make.
+243
View File
@@ -8,6 +8,91 @@ capability that moved. Small and trivial changes do not go here.
An entry gives the date, what changed, why, and a short before/after where
it helps judge the change without the session that made it. Newest first.
## 2026-09-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")
`iris::widget::List` gained a real fling: `fling(velocity_px_per_s)` starts
one (cancelled by the next touch-down via `cancel_fling`, or automatically
once it settles or reaches loaded content's start/end), `is_scrolling()`
reports whether one is running, and `tick_fling(now: Instant) -> bool`
advances it and returns whether it is still going -- a caller that owns a
`RequestRedraw` handle can hand it to the list once via the new
`set_redraw_handle`, after which `List` re-arms its own next frame while
flinging with no further polling needed; a caller driving a scripted
benchmark instead calls `tick_fling` itself in a loop, same as it already
drives `scroll`.
The physics is `iris::sense::FlingCalculator` + `VelocityTracker`
(`sense.rs`, beside `DragArbiter`): a port of AOSP `SplineOverScroller`'s
deceleration curve (the same one Compose's own `ScrollableDefaults.
flingBehavior()` uses), cited at the definition, so a fling here travels
the same distance a Compose `LazyColumn` would for the same initial
velocity. `VelocityTracker` estimates that velocity from the drag's last
~100ms of samples rather than one frame's last delta. Unit-tested:
velocity from known samples, fling distance/duration against the closed-
form spline result (within 1%), cancel-on-touch, and the start/end clamp
(a fling stops rather than scrolling into content that was never loaded).
Before: a touch-drag panned exactly as far as the finger moved and stopped
dead on release. After: releasing mid-drag continues scrolling and
decelerates, matching the muscle memory every other Android scroll view
already trained. `transcript_ui::selection::Selection::drag` wires this in
-- a release only flings if the gesture had committed to panning
(`DragArbiter::is_panning`, new), never a selection or an undecided tap.
## 2026-09-06: `UiRenderNode::new` returns `Result`, not `Self` (RUST.md's P0 box, phone-crash fix)
`iris_core::UiRenderNode::new(device, queue, config)` now returns
`Result<Self, String>` instead of `Self`. Why: it used to let a bind-group-
layout validation failure reach wgpu's default error handler, which panics
with no way for a caller to intervene -- exactly what aborted the P0 bench
APK on Iris's phone with the crash report truncated to "wgpu error:
Validation Error" and nothing else recoverable. It now runs its creation
calls inside wgpu error scopes and returns the full error text (wgpu's own
"Caused by" chain) as `Err` instead.
Both callers changed to match: `android::render::AndroidRenderer::new`
itself now returns `Result<Self, String>` too, building a fuller report
(adapter identity, the limits/downlevel flags a layout validates against,
then wgpu's text) on failure -- its caller,
`android::view::IrisViewPeer::surface_changed`, logs that report as one
logcat line and shows it on screen (a new `IrisView.showRendererError`,
called via an ordinary JNI method call rather than a new `native fn`)
instead of letting the process abort. `default::render::UiRenderer::new`
(the winit/desktop backend) still panics on failure -- there is no
on-screen fallback there -- but the panic message is now the same full
text rather than whatever wgpu's own handler would have printed.
No change for an app that never constructs a `UiRenderNode` directly (every
current one goes through `AndroidRenderer`/`UiRenderer`), but anyone who
does needs an `?`/`.expect()`/`match` at the call site now. Full audit and
the named hypothesis for what actually failed on the phone are in
RUST.md's P0 box, "iris bench crash on the phone, 2026-09-06."
## 2026-09-05: `AndroidAppState::platform_ready` (RUST.md's P0 box, iris half)
Added a second, optional lifecycle method to `iris::android::AndroidAppState`
@@ -437,3 +522,161 @@ with a number instead of a guess (RUST.md's I5 box).
blocked handing the frame to the driver," not a confirmed GPU-completion
time. Enough to separate "iris is slow building the frame" from "iris is
slow handing it off," not enough to claim an exact GPU budget.
## 2026-09-05: `List::replace_back`/`List::clear`, and `TranscriptScreen::apply`
Fixes the "every client refolds and rebuilds the whole widget tree per
streamed event" cost RUST.md's P0 box measured (20 events/second against a
~3,200-row transcript). Two small additions to `iris::widget::List`
(`iris/src/widget/list.rs`), plus one new method on `transcript-ui`'s
`TranscriptScreen`.
- **`List::replace_back(row: ListRow) -> Option<ListRow>`**: swaps the
*last* row's widget for a new one without moving it — same slot index,
so an anchor already pinned there (in particular a list flush with its
own end) stays pinned, and a `List` scrolled elsewhere is untouched.
`None` if the list is empty. `RowKey` may differ between the old and new
row; only `heights`/`extents` care, and both are invalidated for the
evicted key the same way `pop_back` already does.
- **`List::clear()`**: drops every loaded row and resets to `List::new`'s
state (`more_before`/`more_after` untouched — a caller that wants those
cleared too calls `set_more_before(None)`/`set_more_after(None)` itself).
The fallback path for a change that touches more than the tail.
- **`transcript_ui::TranscriptScreen::apply(&self, rsc, old: &[TranscriptItem], new: &[TranscriptItem])`**:
the incremental alternative to rebuilding the whole screen from
`transcript_ui::build_tree` on every folded event. Diffs the two
`group_tool_runs` outputs and picks the cheapest update: nothing changed
(no-op), a pure append (`push_row`, unchanged cost), or — the common
streaming case, a delta into a still-open assistant message — a rebuild
of just the one changed row via `List::replace_back`, with any further
new rows appended after it. A row changing *before* the tail (only
`group_tool_runs` retroactively grouping tool calls into a run does
this) falls back to `List::clear` plus a full rebuild, counted in
`TranscriptScreen::take_rebuilds()`. `bench_client.rs`, `transcript_client.rs`
and `desktop-app/app.rs` all call this now instead of rebuilding on every
event; only the opening page (and `apply`'s own fallback) still calls
`build_tree`.
- **`TextEditCtx::set_with_spans(text, spans)`**: `set()` plus a fresh
`Vec<SpanStyle>` in one call, needed because a streamed row's markdown
re-renders to both a new string and a new span list on every delta and
the two have to land together — a stale span list drawn against new
text can point past its end. `set()` itself is unchanged (still clears
spans to none, as before).
Measured on this checkout's emulator (`iris/android-app/run-bench.sh`,
release, x86_64, `force-gles`): worst-frame and p99 during the streaming
phase dropped from 369.3ms/284.5ms (full rebuild per event, prior pass) to
~101130ms/~76103ms across three runs (this fix) — see RUST.md's P0 box
for the full numbers and the comparison's caveats (different AVD
instances, not a controlled A/B on identical hardware state).
## 2026-09-06: bundled fonts, `content_scale`, `AndroidAppState::on_insets_changed`
From RUST.md's P0 box, working Iris's first real-phone report (font/scale/
inset bugs the emulator never showed).
- **`TextData` now bundles Noto Sans + Noto Sans Mono** (regular/bold/
italic/bold-italic static faces, OFL) and registers them ahead of the
platform's own fonts in the `SansSerif`/`Monospace` generic-family
lists, rather than relying on the platform's font enumeration alone.
`TextData::font_diagnostics() -> FontDiagnostics` reports what was found
and what each style axis resolved to — logged once at startup and shown
on a screen's Diagnostics page if it has one. Adds ~3.6 MB uncompressed
to any binary linking `iris-core`; `build-apk.sh`'s own output says the
delivered (compressed) number.
- **`UiRenderNode::new`/`resize` now take the window size explicitly**
(`window_size: impl Into<Vec2>`) instead of deriving it from the
surface's physical `SurfaceConfiguration`. Existing callers pass a
*logical* size (physical ÷ density/scale-factor) now; this is what makes
a `font_size: 16.0` 16 dp instead of 16 raw device pixels on a
high-density phone. Before this, `scale_factor` did not exist anywhere
in the crate, on either platform.
- **`AndroidUiState::content_scale: f32`** (`DisplayMetrics.density`, read
once in `new_peer`) and the desktop equivalent (`window.scale_factor()`)
now divide every physical-pixel number before it reaches layout or
touch handling — see `content_scale`'s own field doc for the full list
of what depends on it.
- **New: `AndroidAppState::on_insets_changed(&mut self, rsc, LogicalInsets)`**,
a default-no-op hook called from `render()` exactly when
`AndroidUiState::insets()` changes. Nothing previously consumed
`insets().top` at all; a screen with chrome under the status bar
implements this to pad it, in the same logical units `content_scale`
converts everything else to.
- **New: `iris_core::WgpuErrorLog`**, installed via `Device::
on_uncaptured_error` on the Android device (wgpu's default handler is an
unconditional panic outside `UiRenderNode::new`'s own error scopes).
Explicit `Arc`-backed value passed to the callback and kept on
`AndroidRenderer`, not a global — a caller wanting one on desktop builds
its own the same way.
## 2026-09-06: `Len::dp`, physical pixels throughout, the keyboard glyph wipe
Iris's phone report on build a9232ac (screenshots): text now the right
size but blurry; the keyboard still wipes every glyph; the header buttons
have nothing behind them. All three are fixed; this entry is the public
API side. docs/LAYOUT.md has the layout-side writeup, docs/RUST.md's P0
box has the full investigation and the phone verification still to do.
- **The keyboard wipe was `surface_changed` rebuilding the whole renderer
on every resize**, including an IME-driven one — a fresh, empty glyph
atlas while the CPU-side glyph cache kept UV coordinates from the old
one. `surface_changed` now calls `AndroidRenderer::resize` (reconfigures
the surface and window uniform only) when a renderer is already live,
and only builds a new one when there genuinely isn't one yet.
- **`Len` has a third field, `dp`** (Android's dp / CSS's reference pixel,
1/160in), beside the existing `abs` (now explicitly *physical* pixels)
and `rel`/`rest`. `len_fns::dp`/`Len::dp` construct one, used exactly
like `abs`/`rel`/`rest` — `dp(16)` instead of a bare `16` wherever a
size should look the same physical size on any density. This is the
unit IRIS_TODO.md's "density-independent length unit" item asked for;
it replaces the previous stopgap (the whole rendered scene divided by
`content_scale` then implicitly stretched back up), which is also what
made text blurry — a glyph rasterised at the small, pre-stretch size and
then upscaled onto the real framebuffer.
- **`UiRenderState`/`Painter` gained `density()`/`set_density()`** (physical
pixels per dp). Every place a length resolves (`Len::apply_rest`,
`Size::to_uivec2`) now takes it; `Span::gap` and `Padding`'s four sides
moved from a bare `f32` to `Len` so they take `dp(...)` too. A bare
number anywhere is unaffected — still `abs`, physical pixels.
- **Text is rasterised at physical resolution now.** `TextBuffer::shape`
takes `density` and multiplies `font_size`/`line_height` (and any span
override) by it before handing them to parley, so the atlas holds a
bitmap at the size it is actually shown at rather than a low-resolution
one stretched afterward.
- **Everything at the Android boundary is physical pixels now** — window
size, touch coordinates, insets (`LogicalInsets` renamed
`WindowInsets`). The previous "logical" division by `content_scale` is
gone; `content_scale` now feeds `set_density` instead.
- Not yet verified on Iris's actual phone (this pass had no device) —
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
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.
+116
View File
@@ -117,6 +117,72 @@ order and what "done" looks like. Tick and date them in place.
screen wants the same thing (P1's own transcript rows already read
their content from a `TextEdit` for the same reason).
## From the phone, 2026-09-06
Found on Iris's own phone while working RUST.md's P0 box's phone-report
follow-ups. Recorded here rather than fixed in that pass, so a follow-up
agent takes them without colliding with that pass's `bench_client.rs`/
`android/view.rs`/`android/sense.rs` changes.
- [x] **Swiping has no momentum, fixed 2026-09-06.** `List::fling`/
`VelocityTracker`/`FlingCalculator` (`iris/src/widget/list.rs`,
`iris/src/sense.rs`) -- IRIS.md's 2026-09-06 entry has the full account.
Wired through `Selection::drag`'s release path, cancelled by the next
touch-down, clamped at the loaded content's start/end. Verified by unit
test (fling distance against the closed-form spline result, cancel-on-
touch, the clamp), not yet by an on-device or emulator feel-check --
that is still open.
- [x] **Scrolling down sometimes jitters the text, fixed 2026-09-06.**
Root-caused by reading `DragArbiter::update`'s `Undecided`-to-`Panning`
transition rather than by an on-device trace (no emulator was used this
pass): it was the first named suspect, not the second. `self.last` stays
at the press origin for every `Undecided` frame (nothing pans while the
gesture might still be a selection), so the frame that finally crosses
`DRAG_SLOP` returned `Pan(dy)` with `dy` measured from `press_start` --
the *whole* pre-threshold drag, applied to the list in one step, however
many frames it had taken to get there. Fixed by applying only the
excess past `DRAG_SLOP` on that one frame (`dy - DRAG_SLOP.copysign
(dy)`), the same "consume the slop, don't replay it" rule Android's own
touch handling follows. New regression test,
`crossing_the_slop_by_a_little_pans_by_a_little` (`iris/src/sense.rs`).
**Not yet done**: an emulator trace of the real per-frame offset
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
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
- [x] **Benchmarks**, not unit tests, run on demand (2026-09-05; a
@@ -385,3 +451,53 @@ do not duplicate it there.
redundant. Decide after the layout change lands, by writing a button
both ways and keeping the one that is shorter to explain; delete the
other rather than keeping two ways.
## Build (asked for by Iris, 2026-09-06): a density-independent length unit
- [x] **A third length kind beside relative and pixels, so display scales
"just work".** Done 2026-09-06 — `Len::dp`/`len_fns::dp`, resolved
against `UiRenderState`/`Painter::density()` at `apply_rest` time; text
additionally rasterises at the resolved (physical) size instead of
scaling a low-resolution bitmap afterward, which was making text blurry.
`Span::gap`/`Padding` moved from `f32` to `Len` so they take `dp(...)`
too; transcript-ui's row/composer padding and one example migrated.
`em` was not added — nothing in this pass needed a text-relative unit,
and `dp`'s own doc says why it and physical pixels are kept as separate
fields rather than one the caller pre-multiplies. Not yet verified on
Iris's own phone at two densities (this pass had no device) — see
docs/RUST.md's P0 box and docs/IRIS.md's 2026-09-06 entry for what to
check. Iris's words: "another length type similar to absolute &
relative, so instead there would be relative, pixels, and another unit
like em or whatever is standard. That way different display scales
should just work." Today a length is either a fraction of the parent
(`rest`/relative) or physical pixels, and the phone drew 16 px text at
roughly a third of its intended size until the P0 fixes applied the
display's scale factor globally. That global scale is a stopgap for the
benchmark; the real shape is a unit resolved against the display's
density at layout time — Android's `dp` / CSS's reference pixel is the
standard (1 unit = 1/160 in), with `em` as the text-relative option —
so a widget author writes `16.dp()` once and never sees the scale.
Done when: `Length` (or whatever the enum is called) has the third
variant; every place that resolves a length takes the density; the
examples and `transcript-ui` use the new unit for text sizes, padding
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
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.
+52
View File
@@ -861,6 +861,58 @@ unspecified rather than getting them wrong:
conditions, so the remaining slack was accepted rather than chased
further.
## Density: `Len::dp`, resolved at `apply_rest` time (2026-09-06)
Iris asked for a third length kind beside `abs` (physical pixels) and
`rel`/`rest` (a fraction of the parent) — IRIS_TODO.md's "density-
independent length unit" — after the P0 phone pass found 16px text
drawing at roughly a third size on a real phone. The fix that shipped
first (RUST.md's P0 box) was a global stopgap: divide the whole window
into a "logical" coordinate space (physical ÷ `content_scale`) and let
the shader's NDC mapping stretch it back up onto the real framebuffer.
That fixed the *size* but not the *sharpness* — a glyph rasterised at the
small, pre-stretch size and then stretched onto more physical pixels than
it has texels for is blurry, which is exactly what Iris's next report
said.
**The fix**: `Len` gained a `dp` field, resolved against a `density: f32`
(physical pixels per dp) at the one place a `Len` becomes a `UiScalar`
(`Len::apply_rest`) — `abs + dp * density`. `density` lives on
`UiRenderState` (`set_density`/`density()`) and `Painter` (`density()`),
set once from `DisplayMetrics.density` in `android::view::new_peer`; the
desktop backend has no per-monitor density wired up yet and stays at
`1.0`. Every layout call site that used to call `.apply_rest()`/
`.to_uivec2()` now passes `painter.density()` (nine call sites — `Span`,
`Sized`, `MaxSize`, `Aligned`, `Scroll`, `List::place`, and
`UiRenderState::reposition` itself). This also meant the Android
boundary's global logical-space stopgap could come out entirely: window
size, touch coordinates and insets are physical pixels again, matching
`AndroidRenderer`'s own swapchain resolution, with `dp` doing the
per-length work the global divide used to do for everything at once.
**Text is the case that needed more than the `Len` plumbing.** A widget's
`font_size`/`line_height` are plain `f32`, not routed through `Len` at
all (there is no sensible `rel`/`rest` for a font size). `TextBuffer::
shape` now takes `density` directly and multiplies `font_size`/
`line_height` (and any span override) by it before handing them to
parley — so the size that reaches both the line-breaker and the
rasteriser (`TextData::place`, which reads back whatever `shape` set) is
the display's *physical* size, and the glyph atlas holds a bitmap at the
resolution it is actually shown at. `GlyphKey.size` already keys on the
resolved size, so a cache entry is naturally per-physical-size with no
further change. The one caller with no `Painter` to read density from
(`TextEditCtx::layout`, cursor movement and hit-testing) reads a second
copy kept directly on `TextData` (`TextData::density`) instead — an
accepted duplication rather than threading a `Painter` into every input
handler for one field, the same tradeoff `AndroidRenderer::content_scale`
already makes for the Diagnostics page.
**What did not change**: `rel`/`rest` are unaffected (already
resolution-independent, a fraction of the parent). `Span::gap` and
`Padding`'s four sides moved from bare `f32` to `Len` so `dp(...)` works
on them the same as any other size; a bare number is still `abs`,
physical pixels, unchanged.
## For IRIS.md
When this lands, copy this entry into `IRIS.md` (newest first):
+1231 -34
View File
File diff suppressed because it is too large. Load diff
+73
View File
@@ -0,0 +1,73 @@
# Compose bench report from Iris's phone, 2026-09-06
The Compose half of P0 (RUST.md), run by Iris on her own phone and pasted
back verbatim. The iris half's report goes beside it in this directory
when it exists. Her caveat, worth keeping with the numbers: "I don't think
this is entirely fair because the UI for iris is more minimal" -- the
Compose screen also draws the usage bar, the status row and tool cards,
which the iris bench screen does not yet. Her impression of the iris build
before its first-touch bug: "it already feels very smooth so far".
What to read first: the phone runs at 120 Hz, so the budget is 8.3 ms;
`late` is measured against that. Compose's tail is the streaming phase --
`markdown reparsed while streaming: 396, 8.5ms mean, 25.8ms worst` and
`record: one block: 398, 6.3ms mean, 19.9ms worst` -- which is exactly the
path iris's `TranscriptScreen::apply` (replace the last row only) is meant
to beat. Process CPU over the run is 20.9 s of a 38.5 s run; peak RSS
587 MB; battery current mean 419 mA.
```
ai-app render report
device: Pixel 9 Pro XL (Google), Android 17
build: release
transcript:
43 events, 41 rows, 93 units loaded
viewport 1333px, 2 units visible
on screen: the list's own 0px, AssistantMsg 24520px
0 tool calls and 0 groups open
frames:
1613 frames over 38.5s at 120Hz (8.3ms budget)
late: 742 (46.0%)
total p50 7.7ms p90 29.2ms p99 41.1ms
waited p50 0.5ms p90 12.9ms p99 27.2ms
input p50 0.0ms p90 0.0ms p99 0.0ms
anim p50 1.1ms p90 5.8ms p99 9.5ms
layout p50 0.0ms p90 0.1ms p99 0.2ms
draw p50 0.4ms p90 15.9ms p99 27.9ms
sync p50 0.1ms p90 0.5ms p99 1.0ms
issue p50 1.1ms p90 1.7ms p99 3.0ms
swap p50 0.4ms p90 0.5ms p99 0.7ms
gpu p50 1.8ms p90 2.1ms p99 6.6ms
where the draw phase went:
draw phase 3.83ms per frame, of which:
the transcript: 0.25ms (measure 0.15, place 0.10, record 0.00)
everything else: 3.58ms (93%)
work since this was last copied:
draw: the whole transcript: 12, 0.2ms total, 0.0ms mean, 0.0ms worst
grouped tool runs: 398, 10.3ms total, 0.0ms mean, 0.1ms worst
markdown cut into pieces: 1, 0.0ms total, 0.0ms mean, 0.0ms worst
markdown parsed while composing: 7, 3.0ms total, 0.4ms mean, 0.5ms worst
markdown ready: 46
markdown reparsed while streaming: 396, 3384.6ms total, 8.5ms mean, 25.8ms worst
markdown warmed: 1, 1.4ms total, 1.4ms mean, 1.4ms worst
measure: the whole transcript: 957, 248.7ms total, 0.3ms mean, 15.7ms worst
message composed: 403
message cut into parts: 1, 0.1ms total, 0.1ms mean, 0.1ms worst
place: the whole transcript: 1319, 162.4ms total, 0.1ms mean, 1.3ms worst
record: one block: 398, 2498.7ms total, 6.3ms mean, 19.9ms worst
session screen recomposed: 413
status row recomposed: 1
unit composed: 538
units flattened: 399, 44.3ms total, 0.1ms mean, 0.5ms worst
usage bar recomposed: 413
bench:
scroll: 6 cycles (24 swipes), streamed 400/400 fixture events
process CPU time over this run: 20907ms
peak RSS: 587356kB
battery current: mean -418509µA over 39 samples (min -1988281, max -107812)
```
+93
View File
@@ -0,0 +1,93 @@
# Compose bench v2 report from Iris's phone, 2026-09-06
Bench v2 (fling / stream / type / keyboard, RUST.md's P0 box) on the
Compose `bench` build, run by Iris on her Pixel 9 Pro XL, verbatim. Note
the display was at **60 Hz** for this run (16.7 ms budget) where the v1
run was at 120 Hz -- the phone's adaptive refresh rate decides, and
`late` is judged against whichever it was, so compare a run with a run at
the same rate. The iris v2 report goes beside this when it exists.
What it says: fling, type and keyboard are all essentially clean on
Compose (0.1%, 0.9% and 0% late; fling p50 5.5 ms, p99 11.6 ms). The
whole tail is the streaming phase again -- 41.9% late, p99 42.5 ms,
driven by `markdown reparsed while streaming` (8.6 ms mean, 30.3 ms
worst) and `record: one block` (6.3 ms mean, 25.5 ms worst). Process CPU
69.6 s over the 125.5 s run; peak RSS 577 MB; battery current mean
571 mA over 126 samples.
```
ai-app render report
device: Pixel 9 Pro XL (Google), Android 17
build: release
transcript:
108 events, 26 rows, 58 units loaded
viewport 1531px, 2 units visible
on screen: the list's own 0px, AssistantMsg 24520px
0 tool calls and 0 groups open
per phase:
fling: 3278 frames over 32.7s
late: 4 (0.1%)
total p50 5.5ms p90 8.7ms p99 11.6ms
worst 49.0ms
stream: 1041 frames over 21.3s
late: 436 (41.9%)
total p50 13.4ms p90 31.7ms p99 42.5ms
worst 52.5ms
type: 2446 frames over 61.5s
late: 23 (0.9%)
total p50 7.3ms p90 13.2ms p99 16.5ms
worst 38.9ms
keyboard: 358 frames over 10.0s
late: 0 (0.0%)
total p50 6.3ms p90 8.6ms p99 11.1ms
worst 12.0ms
frames:
7122 frames over 125.5s at 60Hz (16.7ms budget)
late: 463 (6.5%)
total p50 6.0ms p90 13.8ms p99 34.0ms
waited p50 0.5ms p90 1.1ms p99 19.9ms
input p50 0.0ms p90 0.0ms p99 0.0ms
anim p50 0.7ms p90 4.5ms p99 7.6ms
layout p50 0.1ms p90 0.1ms p99 0.2ms
draw p50 0.7ms p90 2.9ms p99 21.9ms
sync p50 0.1ms p90 0.2ms p99 0.6ms
issue p50 1.4ms p90 2.4ms p99 3.2ms
swap p50 0.4ms p90 0.8ms p99 1.2ms
gpu p50 1.5ms p90 2.1ms p99 6.6ms
where the draw phase went:
draw phase 1.74ms per frame, of which:
the transcript: 0.24ms (measure 0.10, place 0.14, record 0.00)
everything else: 1.51ms (86%)
work since this was last copied:
draw: the whole transcript: 280, 1.9ms total, 0.0ms mean, 0.0ms worst
grouped tool runs: 407, 18.6ms total, 0.0ms mean, 0.2ms worst
markdown cut into pieces: 40, 0.4ms total, 0.0ms mean, 0.0ms worst
markdown parsed while composing: 2, 1.6ms total, 0.8ms mean, 1.1ms worst
markdown ready: 323
markdown reparsed while streaming: 395, 3406.9ms total, 8.6ms mean, 30.3ms worst
markdown warmed: 40, 38.2ms total, 1.0ms mean, 4.3ms worst
measure: the whole transcript: 1978, 683.9ms total, 0.3ms mean, 15.9ms worst
message composed: 397
message cut into parts: 40, 2.9ms total, 0.1ms mean, 0.2ms worst
place: the whole transcript: 4308, 996.0ms total, 0.2ms mean, 2.4ms worst
record: one block: 394, 2472.7ms total, 6.3ms mean, 25.5ms worst
session screen recomposed: 1630
status row recomposed: 1
transcript page from server: 10
unit composed: 927
units flattened: 408, 85.5ms total, 0.2ms mean, 1.8ms worst
bench:
fling: 8 flings out + 8 back at 12000px/s, travel start=idx=0/off=0px outward=idx=188/off=182px end=idx=0/off=0px
scroll: 6 cycles (24 swipes, legacy tween), streamed 400/400 fixture events
type: 600 characters inserted then deleted, one per 50ms
keyboard: shown 5/5, hidden 5/5 (confirmed via isImeVisible)
process CPU time over this run: 69564ms
peak RSS: 577452kB
battery current: mean -571483µA over 126 samples (min -2361718, max -99218)
```
@@ -0,0 +1,31 @@
# iris bench report from Iris's phone, 2026-09-06, before the phone fixes
Build 46246ea (Vulkan, bench v1: 24-swipe scroll loop then 400 streamed
events), run by Iris on her Pixel 9 Pro XL before the first-touch wipe,
the missing bold faces, the density scale and the status-bar inset were
fixed -- so the rows were drawn at roughly a third of their intended size
and the run may have included frames after the wipe. Preliminary, kept
because it is the first iris number from real hardware. Compare with
`compose-phone-2026-09-06.md`, taken on the same phone with the same
fixture and gesture loop.
Reading it: the phone is 120 Hz (8.3 ms budget). `janky%` here counts
frames over 16.7 ms, so it is not Compose's `late` (over 8.3 ms). Like for
like: iris p50 6.2 ms vs Compose 7.7 ms; p90 32.0 vs 29.2; p99 42.1 vs
41.1. `cpu_p50=4.7ms` is iris's own per-frame CPU work on the phone,
against 0.2-0.4 ms on the emulator's x86 cores. Process CPU 15.6 s vs
20.9 s, but over a shorter run (692 frames vs 1613 -- iris only renders on
change and had no fling settle time), so per-second CPU is not directly
comparable; peak RSS 365 MB vs 587 MB. Battery current mean 563 mA vs
419 mA is the one figure that reads worse, and it is the least
comparable: 22 samples vs 39, over runs of different length and different
idle share. Bench v2's per-phase accounting is what makes these comparable.
```
iris bench report
frames=692 janky%=32.37 p50=6.2ms p90=32.0ms p99=42.1ms worst=52.6ms (measures redraw-start to after present() is called, not GPU/compositor completion) cpu_p50=4.7ms gpu_wait_p50=1.3ms (redraw-start-to-submit vs. submit-to-after-present)
scroll: 6 cycles (24 swipes), streamed 400/400 fixture events
process CPU time over this run: 15554ms
peak RSS: 365328kB
battery current: mean -563493µA over 22 samples (min -1807812, max -132812)
```
+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)
```
+19
View File
@@ -305,6 +305,25 @@ pub enum Event {
/// it, which is why this is written down rather than left to be inferred
/// from a second example that does not exist.
Cleared,
/// The account behind this session has no quota left, so the turn stopped
/// without finishing.
///
/// Its own event rather than an [`Event::Error`] carrying the dialect's
/// sentence, because two things act on it that cannot read English: the
/// transcript draws it as a state the session is in rather than as a
/// failure of something it did, and `crate::resume` schedules the message
/// that picks the work back up. Recognising it belongs to the driver, which
/// is the only layer that knows its dialect's wording -- above here nothing
/// matches on strings.
///
/// `resets_at` is epoch seconds, and `None` is a real state: the dialect
/// said the limit was hit without saying when it lifts. Nothing here
/// invents one -- what the wait is actually decided against is the usage
/// endpoint, and this is the hint that starts the waiting.
LimitReached {
#[serde(default, skip_serializing_if = "Option::is_none")]
resets_at: Option<f64>,
},
Error {
message: String,
},
+1
View File
@@ -1757,6 +1757,7 @@ dependencies = [
"fxhash",
"image",
"parley",
"pollster",
"swash",
"wgpu",
]
+1
View File
@@ -1785,6 +1785,7 @@ dependencies = [
"fxhash",
"image",
"parley",
"pollster",
"swash",
"wgpu",
]
@@ -1,6 +1,17 @@
package dev.iris.android.demo;
import android.app.Activity;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.TextView;
import org.linebender.android.rustview.RustView;
@@ -33,4 +44,112 @@ public final class IrisView extends RustView {
unregisterInsetsNative(mViewPeer);
super.onDetachedFromWindow();
}
/**
* Called from the Rust side (iris/src/android/view.rs's
* `show_renderer_error`) when `AndroidRenderer::new` fails instead of
* drawing -- an ordinary instance method rather than a `native` one,
* since this call is Rust reaching into Java rather than the other
* direction. Replaces the whole activity content with plain,
* selectable, scrollable text rather than leaving the last frame (or a
* blank surface) on screen with no way to report what happened:
* UI_RULES.md's "a failure is reported where it happened, and says
* what to do next." No dialog and no styling beyond what is needed to
* read and copy the text -- this path exists for exactly the crash it
* replaces, so it must not depend on anything that could itself fail
* to render.
*/
void showRendererError(String report) {
Context context = getContext();
if (!(context instanceof Activity)) {
return;
}
Activity activity = (Activity) context;
TextView text = new TextView(activity);
text.setText(report);
text.setTextIsSelectable(true);
text.setGravity(Gravity.TOP | Gravity.START);
int pad = (int) (16 * activity.getResources().getDisplayMetrics().density);
text.setPadding(pad, pad, pad, pad);
ScrollView scroll = new ScrollView(activity);
scroll.addView(text);
activity.setContentView(scroll);
}
private static final String DIAGNOSTICS_OVERLAY_TAG = "iris-diagnostics-overlay";
/**
* The bench build's keyboard diagnostics capture
* (`bench_client.rs`'s `on_insets_changed` /
* `capture_keyboard_diagnostics`, via `bench_jni.rs`'s
* `PlatformHandle::show_diagnostics_overlay`): unlike
* `showRendererError` above, this adds a panel *over* this view
* (`MainActivity`'s `FrameLayout` still holds `IrisView` underneath,
* running) rather than replacing the activity's content, and gives it
* a Copy button and a Close that removes the panel -- so it draws
* (and can be read) whether or not iris itself is still putting
* anything on screen, without abandoning the session that produced
* it. Runs on the UI thread regardless of which thread calls it,
* since the call comes from a background task (a delayed capture
* after the keyboard opens), and touching the view tree off the UI
* thread is undefined.
*/
void showDiagnosticsOverlay(String report) {
Context context = getContext();
if (!(context instanceof Activity)) {
return;
}
Activity activity = (Activity) context;
activity.runOnUiThread(() -> {
ViewGroup parent = (ViewGroup) getParent();
if (parent == null) {
return;
}
View existing = parent.findViewWithTag(DIAGNOSTICS_OVERLAY_TAG);
if (existing != null) {
parent.removeView(existing);
}
float density = activity.getResources().getDisplayMetrics().density;
int pad = (int) (16 * density);
LinearLayout overlay = new LinearLayout(activity);
overlay.setTag(DIAGNOSTICS_OVERLAY_TAG);
overlay.setOrientation(LinearLayout.VERTICAL);
overlay.setBackgroundColor(0xEE000000);
overlay.setPadding(pad, pad, pad, pad);
TextView text = new TextView(activity);
text.setText(report);
text.setTextIsSelectable(true);
text.setTextColor(0xFFFFFFFF);
ScrollView scroll = new ScrollView(activity);
scroll.addView(text);
overlay.addView(scroll, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f));
LinearLayout buttonRow = new LinearLayout(activity);
buttonRow.setOrientation(LinearLayout.HORIZONTAL);
buttonRow.setPadding(0, pad, 0, 0);
Button copy = new Button(activity);
copy.setText("Copy");
copy.setOnClickListener(v -> {
ClipboardManager clipboard =
(ClipboardManager) activity.getSystemService(Context.CLIPBOARD_SERVICE);
if (clipboard != null) {
clipboard.setPrimaryClip(ClipData.newPlainText("iris diagnostics", report));
}
});
Button close = new Button(activity);
close.setText("Close");
close.setOnClickListener(v -> parent.removeView(overlay));
buttonRow.addView(copy);
buttonRow.addView(close);
overlay.addView(buttonRow);
parent.addView(overlay, new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
});
}
}
@@ -36,9 +36,28 @@ public final class MainActivity extends Activity {
int top = insets.getSystemWindowInsetTop();
int right = insets.getSystemWindowInsetRight();
int bottom = insets.getSystemWindowInsetBottom();
// The manifest declares adjustResize (AGENTS.md: without it the
// keyboard pans the whole window instead of resizing it), and
// under adjustResize the window itself shrinks to make room for
// the keyboard -- which is exactly the condition under which
// WindowInsets.Type.ime()'s own *inset amount* reports zero: it
// measures how much of the window the keyboard overlaps, and
// resize already made that overlap zero by construction. That
// numeric inset is not a usable "is the keyboard open" signal
// here (found while root-causing why bench_client.rs's keyboard
// phase and auto-diagnostics never fired on the emulator despite
// the keyboard visibly opening -- RUST.md's P0 box). What does
// survive adjustResize is the boolean isVisible() answer, set
// from the platform's own start/end of the transition over a
// different path than the inset amount -- the same fact
// AGENTS.md's "Things that have bitten" already names for the
// Compose side's identical trap. Passed through as a 0/1 stand-
// in for the ime_bottom pixel amount, since nothing on the Rust
// side reads it as a real pixel value -- only `> 0.0`.
int imeBottom = 0;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
imeBottom = insets.getInsets(WindowInsets.Type.ime()).bottom;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
&& insets.isVisible(WindowInsets.Type.ime())) {
imeBottom = 1;
}
((IrisView) v).applyWindowInsets(left, top, right, bottom, imeBottom);
return insets;
+90
View File
@@ -0,0 +1,90 @@
#!/bin/sh
# Builds iris-android-app end to end: the cdylib (cargo ndk, straight into
# app/src/main/jniLibs/) then the APK (Gradle). Written to stop re-typing
# the same incantation by hand every time (ANDROID_HOME/NDK exports, the
# cargo ndk invocation, the keystore env for a release build, apksigner/
# aapt2 verification) -- see docs/RUST.md's P0 box. Same shape as `app/
# build-apk.sh` (the Compose app's own build script) and `app/
# iris-scroll.sh` (no coordinates, set -eu, exit 0 on success).
#
# Usage: ./build-apk.sh [debug|release] [--abi arm64-v8a|x86_64] [--features "a b c"]
# debug/release default to debug (matches this-machine-android's "the
# emulator stays on debug" rule -- pass `release` explicitly for a phone
# build). --abi defaults to arm64-v8a (a phone/real device); pass
# x86_64 for this checkout's own AVD. --features defaults to
# "transcript-screen bench" -- deliberately *without* `force-gles`, unlike
# an earlier version of this default. `force-gles` (`iris/Cargo.toml`'s
# own doc) exists only to force the emulator off its default software
# Vulkan and onto GLES for one specific measurement (RUST.md's I5, "Where
# iris's frame time goes") -- it was never meant to reach a real device,
# but this script's old default put it in every arm64 build regardless,
# so the P0 bench APK delivered to Iris's phone forced GLES there too.
# That is the named hypothesis in RUST.md's P0 box ("iris bench crash on
# the phone, 2026-09-06"): a real Vulkan driver is what a phone should
# run, and GLES is the backend the same box's own SwiftShader finding
# already flagged as the fragile one for this shader's storage buffers.
# Pass `--features "transcript-screen force-gles bench"` explicitly for
# an emulator backend-isolation run; never for a build meant for a phone.
set -eu
cd "$(dirname "$0")"
BUILD_TYPE="debug"
ABI="arm64-v8a"
FEATURES="transcript-screen bench"
case "${1:-}" in
debug|release) BUILD_TYPE="$1"; shift ;;
esac
while [ $# -gt 0 ]; do
case "$1" in
--abi) ABI="$2"; shift 2 ;;
--features) FEATURES="$2"; shift 2 ;;
*) echo "build-apk.sh: unknown argument: $1" >&2; exit 1 ;;
esac
done
SDK_ROOT="$HOME/Android/Sdk"
export ANDROID_HOME="$SDK_ROOT"
export ANDROID_SDK_ROOT="$SDK_ROOT"
NDK_DIR=$(ls -d "$SDK_ROOT"/ndk/*/ 2>/dev/null | sort -V | tail -1)
if [ -z "$NDK_DIR" ]; then
echo "build-apk.sh: no NDK found under $SDK_ROOT/ndk" >&2
exit 1
fi
export ANDROID_NDK_HOME="$NDK_DIR"
echo "build-apk.sh: cargo ndk -t $ABI build ${BUILD_TYPE:+(${BUILD_TYPE})} --features \"$FEATURES\""
if [ "$BUILD_TYPE" = "release" ]; then
cargo ndk -t "$ABI" -P 26 -o app/src/main/jniLibs/ build --release --features "$FEATURES"
else
cargo ndk -t "$ABI" -P 26 -o app/src/main/jniLibs/ build --features "$FEATURES"
fi
GRADLE_TASK="assembleDebug"
APK_DIR="app/build/outputs/apk/debug"
APK_NAME="app-debug.apk"
if [ "$BUILD_TYPE" = "release" ]; then
GRADLE_TASK="assembleRelease"
APK_DIR="app/build/outputs/apk/release"
APK_NAME="app-release.apk"
# Same key `app/build-apk.sh` (the Compose app) generates once under
# ~/.config/ai-app/release.jks -- see AGENTS.md's "Checking your work".
export AI_APP_KEYSTORE="$HOME/.config/ai-app/release.jks"
if [ ! -f "$AI_APP_KEYSTORE" ]; then
echo "build-apk.sh: no release key at $AI_APP_KEYSTORE -- run app/build-apk.sh once first" >&2
exit 1
fi
export AI_APP_KEYSTORE_PASSWORD
AI_APP_KEYSTORE_PASSWORD=$(cat "$AI_APP_KEYSTORE.password")
fi
gradle ":app:$GRADLE_TASK" --console=plain
APK_PATH="$(pwd)/$APK_DIR/$APK_NAME"
BUILD_TOOLS=$(ls -d "$SDK_ROOT"/build-tools/*/ | sort -V | tail -1)
echo "--- aapt2 dump badging ---"
"${BUILD_TOOLS}aapt2" dump badging "$APK_PATH" | head -5
if [ "$BUILD_TYPE" = "release" ]; then
echo "--- apksigner verify ---"
"${BUILD_TOOLS}apksigner" verify --print-certs "$APK_PATH"
fi
echo "$APK_PATH"
+69
View File
@@ -0,0 +1,69 @@
#!/bin/sh
# Installs and runs the iris `bench` build on this checkout's own emulator
# (per this-machine-android's per-checkout-AVD rule; `emu serial` picks it)
# and prints the report -- the iris half of `app/transcript-bench.sh`'s
# job. No coordinates: the button is found by its accessibility label
# through `ui-trace`, per AGENTS.md's "Driving the UI".
#
# Usage: ./run-bench.sh [--apk PATH]
# Defaults to this checkout's own release APK
# (app/build/outputs/apk/release/app-release.apk) if it exists, else the
# debug one -- build one first with ./build-apk.sh.
set -eu
cd "$(dirname "$0")"
APK=""
while [ $# -gt 0 ]; do
case "$1" in
--apk) APK="$2"; shift 2 ;;
*) echo "run-bench.sh: unknown argument: $1" >&2; exit 1 ;;
esac
done
if [ -z "$APK" ]; then
if [ -f app/build/outputs/apk/release/app-release.apk ]; then
APK=app/build/outputs/apk/release/app-release.apk
else
APK=app/build/outputs/apk/debug/app-debug.apk
fi
fi
if [ ! -f "$APK" ]; then
echo "run-bench.sh: no APK at $APK -- run ./build-apk.sh first" >&2
exit 1
fi
SERIAL=$(emu serial)
PKG=$(aapt2 dump badging "$APK" 2>/dev/null | sed -n "s/^package: name='\\([^']*\\)'.*/\\1/p")
if [ -z "$PKG" ]; then
BUILD_TOOLS=$(ls -d "$HOME"/Android/Sdk/build-tools/*/ | sort -V | tail -1)
PKG=$("${BUILD_TOOLS}aapt2" dump badging "$APK" | sed -n "s/^package: name='\\([^']*\\)'.*/\\1/p")
fi
echo "run-bench.sh: installing $APK ($PKG) on $SERIAL"
adb -s "$SERIAL" install -r "$APK" >/dev/null
adb -s "$SERIAL" shell am force-stop "$PKG"
adb -s "$SERIAL" logcat -c
adb -s "$SERIAL" shell am start -n "$PKG/dev.iris.android.demo.MainActivity" >/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 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
while [ "$i" -lt 260 ]; do
LINE=$(adb -s "$SERIAL" logcat -d -s iris-android-app:I 2>/dev/null | grep "iris bench report:" || true)
if [ -n "$LINE" ]; then
break
fi
i=$((i + 1))
sleep 1
done
if [ -z "$LINE" ]; then
echo "run-bench.sh: no report after 260s -- check logcat by hand" >&2
exit 1
fi
# -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:"
+683 -101
View File
@@ -4,17 +4,21 @@
//! `transcript-ui`'s real screen with no server -- a frame-time comparison
//! that measures the renderer rather than the data or the network.
//!
//! **Reuses `transcript_client.rs`'s shape** (folded items, a full
//! `transcript_ui::build_tree` rebuild per event) with the network half
//! replaced by the checked-in fixture, embedded with `include_str!` --
//! `app/bench-fixture/assets/transcript.jsonl`, 1,915,760 bytes, generated
//! by `app/bench-fixture/generate.py` and never a real transcript (that
//! file's own README). The first 3,200 lines are the opening backlog,
//! folded once through `client_core::transcript_fold::fold_page` exactly
//! as a real `/transcript` page would be; the remaining ~400 are the
//! streaming tail, replayed one at a time through `fold_event` -- the same
//! fold path a live SSE reply arrives on -- by the "Run benchmark"
//! control below.
//! **Reuses `transcript_client.rs`'s shape** (folded items, the same
//! `TranscriptScreen::apply` incremental update on every event) with the
//! network half replaced by the checked-in fixture, embedded with
//! `include_str!` -- `app/bench-fixture/assets/transcript.jsonl`,
//! 1,915,760 bytes, generated by `app/bench-fixture/generate.py` and never
//! a real transcript (that file's own README). The first 3,200 lines are
//! the opening backlog, folded once through
//! `client_core::transcript_fold::fold_page` exactly as a real
//! `/transcript` page would be (then a full `transcript_ui::build_tree`,
//! same as any first load); the remaining ~400 are the streaming tail,
//! replayed one at a time through `fold_event` -- the same fold path a
//! live SSE reply arrives on -- by the "Run benchmark" control below.
//! Streaming through `apply` rather than a full rebuild per event is what
//! this file exists to measure -- see docs/RUST.md's P0 box for the
//! before/after report.
use crate::bench_jni::PlatformHandle;
use android_view::jni::{JavaVM, objects::GlobalRef};
@@ -22,9 +26,9 @@ use client_core::transcript_fold::{TranscriptItem, fold_event, fold_page, group_
use event_model::SeqEvent;
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
use iris::prelude::*;
use std::sync::Arc;
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
/// the opening window; the rest are the streaming tail. Kept in sync with
@@ -33,19 +37,57 @@ use std::time::Duration;
/// builds open a different split of it, not a wrong-vs-right answer.
const BACKLOG_COUNT: usize = 3200;
/// `BenchRun.kt`'s own constants -- kept identical so the two apps' bench
/// runs are the same gesture and the same load, which is the entire point
/// of a shared fixture and a shared scripted loop (P0's pass condition).
const CYCLES: usize = 6;
const SWIPE_PX: f32 = 900.0;
const SWIPE_MS: u64 = 200;
const SWIPE_PAUSE_MS: u64 = 500;
/// RUST.md's "Benchmark v2" spec, written once so both apps' bench clients
/// implement the identical four phases -- see that box before changing any
/// constant here, since a mismatch would make the two reports stop
/// measuring the same thing while still looking like they do.
const STREAM_EVENTS_PER_SEC: 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
/// `List::scroll` swipe is many small moves rather than one jump, so
/// frames are actually rendered along the way (the point of animating it
/// at all rather than calling `scroll` once per swipe).
/// fling/scroll is many small moves rather than one jump, so frames are
/// actually rendered along the way, and close enough that a `ctx.update`
/// 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 FIXTURE_JSONL: &str = include_str!("../../../app/bench-fixture/assets/transcript.jsonl");
@@ -54,6 +96,12 @@ pub struct BenchClient {
ui_state: AndroidUiState,
content: WeakWidget<WidgetPtr>,
report_display: WeakWidget<TextEdit>,
/// The top button row, in a `WidgetPtr` slot rather than added
/// directly (like `content`) so `on_insets_changed` can swap in a
/// version padded for the status bar once insets are known -- RUST.md's
/// P0 box, "the status-bar inset is not applied," found the row sitting
/// directly under it because nothing here read `insets().top` at all.
top_bar: WeakWidget<WidgetPtr>,
screen: Option<transcript_ui::TranscriptScreen>,
items: Vec<TranscriptItem>,
/// The events not yet streamed -- consumed by `start_benchmark`'s own
@@ -64,6 +112,35 @@ pub struct BenchClient {
platform: Option<Arc<PlatformHandle>>,
last_report: Option<String>,
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
/// first `on_insets_changed` where `ime_bottom > 0.0`, cleared on the
/// first where it is not, so opening the keyboard fires this once
/// rather than on every insets update while it stays open (a rotation
/// or a status-bar change with the keyboard already up would otherwise
/// re-fire it).
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 {
@@ -168,27 +245,50 @@ impl AndroidAppState for BenchClient {
.label("Benchmark report")
.add(rsc);
let controls = bench_controls(rsc);
let top_bar = WidgetPtr::new().add(rsc);
let controls = bench_controls(rsc, 0.0);
top_bar(rsc).set(controls);
let tree = (
controls,
top_bar,
content.height(rest(2)),
report_display.height(rest(1)).pad(8),
report_display.height(rest(1)).pad(dp(8)),
)
.span(Dir::DOWN)
.add_strong(rsc)
.any();
ui_state.set_root(tree);
// Startup log line (RUST.md's P0 box, "log once at startup ... the
// number of font families found, the default family resolved"):
// what font discovery actually found on this device, before
// anything is drawn.
let font = rsc.ui.text.font_diagnostics();
log::info!(
"iris fonts: {} families found, default={:?} mono={:?}, resolved regular={:?} \
bold={:?} italic={:?} mono={:?}",
font.families_found,
font.default_family,
font.default_mono_family,
font.regular_resolved,
font.bold_resolved,
font.italic_resolved,
font.mono_resolved,
);
let mut client = Self {
ui_state,
content,
report_display,
top_bar,
screen: None,
items: Vec::new(),
stream_tail: Vec::new(),
platform: None,
last_report: None,
running: false,
ime_state: Arc::new(Mutex::new(ImeState::default())),
keyboard_was_visible: false,
last_top_pad: 0.0,
};
let (backlog, stream_tail) = parse_fixture();
@@ -212,11 +312,138 @@ impl AndroidAppState for BenchClient {
fn back_pressed(&mut self, _rsc: &mut AndroidRsc<Self>, _render: &mut UiRenderState) -> bool {
false
}
/// Pads the top button row by the status-bar inset -- see `top_bar`'s
/// field comment. Rebuilds the row rather than mutating a stored
/// `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 two things downstream of the same `ime_bottom` transition:
/// **the keyboard phase's own confirmation signal** (`ime_state`'s
/// doc -- the platform's own answer for whether the IME actually
/// opened or closed, rather than assumed from having called
/// `show_ime`/`hide_ime`), and **the trigger for the keyboard
/// diagnostics capture** (RUST.md's P0 box): the IME resizing the
/// surface is exactly the case a previous commit found wiped text,
/// and Iris needs a way to get a report off the phone even if that
/// (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(
&mut self,
rsc: &mut AndroidRsc<Self>,
insets: iris::android::WindowInsets,
) {
if insets.top != self.last_top_pad {
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 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 {
self.keyboard_was_visible = true;
let redraw = rsc.tasks.redraw_handle();
rsc.spawn_task(async move |mut ctx| {
tokio::time::sleep(Duration::from_millis(KEYBOARD_DIAGNOSTICS_DELAY_MS)).await;
ctx.update(|state: &mut BenchClient, rsc| {
state.capture_keyboard_diagnostics(rsc);
});
redraw.request_redraw();
});
} else if !ime_visible {
self.keyboard_was_visible = false;
}
}
}
/// How long to wait after the keyboard becomes visible before capturing
/// diagnostics -- long enough that the resize, the reported wipe (if it is
/// still happening) and a couple of frames have all had time to land, per
/// AGENTS.md's "so that operations that finish in milliseconds have states
/// on the way that nothing can observe" reasoning applied the other way:
/// this wants to observe the state *after* the transition settles, not
/// mid-flight.
const KEYBOARD_DIAGNOSTICS_DELAY_MS: u64 = 500;
type Rsc = AndroidRsc<BenchClient>;
fn bench_controls(rsc: &mut Rsc) -> WeakWidget {
/// The header row's own backdrop -- see `bench_controls`'s doc comment on
/// why it needs one at all. A dark neutral rather than pure black
/// (`android::render::CLEAR_COLOR`) so the row reads as a distinct panel
/// instead of a hole in the background the buttons happen to float in.
const HEADER_SURFACE: UiColor = UiColor::new(28, 28, 34, 255);
/// `top_pad` is the status-bar inset in physical pixels (0.0 until
/// `on_insets_changed` has run once) -- folded in here, rather than
/// exposing the unadded builder for a caller to `.pad()` itself, because
/// naming that builder's type at each call site is more machinery than a
/// top-of-screen padding number is worth.
///
/// **Backed by an opaque rect the full size of the row, not just the three
/// buttons.** Iris's phone report (docs/RUST.md's P0 box, screenshots on
/// build a9232ac): "the header buttons have nothing behind them and
/// overlap the transcript text" -- before this, only each button's own
/// `rect(...)` painted anything, so the gaps between and around them (and
/// the status-bar strip above them) showed whatever was one layer back
/// (`CLEAR_COLOR`, black), and the row's true height was three
/// physical-pixel-sized (`abs`, not `dp`) button boxes rather than the
/// density-correct size the transcript below was already using post-P0 --
/// exactly what reads as "overlap" once the two disagree. Fixed two ways
/// together: a `HEADER_SURFACE` rect stacked behind the whole row (this
/// function), and every size below moved from a bare number (physical
/// pixels) to `dp(...)` (IRIS_TODO.md's density-independent length unit),
/// so the row's reserved height in the outer `Span::DOWN`
/// (`AndroidAppState::new`) matches what is actually painted.
fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
let run_rect = rect(Color::rgb(40, 70, 40))
.on(
CursorSense::click(),
@@ -230,7 +457,7 @@ fn bench_controls(rsc: &mut Rsc) -> WeakWidget {
wtext("Run benchmark").size(18).text_align(Align::CENTER),
)
.stack()
.pad(8)
.pad(dp(8))
.add(rsc);
let copy_rect = rect(Color::rgb(50, 50, 60))
@@ -246,10 +473,33 @@ fn bench_controls(rsc: &mut Rsc) -> WeakWidget {
wtext("Copy report").size(18).text_align(Align::CENTER),
)
.stack()
.pad(8)
.pad(dp(8))
.add(rsc);
(run, copy).span(Dir::RIGHT).height(56).add(rsc)
let diag_rect = rect(Color::rgb(60, 45, 70))
.on(
CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
ctx.state.show_diagnostics(rsc);
},
)
.label("Diagnostics");
let diagnostics = (
diag_rect,
wtext("Diagnostics").size(18).text_align(Align::CENTER),
)
.stack()
.pad(dp(8))
.add(rsc);
let buttons = (run, copy, diagnostics).span(Dir::RIGHT).add(rsc);
(rect(HEADER_SURFACE), buttons)
.stack()
.height(dp(56))
.pad(Padding::top(top_pad))
.add_strong(rsc)
.any()
}
impl BenchClient {
@@ -266,6 +516,56 @@ impl BenchClient {
self.screen = Some(screen);
}
/// RUST.md's P0 box: "a named `Diagnostics` control ... with 'copy this
/// and send it to Iris'." Fills `report_display` (the same TextEdit the
/// benchmark report uses) rather than a separate widget, so the
/// existing "Copy report" button and clipboard path work on whichever
/// text is currently shown -- `last_report` is what `copy_report` reads,
/// so it's set here too rather than adding a second copy path.
fn show_diagnostics(&mut self, rsc: &mut Rsc) {
let font = rsc.ui.text.font_diagnostics();
let frame_report = match self.android_state().frame_report.report() {
Some(stats) => format!("{stats}"),
None => "no frames recorded yet".to_string(),
};
let report = match &self.android_state().renderer {
Some(renderer) => renderer.diagnostics_report(&font, &frame_report),
None => "iris diagnostics: no renderer yet (no surface)".to_string(),
};
self.report_display.edit(rsc).set(&report);
self.last_report = Some(report);
}
/// The keyboard's own diagnostics capture -- see `on_insets_changed`'s
/// doc comment. Reuses `show_diagnostics`'s exact report (so it is the
/// same text the on-screen `Diagnostics` button produces, plus the
/// per-frame log `FrameReport` already keeps around the resize --
/// `frame_report.report()` above covers "the frames around the
/// resize" without a second accounting mechanism), then does three
/// things the button does not: logs it (so a `logcat` pull gets it
/// even if nothing on screen does), copies it to the clipboard
/// unprompted, and shows it in the shell's plain overlay view, which
/// draws independently of iris's own renderer -- the whole point,
/// since the renderer is exactly what might be in the wiped state
/// this exists to report on.
fn capture_keyboard_diagnostics(&mut self, rsc: &mut Rsc) {
self.show_diagnostics(rsc);
let Some(report) = self.last_report.clone() else {
return;
};
log::info!("iris keyboard diagnostics:\n{report}");
let Some(platform) = &self.platform else {
log::info!("iris keyboard diagnostics: no platform handle, can't reach the shell");
return;
};
if platform.copy_to_clipboard("iris keyboard diagnostics", &report) {
log::info!("iris keyboard diagnostics: copied to clipboard");
} else {
log::info!("iris keyboard diagnostics: clipboard copy failed");
}
platform.show_diagnostics_overlay(&report);
}
fn copy_report(&mut self) {
let Some(report) = &self.last_report else {
log::info!("iris bench report: nothing to copy -- run the benchmark first");
@@ -282,10 +582,10 @@ impl BenchClient {
}
}
/// P0's scripted run: `BenchRun.kt`'s scroll loop, then its streaming
/// phase, then the report -- run in-process for the same reason that
/// file's own doc gives (no usable system tracing on a real phone, no
/// agent that can drive one).
/// RUST.md's "Benchmark v2": fling, then stream (unchanged from v1),
/// then type, then keyboard, then the report -- run in-process for the
/// same reason `BenchRun.kt`'s own doc gives (no usable system tracing
/// on a real phone, no agent that can drive one).
fn start_benchmark(&mut self, rsc: &mut Rsc) {
if self.running {
log::info!("iris bench report: already running");
@@ -298,35 +598,21 @@ impl BenchClient {
let redraw = rsc.tasks.redraw_handle();
let platform = self.platform.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 run_started_at = Instant::now();
rsc.spawn_task(async move |mut ctx| {
// The swipe loop: two drags toward newer content, two back --
// a cycle returns to where it started, so the whole loop
// measures steady-state scrolling. `BenchRun.kt`'s own
// comment on this shape.
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.
// The battery sampler runs for the whole run, 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 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 done = sampler_done.clone();
let samples = samples.clone();
@@ -340,20 +626,10 @@ impl BenchClient {
})
});
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| {
state.items = fold_event(&state.items, &event);
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;
let travel = run_fling_phase(&mut ctx, &redraw).await;
let (sent, total) = run_stream_phase(&mut ctx, &redraw, stream_tail).await;
run_type_phase(&mut ctx, &redraw, &platform).await;
let keyboard = run_keyboard_phase(&mut ctx, &platform, &ime_state).await;
sampler_done.store(true, Ordering::Relaxed);
if let Some(sampler) = sampler {
@@ -362,7 +638,10 @@ impl BenchClient {
let battery = battery_line(&samples.lock().unwrap());
let cpu_line = match (cpu_start, process_cpu_ms()) {
(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(),
};
@@ -370,19 +649,61 @@ impl BenchClient {
Some(kb) => format!(" peak RSS: {kb}kB"),
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| {
state.running = false;
let scroll_line = format!(
" scroll: {CYCLES} cycles ({} swipes), streamed {sent}/{total} fixture events",
CYCLES * 4
);
let frames_line = match state.android_state().frame_report.report() {
Some(stats) => format!("{stats}"),
None => "no frames recorded".to_string(),
let now = Instant::now();
let phase_lines: String = state
.android_state()
.frame_report
.phase_stats(now, refresh_hz)
.iter()
.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!(
"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}");
state.report_display.edit(rsc).set(&report);
@@ -393,26 +714,287 @@ impl BenchClient {
}
}
/// Moves `List::scroll` by `total_px` over `duration_ms`, in ~60Hz steps,
/// so the swipe is many rendered frames rather than one jump -- the same
/// shape `animateScrollBy(SWIPE_PX, tween(SWIPE_MS))` gives on the Compose
/// side, in the one place the two backends have to differ (iris's `List`
/// has no built-in tween, so this drives it by hand).
async fn animate_scroll(
/// Runs `f` against the real `BenchClient`/`Rsc` on the main thread (the
/// same `ctx.update` every other mutation here goes through) and returns
/// its result to the caller's async task -- `ctx.update` alone has no way
/// to hand a value back, since the closure only actually runs once the
/// next frame callback drains `IrisViewPeer`'s task channel
/// (`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>,
redraw: &Arc<dyn iris::task::RequestRedraw>,
total_px: f32,
duration_ms: u64,
) {
let steps = (duration_ms / ANIM_STEP_MS).max(1);
let step_px = total_px / steps as f32;
for _ in 0..steps {
ctx.update(move |state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
(screen.list)(rsc).scroll(step_px);
}
});
redraw.request_redraw();
redraw: &Arc<dyn RequestRedraw>,
f: F,
) -> T
where
T: Send + 'static,
F: FnOnce(&mut BenchClient, &mut Rsc) -> T + Send + 'static,
{
let (tx, rx) = std::sync::mpsc::channel();
ctx.update(move |state: &mut BenchClient, rsc| {
let _ = tx.send(f(state, rsc));
});
redraw.request_redraw();
loop {
if let Ok(value) = rx.try_recv() {
return value;
}
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);
}
}
+123 -5
View File
@@ -1,11 +1,14 @@
//! JNI calls the `bench` feature needs that go through the shell's own
//! Java side rather than anything `iris`/`android-view` already wraps:
//! `BatteryManager.getIntProperty(BATTERY_PROPERTY_CURRENT_NOW)` for the
//! per-second battery sample, and `ClipboardManager.setPrimaryClip` for
//! the "Copy report" control (P0's iris half, docs/RUST.md). Neither is
//! 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 two one-off
//! per-second battery sample, `ClipboardManager.setPrimaryClip` for the
//! "Copy report" control (P0's iris half, docs/RUST.md), and -- added for
//! RUST.md's "Benchmark v2" -- `Display.getRefreshRate()` for the phase
//! report's real late-frame budget and `InputMethodManager.
//! 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.
//!
//! Holds its own `JavaVM` + `GlobalRef` to the view (handed in through
@@ -131,4 +134,119 @@ impl PlatformHandle {
.ok()?;
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
/// (`IrisView.showDiagnosticsOverlay`) -- a real `TextView` plus Copy
/// and Close controls, added over whatever iris itself is drawing
/// rather than replacing it (unlike `android::view::show_renderer_error`,
/// which exists for the case the renderer can never recover from and
/// intentionally never returns). Called from a background task after
/// the keyboard-open delay (`bench_client.rs`'s `on_insets_changed`),
/// so the Java side hops onto the UI thread itself before touching the
/// view tree -- see that method's own comment.
pub fn show_diagnostics_overlay(&self, report: &str) -> bool {
self.try_show_diagnostics_overlay(report).is_some()
}
fn try_show_diagnostics_overlay(&self, report: &str) -> Option<()> {
let mut guard = self.vm.attach_current_thread().ok()?;
let env: &mut JNIEnv = &mut guard;
let jreport = env.new_string(report).ok()?;
env.call_method(
self.view.as_obj(),
"showDiagnosticsOverlay",
"(Ljava/lang/String;)V",
&[JValue::Object(jreport.as_ref())],
)
.ok()?;
Some(())
}
}
+26 -9
View File
@@ -20,14 +20,22 @@
//! **Reuses `iris/desktop-app`'s `app.rs` shape almost exactly** --
//! `fold_event`/`group_tool_runs`/`fold_page`/`raw_seq` from
//! `client_core::transcript_fold`, a `generation` counter guarding against
//! a stale background response, and a full rebuild of the widget tree on
//! every event (same tradeoff, same reason: `push_row` cannot update a row
//! already on screen, and this rig's conversations are small). What
//! differs is only the redraw mechanism: android-view has no
//! `winit::EventLoopProxy`, so this uses `iris::task::Tasks::redraw_handle`
//! (new, added alongside this box) to request a frame after each
//! `TaskCtx::update` instead of relying on `Tasks::spawn`'s single
//! end-of-future redraw -- see that method's own doc for why.
//! a stale background response. What differs is only the redraw
//! mechanism: android-view has no `winit::EventLoopProxy`, so this uses
//! `iris::task::Tasks::redraw_handle` (new, added alongside this box) to
//! request a frame after each `TaskCtx::update` instead of relying on
//! `Tasks::spawn`'s single end-of-future redraw -- see that method's own
//! doc for why.
//!
//! **Streaming no longer costs a full rebuild** (fixed after the P0 gate
//! showed why it mattered -- 20 events/second means 20 rebuilds/second of
//! a ~3,200-row transcript otherwise): `apply_event` calls
//! `transcript_ui::TranscriptScreen::apply` with the item list before and
//! after `fold_event`, which updates only the row(s) that actually
//! changed (almost always just the one open assistant message) instead of
//! refolding and rebuilding every row. `rebuild_transcript` still runs
//! the whole widget tree once, for the opening page and for `apply`'s own
//! rare regroup fallback.
use client_core::api::{ApiClient, UreqTransport};
use client_core::event_stream::{StreamItem, follow_session_events};
@@ -360,8 +368,17 @@ impl TranscriptClient {
}
fn apply_event(&mut self, rsc: &mut AndroidRsc<Self>, event: &SeqEvent) {
let old_items = self.items.clone();
self.items = fold_event(&self.items, event);
self.rebuild_transcript(rsc);
match &self.screen {
// The common path: update only the row(s) that actually
// changed instead of refolding and rebuilding all ~3,200 of
// them per event (RUST.md's P0 streaming-phase fix).
Some(screen) => screen.apply(rsc, &old_items, &self.items),
// No screen yet (the opening page hasn't landed) -- build one
// the ordinary way once it has.
None => self.rebuild_transcript(rsc),
}
}
fn send_message(&mut self, session_id: String, text: String) {
+6
View File
@@ -5,6 +5,12 @@ edition.workspace = true
[dependencies]
wgpu = { workspace = true }
# Only for `UiRenderNode::new`'s `push_error_scope`/`pop_error_scope` pair
# (renderer-creation error reporting, RUST.md's P0 phone-crash box) --
# `block_on` turns that one async pop into the same synchronous call shape
# `device_limits()`'s two callers already use for `request_adapter`/
# `request_device`, rather than making this crate's one entry point async.
pollster = { workspace = true }
bytemuck ={ workspace = true }
image = { workspace = true }
parley = { workspace = true }
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+64 -7
View File
@@ -9,7 +9,31 @@ pub struct Size {
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Len {
/// Physical pixels -- a raw device pixel, unaffected by the display's
/// density. Rare to want directly (a hairline border is the usual
/// case); most sizes should be `dp` instead. See `dp`'s own doc for why
/// the two are kept separate rather than one field a caller has to
/// remember to pre-multiply.
pub abs: f32,
/// Density-independent pixels -- Android's `dp` / CSS's reference pixel
/// (1 unit = 1/160in), resolved against the display's density at
/// layout time (`apply_rest`'s `density` parameter) rather than at the
/// point a widget is built, since density is a property of the device
/// this ends up running on, not of the widget tree. This is the unit
/// IRIS_TODO.md's "a density-independent length unit" item asked for,
/// 2026-09-06: before it existed, every size in the tree was `abs`
/// (physical pixels), and the only way to make a 16px design draw at
/// the right *size* on a denser display was a single global multiply
/// applied to the whole rendered scene after layout -- which is also
/// what made text blurry (RUST.md's P0 box, "blurry ... glyphs drawn
/// at logical size and stretched by the scale"): a glyph rasterised at
/// 16 physical px and then stretched 3x by that global multiply is a
/// 48px area sampled from a 16px bitmap. Resolving `dp` per-length at
/// layout time instead means the font size handed to the text shaper
/// is already the physical size (`16.0.dp() * 3.0`), so the glyph
/// atlas rasterises at the display's real resolution and nothing
/// downstream needs to stretch anything.
pub dp: f32,
pub rel: f32,
pub rest: f32,
}
@@ -67,10 +91,10 @@ impl Size {
}
}
pub fn to_uivec2(self) -> UiVec2 {
pub fn to_uivec2(self, density: f32) -> UiVec2 {
UiVec2 {
x: self.x.apply_rest(),
y: self.y.apply_rest(),
x: self.x.apply_rest(density),
y: self.y.apply_rest(density),
}
}
@@ -98,26 +122,43 @@ impl Size {
impl Len {
pub const ZERO: Self = Self {
abs: 0.0,
dp: 0.0,
rel: 0.0,
rest: 0.0,
};
pub const REST: Self = Self {
abs: 0.0,
dp: 0.0,
rel: 0.0,
rest: 1.0,
};
pub fn apply_rest(&self) -> UiScalar {
/// Resolves to a `UiScalar`, folding `dp` into `abs` pixels against
/// `density` (physical pixels per dp -- 1.0 on a desktop or an
/// unscaled display, `content_scale` on Android; see `dp`'s field
/// doc). Every other component of `Len` is already resolution-
/// independent (`rel` is a fraction of the parent; `rest` becomes a
/// fraction too, below), so `density` only ever touches this one term.
pub fn apply_rest(&self, density: f32) -> UiScalar {
UiScalar {
rel: self.rel + if self.rest > 0.0 { 1.0 } else { 0.0 },
abs: self.abs,
abs: self.abs + self.dp * density,
}
}
pub fn abs(abs: impl UiNum) -> Self {
Self {
abs: abs.to_f32(),
dp: 0.0,
rel: 0.0,
rest: 0.0,
}
}
pub fn dp(dp: impl UiNum) -> Self {
Self {
abs: 0.0,
dp: dp.to_f32(),
rel: 0.0,
rest: 0.0,
}
@@ -125,6 +166,7 @@ impl Len {
pub fn rel(rel: impl UiNum) -> Self {
Self {
abs: 0.0,
dp: 0.0,
rel: rel.to_f32(),
rest: 0.0,
}
@@ -132,6 +174,7 @@ impl Len {
pub fn rest(ratio: impl UiNum) -> Self {
Self {
abs: 0.0,
dp: 0.0,
rel: 0.0,
rest: ratio.to_f32(),
}
@@ -144,6 +187,15 @@ pub mod len_fns {
pub fn abs(abs: impl UiNum) -> Len {
Len {
abs: abs.to_f32(),
dp: 0.0,
rel: 0.0,
rest: 0.0,
}
}
pub fn dp(dp: impl UiNum) -> Len {
Len {
abs: 0.0,
dp: dp.to_f32(),
rel: 0.0,
rest: 0.0,
}
@@ -151,6 +203,7 @@ pub mod len_fns {
pub fn rel(rel: impl UiNum) -> Len {
Len {
abs: 0.0,
dp: 0.0,
rel: rel.to_f32(),
rest: 0.0,
}
@@ -158,14 +211,15 @@ pub mod len_fns {
pub fn rest(ratio: impl UiNum) -> Len {
Len {
abs: 0.0,
dp: 0.0,
rel: 0.0,
rest: ratio.to_f32(),
}
}
}
impl_op!(Len Add add; abs rel rest);
impl_op!(Len Sub sub; abs rel rest);
impl_op!(Len Add add; abs dp rel rest);
impl_op!(Len Sub sub; abs dp rel rest);
impl_op!(Size Add add; x y);
impl_op!(Size Sub sub; x y);
@@ -187,6 +241,9 @@ impl std::fmt::Display for Len {
if self.abs != 0.0 {
write!(f, "{} abs;", self.abs)?;
}
if self.dp != 0.0 {
write!(f, "{} dp;", self.dp)?;
}
if self.rel != 0.0 {
write!(f, "{} rel;", self.rel)?;
}
+251 -11
View File
@@ -2,14 +2,63 @@ use crate::{Align, GlyphAtlas, GlyphKey, PlacedGlyph, RegionAlign, Textures, UiC
use parley::{
Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, FontStyle, FontWeight,
GenericFamily, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty,
fontique::{Blob, FamilyId},
};
use std::ops::Range;
use std::sync::Arc;
use swash::{
FontRef,
scale::{Render, ScaleContext, Source, StrikeWith},
zeno::{Format, Vector},
};
/// Bundled fonts, registered over the system collection rather than relied
/// on alone -- see `TextData::register_bundled_fonts`'s doc comment for
/// why. Static weight/style cuts, not a variable font: parley/fontique
/// resolve a variable font's weight axis by picking normalized coordinates
/// on whatever single face registers for the family, and a phone whose
/// system "Roboto" is actually the variable "Roboto Flex" is exactly the
/// device class this sidesteps, rather than depends on working correctly.
/// Noto Sans, OFL-licensed (`assets/fonts/OFL.txt`), chosen for coverage
/// breadth (a transcript's content is not known in advance) over a
/// smaller-footprint alternative -- see the doc comment for the size this
/// added.
const NOTO_SANS_REGULAR: &[u8] = include_bytes!("../../assets/fonts/NotoSans-Regular.ttf");
const NOTO_SANS_BOLD: &[u8] = include_bytes!("../../assets/fonts/NotoSans-Bold.ttf");
const NOTO_SANS_ITALIC: &[u8] = include_bytes!("../../assets/fonts/NotoSans-Italic.ttf");
const NOTO_SANS_BOLD_ITALIC: &[u8] = include_bytes!("../../assets/fonts/NotoSans-BoldItalic.ttf");
const NOTO_SANS_MONO_REGULAR: &[u8] = include_bytes!("../../assets/fonts/NotoSansMono-Regular.ttf");
const NOTO_SANS_MONO_BOLD: &[u8] = include_bytes!("../../assets/fonts/NotoSansMono-Bold.ttf");
/// What starting up found about text rendering, for the on-screen
/// Diagnostics page and the one startup log line (RUST.md's P0 box, "log
/// once at startup ... the number of font families found, the default
/// family resolved"). Built once by `TextData::font_diagnostics` --
/// `Default::default` still exists for callers (tests, examples) that
/// don't need the report.
#[derive(Clone, Debug)]
pub struct FontDiagnostics {
/// `Collection::family_names().count()` after registering the bundled
/// fonts -- system families plus the two bundled ones.
pub families_found: usize,
/// The family `GenericFamily::SansSerif` resolves to first -- the
/// bundled "Noto Sans" unless registration itself failed.
pub default_family: Option<String>,
/// The family `GenericFamily::Monospace` resolves to first.
pub default_mono_family: Option<String>,
/// One resolved family name per style axis this crate actually uses
/// (`SpanStyle::bold`/`italic`), so a report can say plainly whether a
/// bold/italic request is landing on a real face rather than being
/// silently absorbed by whatever the sans-serif default resolves to
/// for every weight (RUST.md's P0 box, "bold words render as blank
/// gaps" -- a family that resolves but has no distinct bold face is
/// exactly what produced that).
pub regular_resolved: Option<String>,
pub bold_resolved: Option<String>,
pub italic_resolved: Option<String>,
pub mono_resolved: Option<String>,
}
/// Everything text needs that outlives one string: the font collection, the
/// layout scratch space, the glyph rasteriser and the atlas they fill.
pub struct TextData {
@@ -17,15 +66,182 @@ pub struct TextData {
pub layout_cx: LayoutContext<UiColor>,
scale_cx: ScaleContext,
pub atlas: GlyphAtlas,
/// Physical pixels per dp -- a second copy of
/// `UiRenderState::density`, kept here too because `TextEditCtx::layout`
/// (cursor movement and hit-testing, `widget/text/edit.rs`) shapes text
/// from an event callback that has a `TextData` but no `Painter`, so it
/// has nowhere else to read the display's density from. Both copies are
/// set together, from the one place either backend learns the real
/// value (`android::view::new_peer`); this is the same accepted
/// duplication as `AndroidRenderer::content_scale`; a single source of
/// truth would mean carrying a `Painter` (or output size) into every
/// input handler for the sake of one field.
pub density: f32,
}
impl Default for TextData {
fn default() -> Self {
Self {
let mut data = Self {
font_cx: FontContext::new(),
layout_cx: LayoutContext::new(),
scale_cx: ScaleContext::new(),
atlas: GlyphAtlas::default(),
density: 1.0,
};
data.register_bundled_fonts();
data
}
}
impl TextData {
/// Registers Noto Sans (regular/bold/italic/bold-italic) and Noto Sans
/// Mono (regular/bold) as static faces, and puts them **first** in the
/// `SansSerif`/`Monospace` generic-family fallback lists -- ahead of,
/// not instead of, whatever the platform already found, so a script
/// Noto Sans lacks (CJK, emoji, ...) still falls through to the system
/// font the same as before this existed.
///
/// Exists because text rendering must not depend on the platform's own
/// font enumeration succeeding or resolving weight/style the way this
/// crate assumes: RUST.md's P0 box found bold spans on a real phone
/// rendering as blank gaps of the correct advance width (the glyph
/// simply wasn't rasterised -- `TextData::place`'s `None` arm), while
/// the emulator's system fonts happened to resolve every style. A
/// bundled, static-per-style family removes fontique's Android font
/// scan (`fontique::backend::android::SystemFonts::new`, which parses
/// `/system/fonts` and `/system/etc/fonts.xml`) from the path a glyph
/// has to survive to reach the screen at all.
///
/// Cost: six static `.ttf`s, ~3.6 MB uncompressed
/// (`iris/core/assets/fonts/`), landing in the APK compressed --
/// `build-apk.sh`'s own output is what says the delivered number, not
/// this comment.
fn register_bundled_fonts(&mut self) {
fn register(cx: &mut FontContext, bytes: &'static [u8]) -> Option<FamilyId> {
let blob = Blob::new(Arc::new(bytes));
cx.collection
.register_fonts(blob, None)
.into_iter()
.map(|(id, _)| id)
.next()
}
let sans_id = register(&mut self.font_cx, NOTO_SANS_REGULAR);
register(&mut self.font_cx, NOTO_SANS_BOLD);
register(&mut self.font_cx, NOTO_SANS_ITALIC);
register(&mut self.font_cx, NOTO_SANS_BOLD_ITALIC);
let mono_id = register(&mut self.font_cx, NOTO_SANS_MONO_REGULAR);
register(&mut self.font_cx, NOTO_SANS_MONO_BOLD);
if let Some(sans_id) = sans_id {
let existing: Vec<_> = self
.font_cx
.collection
.generic_families(GenericFamily::SansSerif)
.collect();
self.font_cx.collection.set_generic_families(
GenericFamily::SansSerif,
std::iter::once(sans_id).chain(existing),
);
let existing: Vec<_> = self
.font_cx
.collection
.generic_families(GenericFamily::SystemUi)
.collect();
self.font_cx.collection.set_generic_families(
GenericFamily::SystemUi,
std::iter::once(sans_id).chain(existing),
);
}
if let Some(mono_id) = mono_id {
let existing: Vec<_> = self
.font_cx
.collection
.generic_families(GenericFamily::Monospace)
.collect();
self.font_cx.collection.set_generic_families(
GenericFamily::Monospace,
std::iter::once(mono_id).chain(existing),
);
}
}
/// Builds the startup report -- see `FontDiagnostics`. Queries the
/// collection directly (`fontique::Query`) rather than shaping a real
/// string, since all that's needed is which family each axis lands on.
pub fn font_diagnostics(&mut self) -> FontDiagnostics {
use parley::fontique::{Attributes, FontWidth, QueryStatus};
let families_found = self.font_cx.collection.family_names().count();
let default_family_id = self
.font_cx
.collection
.generic_families(GenericFamily::SansSerif)
.next();
let default_family = default_family_id
.and_then(|id| self.font_cx.collection.family_name(id).map(str::to_string));
let default_mono_family_id = self
.font_cx
.collection
.generic_families(GenericFamily::Monospace)
.next();
let default_mono_family = default_mono_family_id
.and_then(|id| self.font_cx.collection.family_name(id).map(str::to_string));
// Resolves the family a (generic family, weight, style) query lands
// on, without holding the `Query`'s borrow of `collection` across
// the `family_name` lookup that needs it back -- the `FamilyId` is
// captured out of the closure first, then looked up once `query`
// (and its borrow) has been dropped.
let mut resolve_family =
|generic: GenericFamily, weight: FontWeight, style: FontStyle| -> Option<String> {
let mut family_id = None;
{
let mut query = self
.font_cx
.collection
.query(&mut self.font_cx.source_cache);
query.set_families([generic]);
query.set_attributes(Attributes {
width: FontWidth::NORMAL,
style,
weight,
});
query.matches_with(|font| {
family_id = Some(font.family.0);
QueryStatus::Stop
});
}
family_id.and_then(|id| self.font_cx.collection.family_name(id).map(str::to_string))
};
let regular_resolved = resolve_family(
GenericFamily::SansSerif,
FontWeight::NORMAL,
FontStyle::Normal,
);
let bold_resolved = resolve_family(
GenericFamily::SansSerif,
FontWeight::BOLD,
FontStyle::Normal,
);
let italic_resolved = resolve_family(
GenericFamily::SansSerif,
FontWeight::NORMAL,
FontStyle::Italic,
);
let mono_resolved = resolve_family(
GenericFamily::Monospace,
FontWeight::NORMAL,
FontStyle::Normal,
);
FontDiagnostics {
families_found,
default_family,
default_mono_family,
regular_resolved,
bold_resolved,
italic_resolved,
mono_resolved,
}
}
}
@@ -159,7 +375,7 @@ pub struct TextBuffer {
/// `set_spans` forces `shaped` to `None` directly, the same way `edit`
/// does, since spans change far less often than a naive equality check
/// on the whole `Vec` would cost to compute every frame.
shaped: Option<(TextAttrs, Option<f32>)>,
shaped: Option<(TextAttrs, Option<f32>, f32)>,
}
impl TextBuffer {
@@ -215,19 +431,42 @@ impl TextBuffer {
Vec2::new(self.layout.width(), self.layout.height())
}
/// Lay the text out, unless it is already laid out for these attributes and
/// this width.
pub fn shape(&mut self, data: &mut TextData, attrs: &TextAttrs, width: Option<f32>) {
if self.shaped.as_ref() == Some(&(attrs.clone(), width)) {
/// Lay the text out, unless it is already laid out for these
/// attributes, this width and this density.
///
/// **`attrs.font_size`/`line_height` and every span's own `font_size`
/// are density-independent (dp) units, multiplied by `density` here --
/// the one place text crosses from the widget tree's dp sizes into the
/// physical pixels the shaper and rasteriser (`TextData::place`) both
/// then work in.** This is what makes glyphs sharp on a dense display:
/// before this existed, `font_size` was already a physical-pixel value
/// (RUST.md's P0 box's global-scale stopgap resolved density by
/// stretching the whole rendered frame afterward instead), so a glyph
/// was rasterised small and then upscaled by whatever the display's
/// scale factor was -- exactly the blur Iris's report described.
/// Multiplying here instead means the font size hitting `ScaleContext`
/// in `place` below is already the display's real physical size, so
/// the atlas holds a bitmap at the resolution it is actually shown at.
/// `GlyphKey.size` already keys on that resolved `font_size`
/// (`(font_size * 16.0).round()`), so a cache entry is naturally per
/// physical size with no change needed there.
pub fn shape(
&mut self,
data: &mut TextData,
attrs: &TextAttrs,
width: Option<f32>,
density: f32,
) {
if self.shaped.as_ref() == Some(&(attrs.clone(), width, density)) {
return;
}
let mut builder = data
.layout_cx
.ranged_builder(&mut data.font_cx, &self.text, 1.0, true);
builder.push_default(StyleProperty::FontFamily(attrs.family.family()));
builder.push_default(StyleProperty::FontSize(attrs.font_size));
builder.push_default(StyleProperty::FontSize(attrs.font_size * density));
builder.push_default(StyleProperty::LineHeight(LineHeight::Absolute(
attrs.line_height,
attrs.line_height * density,
)));
builder.push_default(StyleProperty::Brush(attrs.color));
for span in &self.spans {
@@ -239,7 +478,7 @@ impl TextBuffer {
builder.push(StyleProperty::FontFamily(family.family()), range.clone());
}
if let Some(size) = span.font_size {
builder.push(StyleProperty::FontSize(size), range.clone());
builder.push(StyleProperty::FontSize(size * density), range.clone());
}
if span.bold {
builder.push(StyleProperty::FontWeight(FontWeight::BOLD), range.clone());
@@ -255,7 +494,7 @@ impl TextBuffer {
self.layout.break_all_lines(width);
self.layout
.align(Alignment::Start, AlignmentOptions::default());
self.shaped = Some((attrs.clone(), width));
self.shaped = Some((attrs.clone(), width, density));
}
}
@@ -372,8 +611,9 @@ impl TextData {
attrs: &TextAttrs,
width: Option<f32>,
textures: &mut Textures,
density: f32,
) -> RenderedText {
buffer.shape(self, attrs, width);
buffer.shape(self, attrs, width, density);
let glyphs = self.place(buffer, textures);
RenderedText {
glyphs: std::sync::Arc::new(glyphs),
+21
View File
@@ -141,6 +141,27 @@ impl Textures {
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) {
for (kind, idx) in self.recv.try_iter() {
self.images[idx as usize] = None;
+19
View File
@@ -173,6 +173,25 @@ impl GlyphAtlas {
pub fn glyph_count(&self) -> usize {
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 {
+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
/// 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);
/// Enough frames for several minutes of scrolling before the oldest ones
/// start being overwritten -- the same "diagnostic, not a log" sizing
/// `FrameStats.kt`'s `CAP` uses on the Compose side, chosen independently
/// 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
/// 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).
/// `ring[i] - submit_ring[i]` is that frame's `redraw_to_submit` half.
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
/// `RING_CAPACITY`, unlike `total_frames` below which keeps counting.
len: usize,
@@ -50,6 +127,12 @@ pub struct FrameReport {
/// correct even once the ring itself only holds the most recent frames.
total_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"
@@ -107,10 +190,12 @@ impl FrameReport {
Self {
ring: Box::new([Duration::ZERO; RING_CAPACITY]),
submit_ring: Box::new([Duration::ZERO; RING_CAPACITY]),
index_ring: Box::new([0; RING_CAPACITY]),
len: 0,
pos: 0,
total_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) {
self.ring[self.pos] = total;
self.submit_ring[self.pos] = submit_to_present;
self.index_ring[self.pos] = self.total_frames;
self.pos = (self.pos + 1) % RING_CAPACITY;
self.len = (self.len + 1).min(RING_CAPACITY);
self.total_frames += 1;
@@ -142,12 +228,89 @@ impl FrameReport {
/// Clears every counter and every sample -- what the "Reset frame
/// report" control calls, so a report covers only what was scrolled
/// 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) {
self.len = 0;
self.pos = 0;
self.total_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
@@ -186,6 +349,28 @@ impl FrameReport {
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 {
@@ -296,4 +481,68 @@ mod tests {
// same pattern here.
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));
}
}
+150 -18
View File
@@ -4,6 +4,7 @@ use crate::{
util::{HashMap, Vec2},
};
use data::WindowUniform;
use pollster::FutureExt;
use wgpu::{
util::{BufferInitDescriptor, DeviceExt},
*,
@@ -65,6 +66,57 @@ pub fn device_limits() -> Limits {
}
}
/// A capped log of wgpu's *uncaptured* errors -- everything that reaches
/// `Device::on_uncaptured_error` rather than one of `UiRenderNode::new`'s
/// own error scopes, i.e. every wgpu error raised outside device/pipeline
/// creation: a validation failure during an ordinary frame's `update`/
/// `draw`, for instance. wgpu's default handler for these is `panic!` with
/// no caller able to intervene -- exactly what aborted the P0 bench APK
/// once already (this file's `UiRenderNode::new` doc comment) -- so both
/// platform backends install a handler here instead of leaving the default
/// in place, per RUST.md's P0 box ("every wgpu uncaptured error ... it
/// must never panic in release").
///
/// Cheap to `Clone` (an `Arc` around the real storage) rather than a
/// process-wide static, so a caller builds one alongside its `Device`,
/// hands one clone to `on_uncaptured_error`'s closure and keeps the other
/// for the Diagnostics page to read -- context passed explicitly, per
/// AGENTS.md/CODE_RULES.md's "no globals" rather than reached for through a
/// `OnceLock`.
#[derive(Clone)]
pub struct WgpuErrorLog {
errors: std::sync::Arc<std::sync::Mutex<std::collections::VecDeque<String>>>,
}
/// How many uncaptured errors the log keeps -- old ones drop off the front
/// rather than being trimmed on read, so a build spraying errors every
/// frame doesn't grow this without bound.
const WGPU_ERROR_LOG_CAP: usize = 20;
impl Default for WgpuErrorLog {
fn default() -> Self {
Self {
errors: std::sync::Arc::new(std::sync::Mutex::new(std::collections::VecDeque::new())),
}
}
}
impl WgpuErrorLog {
pub fn record(&self, error: impl std::fmt::Display) {
let mut errors = self.errors.lock().unwrap();
if errors.len() >= WGPU_ERROR_LOG_CAP {
errors.pop_front();
}
errors.push_back(error.to_string());
}
/// A snapshot for the Diagnostics page -- cloned rather than held,
/// since the lock must not outlive one call.
pub fn snapshot(&self) -> Vec<String> {
self.errors.lock().unwrap().iter().cloned().collect()
}
}
pub struct UiRenderNode {
uniform_group: BindGroup,
primitive_layout: BindGroupLayout,
@@ -152,7 +204,7 @@ impl UiRenderNode {
queue: &Queue,
ui: &mut UiData,
ui_render: &mut UiRenderState,
) {
) -> FrameUpdateStats {
self.active.clear();
for (i, primitives) in ui_render.layers.iter_mut() {
self.active.push(i);
@@ -236,6 +288,10 @@ impl UiRenderNode {
if rebuild_main {
self.rsc_group = Self::rsc_group(device, &self.rsc_layout, &self.textures);
}
FrameUpdateStats {
masks_resized,
moves_resized,
}
}
/// Takes a size rather than a window type: this is the only thing the
@@ -251,26 +307,69 @@ impl UiRenderNode {
queue.write_buffer(&self.window_buffer, 0, bytemuck::cast_slice(slice));
}
pub fn new(device: &Device, queue: &Queue, config: &SurfaceConfiguration) -> Self {
/// Builds every bind group layout, the pipeline, and the two storage
/// buffers this needs -- fallibly, since this is exactly the call that
/// aborted the process on Iris's phone in a release build with no
/// message beyond "wgpu error: Validation Error" (RUST.md's P0 box,
/// "iris bench crash on the phone, 2026-09-06"). wgpu's own default
/// behaviour for an uncaptured error is `panic!` with no caller able to
/// intervene, so every `create_bind_group_layout`/`create_render_pipeline`
/// call below runs inside three nested error scopes (one per
/// `ErrorFilter`) instead: whichever scope catches something, its
/// `wgpu::Error`'s `Display` is wgpu-core's own `format_error` output
/// (`"Validation Error\n\nCaused by:\n ..."`, the same text the panic
/// would have printed before Android's crash reporter truncated it) and
/// becomes this function's `Err`. Both callers
/// (`android::render::AndroidRenderer::new`, `default::render::
/// UiRenderer::new`) already call `Device`-creation with
/// `pollster::block_on`, so returning a plain `Result` here rather than
/// making this `async fn` keeps that same synchronous shape.
pub fn new(
device: &Device,
queue: &Queue,
config: &SurfaceConfiguration,
window_size: impl Into<Vec2>,
) -> Result<Self, String> {
// Popped in reverse of this order, once every creation call below
// has run -- `Device::push_error_scope`'s own contract.
let oom_scope = device.push_error_scope(ErrorFilter::OutOfMemory);
let validation_scope = device.push_error_scope(ErrorFilter::Validation);
let internal_scope = device.push_error_scope(ErrorFilter::Internal);
let shader = device.create_shader_module(ShaderModuleDescriptor {
label: Some("UI Shape Shader"),
source: ShaderSource::Wgsl(SHAPE_SHADER.into()),
});
// Seeded from the surface's own size, not `WindowUniform::default()`
// (0, 0): the vertex shader divides by `window.dim` to reach clip
// space, so a window this buffer disagrees with means every
// primitive's position is NaN/Inf and is dropped before
// rasterization -- the clear colour still reaches the screen (the
// pass runs regardless) while nothing drawn on top of it ever does.
// winit's backend gets away with the old default because winit
// fires an initial `WindowEvent::Resized` that calls `resize()`
// before the first frame; android-view has no such automatic
// event, so `AndroidRenderer::new` built a node whose window buffer
// was never corrected -- this is I2's "nothing draws" bug (RUST.md).
let window_uniform = WindowUniform {
width: config.width as f32,
height: config.height as f32,
// Seeded from the caller's own reported size, not
// `WindowUniform::default()` (0, 0): the vertex shader divides by
// `window.dim` to reach clip space, so a window this buffer
// disagrees with means every primitive's position is NaN/Inf and is
// dropped before rasterization -- the clear colour still reaches
// the screen (the pass runs regardless) while nothing drawn on top
// of it ever does. winit's backend gets away with the old default
// because winit fires an initial `WindowEvent::Resized` that calls
// `resize()` before the first frame; android-view has no such
// automatic event, so `AndroidRenderer::new` built a node whose
// window buffer was never corrected -- this is I2's "nothing draws"
// bug (RUST.md).
//
// **Deliberately not `config.width`/`config.height`**: those are
// the surface's *physical* pixel size, which the swapchain needs,
// but everything downstream of this uniform (layout, hit-testing,
// glyph/rect positions) works in the caller's own units -- on
// Android that's *logical* (physical / density) since RUST.md's P0
// box ("text is far too small"), on desktop it's whatever
// `default::render::UiRenderer::new` already divides by
// `window.scale_factor()`. Passing it in explicitly, rather than
// deriving it from `config` here, is what keeps this crate from
// needing to know either platform's notion of density at all.
let window_uniform = {
let size = window_size.into();
WindowUniform {
width: size.x,
height: size.y,
}
};
let window_buffer = device.create_buffer_init(&BufferInitDescriptor {
label: Some("window"),
@@ -373,7 +472,18 @@ impl UiRenderNode {
cache: None,
});
Self {
// Reverse of the push order above. Only one of these should ever be
// `Some` in practice -- three separate scopes exist to name *which*
// kind of error it was, not because more than one is expected at
// once.
let internal_err = internal_scope.pop().block_on();
let validation_err = validation_scope.pop().block_on();
let oom_err = oom_scope.pop().block_on();
if let Some(err) = validation_err.or(oom_err).or(internal_err) {
return Err(err.to_string());
}
Ok(Self {
uniform_group,
primitive_layout,
rsc_layout,
@@ -387,7 +497,7 @@ impl UiRenderNode {
move_offsets,
masks_layout,
masks_group,
}
})
}
fn bind_group_0(
@@ -554,4 +664,26 @@ impl UiRenderNode {
pub fn take_image_bind_group_creates(&mut self) -> u64 {
self.textures.take_bind_group_creates()
}
/// Atlas-array `grow_array` calls since the last call -- same calling
/// convention as `take_image_bind_group_creates` (call once per frame,
/// before `update()`, to read exactly the previous frame's tally). Part
/// of the Diagnostics page's per-frame report (RUST.md's P0 box, "the
/// first input frame" investigation): if a report ever shows a grow
/// landing on the same frame the glyphs vanished, that is the
/// coincidence to chase first.
pub fn take_atlas_pages_grown(&mut self) -> u64 {
self.textures.take_pages_grown()
}
}
/// What `UiRenderNode::update` changed this frame that a caller building a
/// per-frame diagnostic report cares about -- see `take_image_bind_group_creates`/
/// `take_atlas_pages_grown` for the two counters this doesn't carry (they
/// use the existing "call before update()" convention instead, so as not
/// to disturb `bench_images`' documented counts).
#[derive(Clone, Copy, Debug, Default)]
pub struct FrameUpdateStats {
pub masks_resized: bool,
pub moves_resized: bool,
}
+14
View File
@@ -67,6 +67,12 @@ pub struct GpuTextures {
/// unchanging image list is zero, the same way `UiRenderState`'s
/// `draw_count`/`region_mut_count` prove the layout side.
bind_group_creates: u64,
/// `grow_array` calls since the last `take_pages_grown` -- the
/// Diagnostics page's per-frame report (RUST.md's P0 box, "the first
/// input frame" investigation) reads this alongside `bind_group_creates`
/// to say whether *this* frame's glyph disappearance, if any, coincided
/// with the atlas array being recreated.
pages_grown: u64,
}
impl GpuTextures {
@@ -226,6 +232,7 @@ impl GpuTextures {
/// array's view, which invalidates every bind group that referenced it,
/// so this also rebuilds all of them before returning.
fn grow_array(&mut self, rsc_layout: &BindGroupLayout) {
self.pages_grown += 1;
let new_capacity = self.array_capacity * 2;
let new_texture = Self::create_array_texture(&self.device, new_capacity);
if self.page_count > 0 {
@@ -392,6 +399,7 @@ impl GpuTextures {
sampler,
null_view,
bind_group_creates: 0,
pages_grown: 0,
}
}
@@ -402,6 +410,12 @@ impl GpuTextures {
std::mem::take(&mut self.bind_group_creates)
}
/// Reads and zeroes the atlas-array-grow counter -- see `pages_grown`'s
/// field comment.
pub fn take_pages_grown(&mut self) -> u64 {
std::mem::take(&mut self.pages_grown)
}
pub fn array_view(&self) -> &TextureView {
&self.array_view
}
+9 -1
View File
@@ -165,8 +165,10 @@ impl<'a> Painter<'a> {
attrs: &TextAttrs,
width: Option<f32>,
) -> RenderedText {
let density = self.state.density;
let ui = self.rsc.ui_mut();
ui.text.render(buffer, attrs, width, &mut ui.textures)
ui.text
.render(buffer, attrs, width, &mut ui.textures, density)
}
/// Draw a laid-out string: one quad per glyph, all sampling the atlas.
@@ -210,6 +212,12 @@ impl<'a> Painter<'a> {
self.state.output_size
}
/// Physical pixels per `dp` -- see `UiRenderState::density`'s field
/// doc. What `Len::dp`'s `apply_rest` call resolves against.
pub fn density(&self) -> f32 {
self.state.density
}
pub fn px_size(&mut self) -> Vec2 {
self.region.size().to_abs(self.state.output_size)
}
+22 -1
View File
@@ -9,6 +9,12 @@ pub struct UiRenderState {
pub active: HashMap<WidgetId, ActiveData>,
pub layers: PrimitiveLayers,
pub(super) output_size: Vec2,
/// Physical pixels per `dp` -- see `Len::dp`'s field doc. `1.0` (an
/// unscaled display) until a backend that knows its own density calls
/// `set_density` (Android's `content_scale`, read at `surface_changed`
/// time); the winit backend has no analogous per-monitor value wired up
/// yet and stays at the default.
pub(super) density: f32,
old_root: Option<WidgetId>,
resized: bool,
@@ -35,6 +41,7 @@ impl UiRenderState {
active: Default::default(),
layers: Default::default(),
output_size: Vec2::ZERO,
density: 1.0,
old_root: None,
resized: false,
draw_started: Default::default(),
@@ -60,6 +67,20 @@ impl UiRenderState {
self.resized = true;
}
/// Sets the physical-pixels-per-dp ratio every `Len::dp` in the tree
/// resolves against from the next layout pass on -- see `density`'s
/// field doc. Not folded into `resize` because the two change on
/// different triggers (a surface resize on every rotation or keyboard
/// open; a density change only if the app follows the display to a
/// different screen, which Android surfaces separately).
pub fn set_density(&mut self, density: f32) {
self.density = density;
}
pub fn density(&self) -> f32 {
self.density
}
pub fn update<'a>(&mut self, root: impl Into<Option<&'a StrongWidget>>, rsc: &mut dyn UiRsc) {
// safety mechanism for memory leaks; might wanna return a result instead so user can
// decide whether to panic or not
@@ -311,7 +332,7 @@ impl UiRenderState {
};
let from = active
.size
.to_uivec2()
.to_uivec2(self.density)
.align(RegionAlign::TOP_LEFT)
.within(&active.region);
let slot = active.move_slot;
+18 -18
View File
@@ -15,23 +15,19 @@
//! +-----------+--------------------------------------+
//! ```
//!
//! **Deliberately left simple, and why**: every incoming SSE event refolds
//! the *entire* transcript (`client_core::transcript_fold::fold_event` is
//! already `O(items)` and a desktop session's conversation is small) and
//! rebuilds the whole right-hand widget tree from scratch, rather than
//! reaching for `TranscriptScreen::push_row`'s incremental append.
//! `push_row` cannot update a row already on screen -- only append a new
//! one -- and a streaming assistant reply is exactly a row whose *text*
//! keeps changing after it first appears (see `transcript-ui`'s own doc on
//! `fold_event` folding deltas into one growing item). A full rebuild
//! shows that growth correctly at the cost of redrawing everything each
//! time; fine for this proof, wrong for a long, fast-streaming transcript
//! -- the incremental path that fixes it needs `transcript-ui` to expose
//! updating a row in place, which it does not yet. The composer's
//! in-progress text survives a rebuild (`rebuild_transcript`'s
//! `in_progress` local) since the user typing a followup while a reply
//! streams in is the one case a naive rebuild would otherwise lose data
//! on.
//! **Incoming SSE events go through `TranscriptScreen::apply`**, not a
//! full rebuild: `client_core::transcript_fold::fold_event` folds the new
//! item list as before, then `apply` updates only the row(s) that actually
//! changed (almost always the one still-open assistant message a delta
//! landed in) instead of rebuilding the whole right-hand widget tree from
//! scratch. `rebuild_transcript` still runs the whole tree once, for a
//! freshly loaded/selected session and for `apply`'s own rare
//! full-rebuild fallback (a `group_tool_runs` regroup touching a row
//! before the tail). The composer's in-progress text survives a rebuild
//! (`rebuild_transcript`'s `in_progress` local) since the user typing a
//! followup while a reply streams in is the one case a naive rebuild
//! would otherwise lose data on -- `apply`'s own path never touches the
//! composer at all, so this only matters on the fallback.
//!
//! Background network I/O (`client_core::api`/`event_stream`, both
//! blocking by design -- see `client-core`'s `Cargo.toml`) runs on plain
@@ -209,8 +205,12 @@ impl DefaultAppState for Client {
event,
} => {
if self.current(&session_id, generation) {
let old_items = self.items.clone();
self.items = fold_event(&self.items, &event);
self.rebuild_transcript(rsc);
match &self.screen {
Some(screen) => screen.apply(rsc, &old_items, &self.items),
None => self.rebuild_transcript(rsc),
}
}
}
AppEvent::StreamEnded {
+5 -2
View File
@@ -68,12 +68,15 @@ fn build_row<Rsc: UiRsc + 'static>(rsc: &mut Rsc, i: usize) -> StrongWidget {
let mut span = Span::empty(Dir::DOWN);
span.push(text);
span.push(img);
span.pad(8.0).background(rect(tint)).add_strong(rsc).any()
span.pad(dp(8.0))
.background(rect(tint))
.add_strong(rsc)
.any()
} else {
wtext(row_text(i))
.wrap(true)
.color(text_color)
.pad(8.0)
.pad(dp(8.0))
.background(rect(tint))
.add_strong(rsc)
.any()
+4
View File
@@ -12,6 +12,10 @@ impl<T: HasAndroidUiState> FocusHost for T {
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>) {
// Showing the keyboard is a JNI call (`InputMethodManager.showSoftInput`),
// 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>> {
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> {
+2 -1
View File
@@ -23,7 +23,8 @@ mod view;
pub use insets::Insets;
pub use render::AndroidRenderer;
pub use view::{
AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState, IrisViewPeer, new_peer,
AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState, IrisViewPeer, WindowInsets,
new_peer,
};
/// Registers the extra native methods this backend needs beyond what
+239 -8
View File
@@ -48,10 +48,65 @@ pub struct AndroidRenderer {
config: SurfaceConfiguration,
encoder: CommandEncoder,
pub ui: UiRenderNode,
/// The adapter identity, kept past `new()` for the Diagnostics page --
/// `Adapter` itself is not `Clone`, so the three fields the page shows
/// are copied out once here rather than holding the adapter.
pub adapter_name: String,
pub adapter_backend: Backend,
pub adapter_driver: String,
/// Every uncaptured wgpu error since this renderer was created -- see
/// `iris_core::WgpuErrorLog`'s doc comment. Installed on `device` in
/// `new()`, kept here so the Diagnostics page and the per-frame log in
/// `update()` can both read it without a global.
pub wgpu_errors: iris_core::WgpuErrorLog,
/// Frames drawn on this surface -- what gates the first-10-frames log
/// `update()` writes (RUST.md's P0 box, "the first input frame"
/// investigation): a fresh surface is exactly what Iris's own report
/// says renders correctly at first, so the frames that matter are the
/// first several after each `surface_changed`, not an arbitrary window
/// during a long-running session.
frame_count: u64,
/// Physical pixels per dp -- see `android::view::AndroidUiState::
/// content_scale`'s field comment for what this feeds.
content_scale: f32,
}
/// One frame's worth of the counters `render/mod.rs`'s doc comments on
/// `FrameUpdateStats`/`take_image_bind_group_creates`/
/// `take_atlas_pages_grown` describe -- assembled here because the three
/// live on two different calling conventions (`FrameUpdateStats` from this
/// exact `update()` call; the other two describe the *previous* frame,
/// same as `bench_images`' existing use of them) and a diagnostic reader
/// should not have to know that split.
#[derive(Clone, Copy, Debug, Default)]
pub struct FrameDiagnostics {
pub masks_resized: bool,
pub moves_resized: bool,
/// From the previous frame's `update()` -- see the struct doc.
pub atlas_pages_grown_prev: u64,
pub image_bind_group_creates_prev: u64,
}
impl AndroidRenderer {
pub fn new(window: NativeWindow, width: u32, height: u32) -> Self {
/// `Err` holds a full, human-readable report -- wgpu's own error text
/// (`UiRenderNode::new`'s doc comment) plus the adapter identity and
/// the limits/downlevel flags bind-group-layout validation checks
/// against -- rather than the panic wgpu's default error handler would
/// otherwise raise with no caller able to see it. This is what aborted
/// the P0 bench APK on Iris's phone with only "wgpu error: Validation
/// Error" surviving into the crash report (RUST.md's P0 box, "iris
/// bench crash on the phone, 2026-09-06"): `create_bind_group_layout`
/// validates against *this* adapter's downlevel capabilities and
/// limits, which a desktop GPU and the emulator's software renderers
/// never exercised. The caller (`android::view::IrisViewPeer::
/// surface_changed`) logs this one-line-flattened and shows it on
/// screen instead of aborting the process.
pub fn new(
window: NativeWindow,
width: u32,
height: u32,
content_scale: f32,
) -> Result<Self, String> {
// `force-gles` (RUST.md's I5 "Where iris's frame time goes") swaps
// the software-Vulkan (SwiftShader) path for GLES/virgl on the same
// build, to isolate whether the backend itself explains the frame
@@ -84,6 +139,18 @@ impl AndroidRenderer {
.block_on()
.expect("Could not get adapter!");
// Requesting the device itself still panics on failure: that is a
// `RequestDeviceError` (a limit or feature the adapter cannot grant
// at all), a different and already-diagnosable failure from the one
// this function now recovers from -- `RUST.md`'s "Software mode ...
// crashes for a third, different reason" is exactly that class, and
// its message already names the limit and the requested/allowed
// values with no truncation risk (it never reaches wgpu's
// uncaptured-error path). What this function's `Result` return
// covers is the *next* class of failure: the adapter grants the
// device, and validation only fails once a specific bind group
// layout is checked against it.
// Same request as the winit backend's `UiRenderer::new` -- no
// binding-array features, see TEXTURES.md's "Recommended shape".
// `iris_core::device_limits()` is shared between the two backends;
@@ -96,6 +163,30 @@ impl AndroidRenderer {
.block_on()
.expect("Could not get device!");
// wgpu's default handler for an error raised outside `UiRenderNode::
// new`'s own error scopes (i.e. everything past device creation --
// an ordinary frame's `update`/`draw`) is `panic!`, unconditionally,
// with no caller able to intervene: the same mechanism that aborted
// the P0 bench APK once already, just at a different call site. Log
// and record instead of letting that default stand -- RUST.md's P0
// box, "every wgpu uncaptured error ... it must never panic in
// release".
let wgpu_errors = iris_core::WgpuErrorLog::default();
let wgpu_errors_for_handler = wgpu_errors.clone();
device.on_uncaptured_error(std::sync::Arc::new(move |error| {
log::error!("iris wgpu uncaptured error: {error}");
wgpu_errors_for_handler.record(error);
}));
let info = adapter.get_info();
let adapter_name = info.name.clone();
let adapter_backend = info.backend;
let adapter_driver = if info.driver_info.is_empty() {
info.driver.clone()
} else {
format!("{} {}", info.driver, info.driver_info)
};
let surface_caps = surface.get_capabilities(&adapter);
let surface_format = surface_caps
.formats
@@ -117,16 +208,121 @@ impl AndroidRenderer {
surface.configure(&device, &config);
let encoder = Self::create_encoder(&device);
let ui = UiRenderNode::new(&device, &queue, &config);
// Physical pixels, matching the swapchain's own `width`/`height`
// exactly -- see `android::view::AndroidUiState::content_scale`'s
// field comment for why this is no longer divided into a separate
// logical space (that stopgap is what made text blurry, RUST.md's
// P0 box). `Len::dp` folds the density in at layout time instead,
// so nothing here needs to know it at all.
let window_size = iris_core::util::Vec2::new(width as f32, height as f32);
let ui = match UiRenderNode::new(&device, &queue, &config, window_size) {
Ok(ui) => ui,
Err(wgpu_error) => return Err(Self::diagnostic(&adapter, &wgpu_error)),
};
Self {
Ok(Self {
surface,
device,
queue,
config,
encoder,
ui,
}
adapter_name,
adapter_backend,
adapter_driver,
wgpu_errors,
frame_count: 0,
content_scale,
})
}
/// The adapter identity plus every limit and downlevel flag
/// `create_bind_group_layout` validates a storage buffer or texture
/// binding against, followed by wgpu's own error text -- everything a
/// person reading this off a screenshot needs to tell "this adapter
/// lacks X" from "this is a bug in the layout." Named explicitly rather
/// than `{limits:?}`/`{flags:?}` wholesale, because `Limits` alone is
/// dozens of fields nobody asked for -- these are exactly the ones
/// `UiRenderNode::new`'s layouts (`rsc_layout`, `masks_layout`,
/// `primitive_layout`) can fail against, per `CreateBindGroupLayoutError`
/// (`wgpu-core::binding_model`) and its downlevel-flag checks
/// (`wgpu-core::device::resource`, `VERTEX_STORAGE` in particular --
/// the one storage buffer here, `move_offsets`, that is visible to the
/// vertex stage).
fn diagnostic(adapter: &Adapter, wgpu_error: &str) -> String {
let info = adapter.get_info();
let limits = adapter.limits();
let downlevel = adapter.get_downlevel_capabilities();
format!(
"iris could not start rendering. Copy this text and send it to Iris.\n\n\
adapter: {name} ({backend:?}), driver: {driver} {driver_info}\n\
limits: max_storage_buffers_per_shader_stage={max_storage_buffers} \
max_sampled_textures_per_shader_stage={max_sampled_textures} \
max_bind_groups={max_bind_groups} \
max_bindings_per_bind_group={max_bindings} \
max_storage_buffer_binding_size={max_storage_binding} \
min_storage_buffer_offset_alignment={min_storage_align}\n\
downlevel flags: {flags:?}\n\n\
{wgpu_error}",
name = info.name,
backend = info.backend,
driver = info.driver,
driver_info = info.driver_info,
max_storage_buffers = limits.max_storage_buffers_per_shader_stage,
max_sampled_textures = limits.max_sampled_textures_per_shader_stage,
max_bind_groups = limits.max_bind_groups,
max_bindings = limits.max_bindings_per_bind_group,
max_storage_binding = limits.max_storage_buffer_binding_size,
min_storage_align = limits.min_storage_buffer_offset_alignment,
flags = downlevel.flags,
)
}
/// The Diagnostics page's whole report: adapter identity, font
/// resolution, the atlas's own view count, every uncaptured wgpu error
/// so far, and the frame report -- RUST.md's P0 box, "a named
/// `Diagnostics` control ... adapter info, limits, fonts found, atlas
/// format/pages, wgpu errors so far, frame report". One string rather
/// than a struct the caller formats, since the only consumer is a
/// plain `TextView` with a "copy this and send it to Iris" affordance,
/// the same shape `surface_changed`'s crash report already uses
/// (UI_RULES.md: a failure -- or here, a state worth reporting --
/// carries enough to act on where it's shown).
pub fn diagnostics_report(
&self,
font: &iris_core::FontDiagnostics,
frame_report: &str,
) -> String {
let errors = self.wgpu_errors.snapshot();
let errors_text = if errors.is_empty() {
"none".to_string()
} else {
errors.join("\n ")
};
format!(
"iris diagnostics. Copy this text and send it to Iris.\n\n\
adapter: {name} ({backend:?}), driver: {driver}\n\
content_scale: {content_scale}\n\
atlas format: Rgba8Unorm, views live: {views}\n\
fonts: {families_found} families found, default={default_family:?} \
mono={default_mono_family:?}\n\
fonts resolved: regular={regular:?} bold={bold:?} italic={italic:?} \
mono={mono:?}\n\
wgpu errors since surface creation:\n {errors_text}\n\n\
{frame_report}",
name = self.adapter_name,
backend = self.adapter_backend,
driver = self.adapter_driver,
content_scale = self.content_scale,
views = self.ui.view_count(),
families_found = font.families_found,
default_family = font.default_family,
default_mono_family = font.default_mono_family,
regular = font.regular_resolved,
bold = font.bold_resolved,
italic = font.italic_resolved,
mono = font.mono_resolved,
)
}
fn create_encoder(device: &Device) -> CommandEncoder {
@@ -135,8 +331,31 @@ impl AndroidRenderer {
})
}
pub fn update(&mut self, ui: &mut UiData, render: &mut UiRenderState) {
self.ui.update(&self.device, &self.queue, ui, render);
/// Returns what changed this frame -- see `FrameDiagnostics`'s doc
/// comment for why two of its four fields describe the *previous*
/// frame rather than this one. `IrisViewPeer::render` logs this for
/// the first `DIAGNOSTIC_FRAMES` frames after each `surface_changed`,
/// per RUST.md's P0 box ("the first input frame" investigation): the
/// glyph-wipe Iris reported happens on the first tap or scroll after a
/// fresh surface, so that is exactly the window a report needs to
/// cover, not an arbitrary slice of a long session.
pub fn update(&mut self, ui: &mut UiData, render: &mut UiRenderState) -> FrameDiagnostics {
let atlas_pages_grown_prev = self.ui.take_atlas_pages_grown();
let image_bind_group_creates_prev = self.ui.take_image_bind_group_creates();
let stats = self.ui.update(&self.device, &self.queue, ui, render);
self.frame_count += 1;
FrameDiagnostics {
masks_resized: stats.masks_resized,
moves_resized: stats.moves_resized,
atlas_pages_grown_prev,
image_bind_group_creates_prev,
}
}
/// Frames drawn on this surface so far -- see `frame_count`'s field
/// comment.
pub fn frame_count(&self) -> u64 {
self.frame_count
}
/// Draws and presents one frame, returning the time spent in
@@ -179,15 +398,27 @@ impl AndroidRenderer {
submit_start.elapsed()
}
/// Physical pixels -- the unit layout and hit-testing use, matching
/// the window uniform's own units. See
/// `android::view::AndroidUiState::content_scale`'s field comment.
pub fn size(&self) -> iris_core::util::Vec2 {
(self.config.width, self.config.height).into()
iris_core::util::Vec2::new(self.config.width as f32, self.config.height as f32)
}
/// Reconfigures the surface and rewrites the window uniform for a new
/// physical size -- deliberately the *only* two things this does.
/// `device`, `ui`'s atlas, buffers and bind groups are untouched, so a
/// call here (as opposed to a fresh `AndroidRenderer::new`) never
/// invalidates a glyph the CPU-side cache already placed in the atlas.
/// See `android::view::IrisViewPeer::surface_changed`'s doc comment for
/// why that distinction matters -- it is what keeps text on screen
/// across an IME resize.
pub fn resize(&mut self, width: u32, height: u32) {
self.config.width = width;
self.config.height = height;
self.surface.configure(&self.device, &self.config);
self.ui.resize((width, height), &self.queue);
let size = iris_core::util::Vec2::new(width as f32, height as f32);
self.ui.resize(size, &self.queue);
}
}
+286 -17
View File
@@ -4,7 +4,11 @@ use accesskit_android::Adapter as AccessAdapter;
use android_view::{
AccessibilityNodeInfo, AccessibilityNodeProvider, Bundle, CallbackCtx, Context,
InputConnection, KeyEvent, MotionEvent, Rect, View, ViewPeer,
jni::{JNIEnv, JavaVM, objects::GlobalRef, sys::jint},
jni::{
JNIEnv, JavaVM,
objects::{GlobalRef, JValue},
sys::jint,
},
ndk::event::{Keycode, MotionAction},
};
// `marker::Sized` explicitly: `crate::prelude::*` below also brings in the
@@ -29,6 +33,10 @@ use super::{
/// `Option` because a `SurfaceView`'s surface does not outlive backgrounding
/// the way a winit `Window` does -- `surfaceDestroyed`/`surfaceCreated` can
/// happen any number of times over the life of one `IrisViewPeer`.
/// How many frames after each `surface_changed` `render()` logs a full
/// diagnostic line for -- see the log site's own comment.
const DIAGNOSTIC_FRAMES: u64 = 10;
pub struct AndroidUiState {
pub root: Option<StrongWidget>,
pub renderer: Option<AndroidRenderer>,
@@ -62,10 +70,41 @@ pub struct AndroidUiState {
/// because `dumpsys gfxinfo` cannot see a `SurfaceView`'s own
/// GPU-drawn frames at all. See `iris_core::FrameReport`'s own doc.
pub frame_report: FrameReport,
/// `DisplayMetrics.density` (`new_peer`'s doc comment): physical pixels
/// per dp on this device, read once at view construction and carried
/// on `UiRenderState::density` (`render.set_density`, `new_peer`) from
/// then on -- every `Len::dp` in the widget tree resolves against it at
/// layout time (`Len::dp`'s field doc, IRIS_TODO.md's
/// "density-independent length unit" item, 2026-09-06).
///
/// **Everything else in this module is physical pixels, matching the
/// real wgpu surface/swapchain resolution** -- window size, touch
/// coordinates, insets. That is a correction from an earlier version
/// of this comment, which had `window_size`/`surface_changed`'s
/// `UiRenderState::resize` call divide by `content_scale` into a
/// *logical* coordinate space instead, as a global stopgap for
/// RUST.md's P0 box's phone report ("text is far too small"). That
/// stopgap fixed the size but not the *sharpness*: dividing to logical
/// units meant a `16.0`-sized glyph rasterised at 16 physical px and
/// then implicitly upscaled ~3x by the NDC mapping onto the real
/// physical framebuffer -- the exact "blurry ... glyphs drawn at
/// logical size and stretched by the scale" Iris reported next.
/// Resolving `dp` at layout time replaces it: a widget author writes
/// `dp(16)` for a size that should look the same physical size on any
/// density, and everything downstream (layout, hit-testing, the window
/// uniform, and the font size handed to the text shaper) works in the
/// display's own physical pixels throughout, so nothing is
/// rasterised at one resolution and displayed at another.
pub content_scale: f32,
/// The last insets `render()` saw -- compared each frame so
/// `AndroidAppState::on_insets_changed` fires only when they actually
/// change (once at startup for the status bar, again if the device
/// rotates), not every frame.
last_insets: Insets,
}
impl AndroidUiState {
fn new(shared: Rc<RefCell<Shared>>) -> Self {
fn new(shared: Rc<RefCell<Shared>>, content_scale: f32) -> Self {
Self {
root: None,
renderer: None,
@@ -78,6 +117,8 @@ impl AndroidUiState {
access_adapter: Default::default(),
access: AccessTree::new(),
frame_report: FrameReport::new(),
content_scale,
last_insets: Insets::default(),
}
}
@@ -120,6 +161,48 @@ pub trait AndroidAppState: HasAndroidUiState {
/// storing them has no effect on that mechanism.
#[allow(unused_variables)]
fn platform_ready(&mut self, rsc: &mut AndroidRsc<Self>, vm: JavaVM, view: GlobalRef) {}
/// Called from `render()` whenever `AndroidUiState::insets()` differs
/// from what it was last frame -- once at startup for the status bar
/// (RUST.md's P0 box: "the status-bar inset is not applied" reported
/// the two top buttons sitting under it, because nothing read `.top`
/// at all), and again on a rotation or the keyboard opening/closing.
/// `insets` is in the same physical-pixel units everything else in the
/// tree now uses (`AndroidUiState::content_scale`'s field comment), so
/// a widget can add it to a layout size directly -- `dp(...) +
/// abs(insets.top)` if the widget wants a density-independent size
/// plus the system bar's own (already-physical) height. The default
/// does nothing -- most screens have no chrome that sits under a
/// system bar.
#[allow(unused_variables)]
fn on_insets_changed(&mut self, rsc: &mut AndroidRsc<Self>, insets: WindowInsets) {}
}
/// `insets::Insets` as `f32`, for the widget-facing callback above -- a
/// distinct type from `insets::Insets` so a caller of `on_insets_changed`
/// is not coupled to that module's own (`i32`, JNI-shaped) representation.
/// Both are physical pixels; this used to divide by `content_scale` into a
/// separate *logical* unit (hence the old name, `LogicalInsets`), back when
/// the rest of layout was logical too -- see `AndroidUiState::content_scale`'s
/// field comment for why that stopgap is gone.
#[derive(Clone, Copy, Default, Debug, PartialEq)]
pub struct WindowInsets {
pub left: f32,
pub top: f32,
pub right: f32,
pub bottom: f32,
pub ime_bottom: f32,
}
impl WindowInsets {
fn from_physical(insets: Insets) -> Self {
Self {
left: insets.left as f32,
top: insets.top as f32,
right: insets.right as f32,
bottom: insets.bottom as f32,
ime_bottom: insets.ime_bottom as f32,
}
}
}
/// The android-view analogue of `default::DefaultRsc` -- identical in
@@ -242,6 +325,13 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
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();
ui_state.cursor.end_frame();
if self.render.needs_redraw(&ui_state.root, self.rsc.widgets()) {
@@ -266,10 +356,23 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
/// these in until that is root-caused; removing them loses the exact
/// evidence a `logcat` capture needs to reproduce the state.
fn render(&mut self, ctx: &mut CallbackCtx) {
let ui_state = self.state.android_state();
if ui_state.renderer.is_none() {
if self.state.android_state().renderer.is_none() {
return;
}
// See `AndroidAppState::on_insets_changed`'s doc comment: fires
// exactly when insets actually differ from last frame, not every
// frame -- most frames this is one `Insets` equality check against
// a `Copy` struct. Done before `ui_state` is bound below, since
// `on_insets_changed` needs `&mut self.state`/`&mut self.rsc` both.
let ui_state = self.state.android_state();
let current_insets = ui_state.insets();
if current_insets != ui_state.last_insets {
let physical = WindowInsets::from_physical(current_insets);
self.state.android_state_mut().last_insets = current_insets;
self.state.on_insets_changed(&mut self.rsc, physical);
}
let ui_state = self.state.android_state();
log::debug!(
"render(): root={:?} widgets={} active={} root_px={:?} out_size={:?}",
ui_state.root.is_some(),
@@ -294,7 +397,27 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
let Some(renderer) = &mut ui_state.renderer else {
return;
};
renderer.update(&mut self.rsc.ui, &mut self.render);
let frame_diagnostics = renderer.update(&mut self.rsc.ui, &mut self.render);
// First `DIAGNOSTIC_FRAMES` frames after each `surface_changed`
// only -- RUST.md's P0 box, "the first input frame" investigation:
// the glyph-wipe Iris reported happens on the first tap or scroll
// after a fresh surface, so a report from that window is what
// would show whether an atlas grow, a masks/move_offsets resize, or
// a fresh wgpu error coincided with it. `frame_count()` was just
// incremented inside `update()`, so `<=` counts frame 1 through
// `DIAGNOSTIC_FRAMES` inclusive.
if renderer.frame_count() <= DIAGNOSTIC_FRAMES {
log::info!(
"iris frame diagnostics: frame={} masks_resized={} moves_resized={} \
atlas_pages_grown_prev={} image_bind_group_creates_prev={} wgpu_errors={}",
renderer.frame_count(),
frame_diagnostics.masks_resized,
frame_diagnostics.moves_resized,
frame_diagnostics.atlas_pages_grown_prev,
frame_diagnostics.image_bind_group_creates_prev,
renderer.wgpu_errors.snapshot().len(),
);
}
let submit_to_present = renderer.draw();
self.state
.android_state_mut()
@@ -337,6 +460,31 @@ fn show_soft_input<'local>(env: &mut JNIEnv<'local>, view: &View<'local>) {
imm.show_soft_input(env, view, 0);
}
/// Replaces the activity's content with a plain, selectable, scrollable
/// text view holding `report` -- the on-screen half of `surface_changed`'s
/// renderer-failure path (UI_RULES.md: "a failure is reported where it
/// happened, and says what to do next," here "copy this and send it").
/// Goes through an ordinary instance method on the Java side
/// (`IrisView.showRendererError`) rather than a new `native` method: this
/// call is Rust reaching *into* Java, the opposite direction from every
/// `native fn` android-view/`IrisView` declare, and an ordinary virtual
/// call resolves against `ctx.view`'s real runtime class (`IrisView`) the
/// same way any other JNI method call here does. Silently does nothing on
/// any JNI failure -- there is no more-fallback screen to fall back to,
/// and the `log::error!` in `surface_changed` already reached logcat
/// first.
fn show_renderer_error<'local>(env: &mut JNIEnv<'local>, view: &View<'local>, report: &str) {
let Ok(message) = env.new_string(report) else {
return;
};
let _ = env.call_method(
&view.0,
"showRendererError",
"(Ljava/lang/String;)V",
&[JValue::Object(message.as_ref())],
);
}
impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
fn on_key_down<'local>(
&mut self,
@@ -379,6 +527,8 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
) -> bool {
self.drain_tasks();
let action = event.action_masked(&mut ctx.env);
// Device (physical) pixels, same space layout now uses throughout
// -- see `AndroidUiState::content_scale`'s field comment.
let x = event.x(&mut ctx.env);
let y = event.y(&mut ctx.env);
let ui_state = self.state.android_state_mut();
@@ -431,7 +581,6 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
height: i32,
) {
self.drain_tasks();
let window = holder.surface(&mut ctx.env).to_native_window(&mut ctx.env);
// The layout engine's own notion of the canvas size is separate
// from the wgpu surface's -- winit's backend sets it from
// `WindowEvent::Resized`, and there is no equivalent automatic
@@ -440,13 +589,117 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
// nothing but the clear colour: the widget tree laid out against
// whatever size `UiRenderState::new` starts at instead of the
// surface's real one.
self.render.resize((width as u32, height as u32));
// Drop the old renderer (and the surface it owns) before building
// one from the new window -- see `AndroidRenderer`'s doc comment.
let ui_state = self.state.android_state_mut();
ui_state.renderer = None;
ui_state.renderer = Some(AndroidRenderer::new(window, width as u32, height as u32));
self.render(ctx);
//
// **Physical pixels, matching `AndroidRenderer`'s own
// `size()`/`resize()`/`new()`** -- `AndroidUiState::content_scale`'s
// field comment. This call sets `UiRenderState::output_size`, which
// every `rel`/`rest` length resolves against and every `abs`
// pixel-region compares to directly; a `dp(56)` height now folds
// in the density at `Len::apply_rest` time instead of this call
// dividing the whole window into a separate logical space, which
// is what used to make every `abs`-unit size (a fixed `.height(56)`
// in particular) mean something different from a `rest`-based one.
self.render.resize((width as f32, height as f32));
// **Reuse the existing renderer (device, atlas, buffers, bind
// groups) when one is already live -- only reconfigure the
// surface.** `surfaceChanged` fires on *every* size or format
// change, not only on a genuinely new `Surface`/window: showing
// the IME under `adjustResize` resizes the same `SurfaceView` and
// is reported through this exact callback. Rebuilding the whole
// `AndroidRenderer` here used to mean a fresh `UiRenderNode::new`
// -- a brand-new, empty glyph atlas and fresh GPU buffers -- while
// `iris_core`'s CPU-side glyph cache (`primitive/text.rs`) kept the
// atlas coordinates it had already handed out against the *old*
// atlas. Every glyph then drew from a UV rectangle that pointed
// into a texture that had just been recreated empty, so text
// vanished on the first keyboard open while rects (which never go
// through the atlas) kept drawing -- exactly the "rectangles stay,
// glyphs disappear" Iris reported. Confirmed by reading this path
// end to end (no fresh-atlas rebuild anywhere in `resize()` below,
// only in `AndroidRenderer::new`) before changing anything, per
// AGENTS.md's "verify before finishing".
//
// `AndroidRenderer::resize` only reconfigures the wgpu surface and
// rewrites the window uniform -- device, atlas, buffers and bind
// groups are untouched, so the glyph cache's coordinates stay
// valid. A genuinely new surface (after `surface_destroyed`, e.g.
// backgrounding) still goes through `AndroidRenderer::new` below,
// since `renderer` is `None` in that case.
let already_live = self.state.android_state().renderer.is_some();
if already_live {
let ui_state = self.state.android_state_mut();
ui_state
.renderer
.as_mut()
.expect("checked Some above")
.resize(width as u32, height as u32);
self.render(ctx);
return;
}
let window = holder.surface(&mut ctx.env).to_native_window(&mut ctx.env);
// `AndroidRenderer::new` used to panic here through wgpu's own
// default uncaptured-error handler on a bind-group-layout
// validation failure -- exactly what aborted the P0 bench APK on
// Iris's phone with the message truncated to "wgpu error:
// Validation Error" and nothing else recoverable from the crash
// report (RUST.md's P0 box, "iris bench crash on the phone,
// 2026-09-06"). It now returns the full diagnostic instead; this is
// the one place in the app that can turn it into something a
// person can read, since `ctx.view`/`ctx.env` (needed to reach the
// Java side) are only in scope inside a `ViewPeer` callback.
//
// `content_scale` reaches `AndroidRenderer` only for the
// Diagnostics page's report text now -- window size and the
// shader's window uniform are physical pixels throughout (see the
// `resize` call above), not divided by it.
let content_scale = self.state.android_state().content_scale;
match AndroidRenderer::new(window, width as u32, height as u32, content_scale) {
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.render(ctx);
}
Err(report) => {
// One line for logcat (UI_RULES.md: "the full text for
// whoever can read the log" lives here), the multi-line
// original on screen -- `show_renderer_error` below.
log::error!("iris renderer init failed: {}", report.replace('\n', " | "));
// Deferred, not called directly: `Activity::setContentView`
// tears the old view hierarchy down synchronously, which
// fires `IrisView`'s own `onFocusChanged` before
// `setContentView` returns -- straight back into this same
// `IrisViewPeer` through `on_focus_changed` while
// `with_peer` (android-view's dispatch, `view.rs` upstream)
// still holds this peer's `RefCell` borrow for the
// `surface_changed` call in progress. Found by inducing a
// validation error and hitting `RefCell already borrowed`
// at exactly that reentrant call (RUST.md's P0 box).
// `push_dynamic_deferred_callback` runs after `with_peer`
// drops the borrow, which is what every other callback in
// this file that reaches into Java already relies on
// (`raise_if_enabled`, above).
ctx.push_dynamic_deferred_callback(move |env, view| {
show_renderer_error(env, view, &report);
});
}
}
}
fn surface_destroyed<'local>(
@@ -557,10 +810,19 @@ impl<State: AndroidAppState> AccessibilityNodeProvider for IrisViewPeer<State> {
/// `register_view_class`, which wants a plain function pointer) -- see
/// `iris/android-app/src/lib.rs`.
pub fn new_peer<'local, State: AndroidAppState>(
env: JNIEnv<'local>,
mut env: JNIEnv<'local>,
view: View<'local>,
_context: Context<'local>,
context: Context<'local>,
) -> android_view::jni::sys::jlong {
// `DisplayMetrics.density` -- physical pixels per dp on this device.
// Read once here, at the one point in this file already handed a
// `Context`, and carried on `AndroidUiState` from then on (see
// `content_scale`'s field comment for what depends on it).
let content_scale = context
.resources(&mut env)
.display_metrics(&mut env)
.density(&mut env);
log::info!("iris: new_peer content_scale={content_scale}");
let vm = env.get_java_vm().unwrap();
let global_view = env.new_global_ref(&view.0).unwrap();
let redraw: Arc<dyn RequestRedraw> = Arc::new(AndroidRedrawHandle::new(vm, global_view));
@@ -572,15 +834,22 @@ pub fn new_peer<'local, State: AndroidAppState>(
state: Default::default(),
_state: PhantomData,
};
// See `TextData::density`'s field doc for why this is set alongside
// `render.set_density` below rather than read from there.
rsc.ui.text.density = content_scale;
let shared = Rc::new(RefCell::new(Shared::default()));
let ui_state = AndroidUiState::new(shared.clone());
let ui_state = AndroidUiState::new(shared.clone(), content_scale);
let mut state = State::new(ui_state, &mut rsc);
let platform_vm = env.get_java_vm().unwrap();
let platform_view = env.new_global_ref(&view.0).unwrap();
state.platform_ready(&mut rsc, platform_vm, platform_view);
let mut render = UiRenderState::new();
// Every `Len::dp` in the tree resolves against this from now on -- see
// `UiRenderState::density`'s field doc and `Len::dp`'s.
render.set_density(content_scale);
let peer = IrisViewPeer {
rsc,
render: UiRenderState::new(),
render,
state,
task_recv,
};
+70 -12
View File
@@ -22,6 +22,13 @@ pub trait FocusHost {
/// it was hit in (`None` when the widget could not be located, which
/// happens for one it was just deselected from).
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
@@ -33,6 +40,17 @@ pub fn recent_click(last_click: &mut Instant) -> bool {
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;
impl<Rsc: HasEvents, W: Widget + 'static> WidgetAttr<Rsc, W> for Selector
@@ -42,7 +60,7 @@ where
type Input = WeakWidget<TextEdit>;
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 id_pos = region.top_left;
let container_pos = ctx
@@ -53,14 +71,14 @@ where
.top_left;
let pos = ctx.data.pos + container_pos - id_pos;
let size = region.size();
select(
on_press(
rsc,
ctx.data.render,
ctx.state,
id,
pos,
size,
ctx.data.sense.is_dragging(),
ctx.data.sense,
);
});
}
@@ -75,31 +93,71 @@ where
type Input = ();
fn run(rsc: &mut Rsc, id: WeakWidget<TextEdit>, _: Self::Input) {
rsc.register_event(id, CursorSense::click_or_drag(), move |ctx, rsc| {
select(
rsc.register_event(id, press_track(), move |ctx, rsc| {
on_press(
rsc,
ctx.data.render,
ctx.state,
id,
ctx.data.pos,
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,
render: &UiRenderState,
state: &mut impl FocusHost,
id: WeakWidget<TextEdit>,
pos: Vec2,
size: Vec2,
dragging: bool,
sense: CursorSense,
) {
let recent = state.recent_click();
id.edit(rsc).select(pos, size, dragging, recent);
state.set_focus(Some(id));
state.focus_gained(render.window_region(&id, &*rsc));
if state.is_focused(id) {
let recent = matches!(sense, CursorSense::PressStart(_)) && state.recent_click();
id.edit(rsc).select(pos, size, sense.is_dragging(), recent);
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;
}
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool {
self.default_state().focus == Some(id)
}
fn focus_gained(&mut self, region: Option<PixelRegion>) {
let state = self.default_state_mut();
let Some(region) = region else { return };
+17 -5
View File
@@ -11,10 +11,15 @@ pub struct Input {
}
impl Input {
pub fn event(&mut self, event: &WindowEvent) -> bool {
/// `scale_factor` converts winit's physical-pixel event coordinates
/// into the same logical units `UiRenderNode`'s window uniform now uses
/// (`default::render::UiRenderer::new`'s doc comment) -- without it,
/// a cursor position and the widget tree it's tested against would be
/// in two different units on any monitor whose scale factor isn't 1.0.
pub fn event(&mut self, event: &WindowEvent, scale_factor: f32) -> bool {
match event {
WindowEvent::CursorMoved { position, .. } => {
self.cursor.pos = Vec2::new(position.x as f32, position.y as f32);
self.cursor.pos = Vec2::new(position.x as f32, position.y as f32) / scale_factor;
self.cursor.exists = true;
}
WindowEvent::MouseInput { state, button, .. } => {
@@ -30,7 +35,9 @@ impl Input {
WindowEvent::MouseWheel { delta, .. } => {
let mut delta = match *delta {
MouseScrollDelta::LineDelta(x, y) => Vec2::new(x, y),
MouseScrollDelta::PixelDelta(pos) => Vec2::new(pos.x as f32, pos.y as f32),
MouseScrollDelta::PixelDelta(pos) => {
Vec2::new(pos.x as f32, pos.y as f32) / scale_factor
}
};
if delta.x == 0.0 && self.modifiers.shift {
delta.x = delta.y;
@@ -68,8 +75,13 @@ impl Input {
impl DefaultUiState {
pub fn window_size(&self) -> Vec2 {
let size = self.renderer.window().inner_size();
(size.width, size.height).into()
let window = self.renderer.window();
let size = window.inner_size();
let scale_factor = window.scale_factor() as f32;
Vec2::new(
size.width as f32 / scale_factor,
size.height as f32 / scale_factor,
)
}
pub fn cursor_state(&self) -> &CursorState {
+2 -1
View File
@@ -246,7 +246,8 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
ui_state
.access_adapter
.process_event(&ui_state.window, &event);
let input_changed = ui_state.input.event(&event);
let scale_factor = ui_state.renderer.window().scale_factor() as f32;
let input_changed = ui_state.input.event(&event, scale_factor);
let cursor_state = ui_state.cursor_state().clone();
let old = ui_state.focus;
if cursor_state.buttons.left.is_start() {
+30 -3
View File
@@ -1,5 +1,5 @@
use crate::task::RequestRedraw;
use iris_core::{UiData, UiRenderNode, UiRenderState};
use iris_core::{UiData, UiRenderNode, UiRenderState, util::Vec2};
use pollster::FutureExt;
use std::sync::Arc;
use wgpu::*;
@@ -66,7 +66,13 @@ impl UiRenderer {
self.config.width = size.width;
self.config.height = size.height;
self.surface.configure(&self.device, &self.config);
self.ui.resize((size.width, size.height), &self.queue);
// Logical, matching `new`'s own seed -- see the comment there.
let scale_factor = self.window.scale_factor() as f32;
let logical = Vec2::new(
size.width as f32 / scale_factor,
size.height as f32 / scale_factor,
);
self.ui.resize(logical, &self.queue);
}
fn create_encoder(device: &Device) -> CommandEncoder {
@@ -141,7 +147,28 @@ impl UiRenderer {
let encoder = Self::create_encoder(&device);
let ui = UiRenderNode::new(&device, &queue, &config);
// Unlike the Android backend, the desktop backend has no on-screen
// fallback to show a diagnostic through, so a renderer-creation
// failure still panics here -- but now with wgpu's full "Caused
// by:" chain as the message, since `UiRenderNode::new` returns it
// rather than letting wgpu's own default handler panic first (see
// that function's doc comment).
// Logical size (physical / `scale_factor`), matching what the
// Android backend now reports too (`android::render::
// AndroidRenderer::new`, `content_scale`) -- the swapchain still
// configures at the real physical resolution above; only the
// window uniform layout/hit-testing agree on is scaled. Without
// this a window on any monitor whose scale factor isn't 1.0 would
// have the identical "everything too small" bug RUST.md's P0 box
// found on Iris's phone, just never noticed here because this
// crate's own dev monitors happen to run at 1.0.
let scale_factor = window.scale_factor() as f32;
let logical_size = Vec2::new(
size.width as f32 / scale_factor,
size.height as f32 / scale_factor,
);
let ui = UiRenderNode::new(&device, &queue, &config, logical_size)
.expect("Could not create iris render node!");
Self {
surface,
+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_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:?}"
);
}
+427 -4
View File
@@ -1,5 +1,6 @@
use crate::prelude::*;
use std::{
collections::VecDeque,
ops::{BitOr, Deref, DerefMut},
rc::Rc,
time::{Duration, Instant},
@@ -491,7 +492,25 @@ impl DragArbiter {
} else if dy.abs() > DRAG_SLOP && dy.abs() >= dx.abs() {
self.state = ArbiterState::Panning;
self.last = pos;
DragOutcome::Pan(dy)
// `dy` here is the *whole* drag since `press_start`,
// not since the last frame -- nothing panned while
// `Undecided` was withholding the slop, so applying it
// in full on this one frame is a visible jump the
// instant `DRAG_SLOP` is crossed (IRIS_TODO.md's
// "scrolling down sometimes jitters the text," root-
// caused by tracing `List`'s per-frame offset against
// a synthetic monotonic drag: the offset held flat for
// every `Undecided` frame, then stepped by several
// frames' worth of motion at once on the frame slop
// was crossed, before resuming ordinary per-frame
// deltas). Only the excess past the slop threshold is
// real, undecided motion the reader hasn't seen
// reflected yet -- so only that excess is applied now,
// the same way Android's own touch handling consumes
// `ViewConfiguration.getScaledTouchSlop()` once from
// the first scroll past it rather than replaying the
// whole pre-threshold drag in one step.
DragOutcome::Pan(dy - DRAG_SLOP.copysign(dy))
} else if now.duration_since(self.origin_at) >= LONG_PRESS
&& dx.abs() <= DRAG_SLOP
&& dy.abs() <= DRAG_SLOP
@@ -510,6 +529,390 @@ impl DragArbiter {
pub fn release(&mut self) {
self.state = ArbiterState::Idle;
}
/// Whether the arbiter's current gesture (if any) has committed to
/// panning -- what a caller checks at release time to decide whether
/// to hand the tracked velocity to [`crate::widget::List::fling`], per
/// IRIS_TODO.md's "swiping has no momentum": a fling must only follow
/// a pan, never a text selection that happened to end with the finger
/// still moving.
pub fn is_panning(&self) -> bool {
matches!(self.state, ArbiterState::Panning)
}
}
/// How far back a [`VelocityTracker`] looks when estimating a fling's
/// initial speed -- Android's own `VelocityTracker` defaults to a similar
/// short window so a gesture's last flick dominates over its slower start.
const VELOCITY_WINDOW: Duration = Duration::from_millis(100);
/// Tracks a drag's speed along one axis from its last ~100ms of motion, so
/// a release can be handed a realistic initial velocity for
/// [`AndroidFlingSpline`]/[`FlingCalculator`] rather than a single frame's
/// noisy last delta. Fed one timestamped pan delta per frame
/// (`add_sample`, the same `dy`/`-dy` quantity `DragArbiter::update`'s
/// `Pan` outcome already carries) and answers `velocity()` in units per
/// second, matching whatever unit the deltas were in.
#[derive(Default)]
pub struct VelocityTracker {
/// `(when, delta)` pairs, oldest first, trimmed to `VELOCITY_WINDOW`
/// on every `add_sample` -- so this never grows past however many
/// frames land in that window.
samples: VecDeque<(Instant, f32)>,
}
impl VelocityTracker {
pub fn new() -> Self {
Self::default()
}
/// Forget everything -- called on a fresh press, so a new gesture's
/// velocity is never contaminated by the tail of the previous one.
pub fn reset(&mut self) {
self.samples.clear();
}
/// Record one frame's motion. `delta` is this frame's movement since
/// the last sample, not a cumulative position.
pub fn add_sample(&mut self, delta: f32, at: Instant) {
self.samples.push_back((at, delta));
while let Some(&(when, _)) = self.samples.front() {
if at.duration_since(when) > VELOCITY_WINDOW {
self.samples.pop_front();
} else {
break;
}
}
}
/// The estimated speed, in units-per-second, over whatever samples
/// currently fall inside the tracking window: total motion divided by
/// the elapsed time between the oldest and newest sample still held.
/// `0.0` with fewer than two samples (no time span to divide by).
pub fn velocity(&self) -> f32 {
if self.samples.len() < 2 {
return 0.0;
}
let total: f32 = self.samples.iter().map(|&(_, d)| d).sum();
let span = self
.samples
.back()
.unwrap()
.0
.duration_since(self.samples.front().unwrap().0)
.as_secs_f32();
if span <= 0.0 { 0.0 } else { total / span }
}
}
/// Android's fling deceleration curve, ported from AOSP's
/// `android.widget.OverScroller.SplineOverScroller` (the same curve
/// Compose's `androidx.compose.ui.gestures.AndroidFlingSpline` and
/// `androidx.compose.foundation.gestures.FlingCalculator` reuse) so a
/// fling here travels the same distance a Compose `LazyColumn`'s own
/// `ScrollableDefaults.flingBehavior()` would for the same initial
/// velocity -- RUST.md's "Benchmark v2" box asked the two apps' fling
/// phase to be comparable, and IRIS_TODO.md's "swiping has no momentum"
/// asked for the same physics a reader's muscle memory already expects
/// from every other Android scroll view.
///
/// The curve is a cubic-Bezier-derived spline sampled into two lookup
/// tables at start-up (`SPLINE`, built once via [`std::sync::OnceLock`]):
/// `SPLINE_POSITION[i]`/`SPLINE_TIME[i]` give the fraction of total
/// distance/time elapsed at the `i`th of 100 even steps along the curve's
/// own parameter. A lookup at an arbitrary time fraction interpolates
/// between the two bracketing samples.
mod android_fling_spline {
use std::sync::OnceLock;
const NB_SAMPLES: usize = 100;
/// Where the two cubic tension lines cross (AOSP's own constant name
/// and value, `SplineOverScroller.INFLEXION`).
pub(super) const INFLEXION: f32 = 0.35;
const START_TENSION: f32 = 0.5;
const END_TENSION: f32 = 1.0;
const P1: f32 = START_TENSION * INFLEXION;
const P2: f32 = 1.0 - END_TENSION * (1.0 - INFLEXION);
pub(super) struct Spline {
position: [f32; NB_SAMPLES + 1],
time: [f32; NB_SAMPLES + 1],
}
fn build() -> Spline {
let mut position = [0.0f32; NB_SAMPLES + 1];
let mut time = [0.0f32; NB_SAMPLES + 1];
let (mut x_min, mut y_min) = (0.0f32, 0.0f32);
for i in 0..NB_SAMPLES {
let alpha = i as f32 / NB_SAMPLES as f32;
let mut x_max = 1.0f32;
let (mut x, mut coef);
loop {
x = x_min + (x_max - x_min) / 2.0;
coef = 3.0 * x * (1.0 - x);
let tx = coef * ((1.0 - x) * START_TENSION + x * END_TENSION) + x * x * x;
if (tx - alpha).abs() < 1e-5 {
break;
}
if tx > alpha {
x_max = x;
} else {
x_min = x;
}
}
position[i] = coef * ((1.0 - x) * P1 + x * P2) + x * x * x;
let mut y_max = 1.0f32;
let (mut y, mut coef_y);
loop {
y = y_min + (y_max - y_min) / 2.0;
coef_y = 3.0 * y * (1.0 - y);
let dy = coef_y * ((1.0 - y) * START_TENSION + y * END_TENSION) + y * y * y;
if (dy - alpha).abs() < 1e-5 {
break;
}
if dy > alpha {
y_max = y;
} else {
y_min = y;
}
}
time[i] = coef_y * ((1.0 - y) * P1 + y * P2) + y * y * y;
}
position[NB_SAMPLES] = 1.0;
time[NB_SAMPLES] = 1.0;
Spline { position, time }
}
static SPLINE: OnceLock<Spline> = OnceLock::new();
/// The fraction of total distance covered at `time_fraction` (0..=1
/// of the fling's total duration). Finds the bracketing samples in
/// `SPLINE_TIME` and interpolates linearly between their matching
/// `SPLINE_POSITION` entries, exactly as AOSP's `SplineOverScroller
/// .flingPosition` does.
pub(super) fn distance_fraction(time_fraction: f32) -> f32 {
let spline = SPLINE.get_or_init(build);
let t = time_fraction.clamp(0.0, 1.0);
let index = ((t * NB_SAMPLES as f32) as usize).min(NB_SAMPLES - 1);
let t_inf = spline.time[index];
let t_sup = spline.time[index + 1];
let d_inf = spline.position[index];
let d_sup = spline.position[index + 1];
let span = t_sup - t_inf;
if span <= 0.0 {
d_inf
} else {
d_inf + (d_sup - d_inf) * (t - t_inf) / span
}
}
}
/// AOSP `SplineOverScroller`'s two other physical constants: the default
/// `ViewConfiguration.getScrollFriction()` and the deceleration rate a
/// friction of `0.84` per frame at 60Hz corresponds to
/// (`ln(0.78)/ln(0.9)`, `SplineOverScroller.DECELERATION_RATE`).
const FLING_FRICTION: f32 = 0.015;
fn deceleration_rate() -> f32 {
(0.78f32.ln()) / (0.9f32.ln())
}
const GRAVITY_EARTH: f32 = 9.80665;
/// Turns an initial fling velocity into a total travel distance and
/// duration, following AOSP `SplineOverScroller`'s own closed-form
/// formulas (`getSplineFlingDistance`/the duration half of `fling()`) --
/// ported the same way Compose's `FlingCalculator` is, including its
/// `density`-dependent physical coefficient (`computeDeceleration`,
/// `GravityEarth * 39.37 * density * 160 * friction`). Density and
/// velocity/distance units cancel algebraically as long as velocity and
/// the returned distance share one pixel space (physical or logical) --
/// [`crate::widget::List::fling`] relies on exactly that cancellation to
/// avoid needing a display density of its own, since iris's `List`
/// already works in logical (density-independent) pixels throughout.
pub struct FlingCalculator {
physical_coefficient: f32,
}
impl FlingCalculator {
pub fn new(density: f32) -> Self {
Self {
physical_coefficient: GRAVITY_EARTH * 39.37 * density * 160.0 * FLING_FRICTION,
}
}
fn deceleration_for(&self, velocity: f32) -> f32 {
(android_fling_spline::INFLEXION * velocity.abs()
/ (FLING_FRICTION * self.physical_coefficient))
.ln()
}
/// Total signed distance the fling travels before settling, in the
/// same pixel units `velocity` was given in.
pub fn distance(&self, velocity: f32) -> f32 {
if velocity == 0.0 {
return 0.0;
}
let l = self.deceleration_for(velocity);
let rate = deceleration_rate();
let magnitude =
FLING_FRICTION * self.physical_coefficient * (rate / (rate - 1.0) * l).exp();
magnitude.copysign(velocity)
}
/// How long the fling takes to settle.
pub fn duration(&self, velocity: f32) -> Duration {
if velocity == 0.0 {
return Duration::ZERO;
}
let l = self.deceleration_for(velocity);
let rate = deceleration_rate();
Duration::from_secs_f32((l / (rate - 1.0)).exp())
}
/// The signed distance covered by `elapsed` into a fling of this
/// `velocity` that started at `t0` -- what a per-frame ticker
/// (`List::tick_fling`) calls to find how far to have scrolled by now.
/// Clamped to the full `distance()` once `elapsed` reaches
/// `duration()`, so a caller need not special-case "past the end."
pub fn position_at(&self, velocity: f32, elapsed: Duration) -> f32 {
let duration = self.duration(velocity);
if duration.is_zero() {
return 0.0;
}
let fraction = (elapsed.as_secs_f32() / duration.as_secs_f32()).min(1.0);
self.distance(velocity) * android_fling_spline::distance_fraction(fraction)
}
}
#[cfg(test)]
mod velocity_tracker_tests {
use super::*;
use std::sync::LazyLock;
// A single fixed base rather than a fresh `Instant::now()` per call --
// computing it once per test keeps every sample's spacing exact
// instead of at the mercy of however long the test itself takes to
// run between calls, the same reasoning `drag_arbiter_tests::t` uses.
static BASE: LazyLock<Instant> = LazyLock::new(Instant::now);
fn t(ms: u64) -> Instant {
*BASE + Duration::from_millis(ms)
}
#[test]
fn fewer_than_two_samples_reports_zero() {
let mut v = VelocityTracker::new();
assert_eq!(v.velocity(), 0.0);
v.add_sample(10.0, t(0));
assert_eq!(v.velocity(), 0.0);
}
#[test]
fn a_steady_drag_reports_its_own_speed() {
// 5px every 10ms, 11 samples spanning 100ms, sums to 55px over
// 0.1s -- 550px/s by this tracker's own "sum of deltas over the
// span between the oldest and newest held sample" definition.
let mut v = VelocityTracker::new();
for i in 0..=10 {
v.add_sample(5.0, t(i * 10));
}
assert!((v.velocity() - 550.0).abs() < 1.0, "got {}", v.velocity());
}
#[test]
fn only_the_last_100ms_of_samples_count() {
// An old, fast burst well outside the window followed by a slow,
// steady drag should report the recent speed, not the average of
// both -- otherwise a flick that trails off would still fling at
// its earlier, faster speed. The burst sits 110ms before the last
// sample, just past the 100ms window, so it is evicted.
let mut v = VelocityTracker::new();
v.add_sample(1000.0, t(0)); // will be 110ms old by the last sample
for i in 1..=11 {
v.add_sample(1.0, t(i * 10)); // 1px/10ms = 100px/s
}
assert!(
(v.velocity() - 110.0).abs() < 5.0,
"old burst leaked into the window: got {}",
v.velocity()
);
}
#[test]
fn reset_forgets_prior_samples() {
let mut v = VelocityTracker::new();
v.add_sample(500.0, t(0));
v.add_sample(500.0, t(10));
assert!(v.velocity() != 0.0);
v.reset();
assert_eq!(v.velocity(), 0.0);
}
}
#[cfg(test)]
mod fling_calculator_tests {
use super::*;
#[test]
fn zero_velocity_flings_nowhere() {
let calc = FlingCalculator::new(1.0);
assert_eq!(calc.distance(0.0), 0.0);
assert_eq!(calc.duration(0.0), Duration::ZERO);
}
#[test]
fn distance_grows_with_velocity_and_keeps_its_sign() {
let calc = FlingCalculator::new(2.75); // a typical phone's density
let d_slow = calc.distance(2000.0);
let d_fast = calc.distance(12000.0);
assert!(d_slow > 0.0);
assert!(d_fast > d_slow);
assert_eq!(calc.distance(-12000.0), -d_fast);
}
/// Summing the spline's own per-frame position deltas across the
/// whole fling has to land within 1% of the closed-form `distance()`
/// -- this is the guarantee that `List::tick_fling`'s per-frame reads
/// of `position_at` actually add up to the total the fling promised,
/// not merely that the two formulas look plausible independently.
#[test]
fn integrating_position_at_matches_the_closed_form_distance() {
let calc = FlingCalculator::new(1.0);
for velocity in [1500.0f32, 5000.0, 12000.0, -12000.0] {
let total = calc.distance(velocity);
let duration = calc.duration(velocity);
let final_position = calc.position_at(velocity, duration);
let err = (final_position - total).abs() / total.abs();
assert!(
err < 0.01,
"velocity {velocity}: position_at(duration)={final_position} vs distance()={total}, err={err}"
);
}
}
#[test]
fn position_at_is_monotonic_and_clamped_past_the_end() {
let calc = FlingCalculator::new(1.0);
let velocity = 12000.0f32;
let duration = calc.duration(velocity);
let total = calc.distance(velocity);
let mut last = 0.0;
let mut t = Duration::ZERO;
while t < duration {
let p = calc.position_at(velocity, t);
assert!(p >= last - 0.01, "position went backwards at {t:?}");
last = p;
t += Duration::from_millis(16);
}
// Well past the end, it stays pinned at the total -- a caller
// must be able to ask "where would this fling be" without first
// checking whether it has already settled.
assert_eq!(
calc.position_at(velocity, duration + Duration::from_secs(5)),
total
);
}
}
#[cfg(test)]
@@ -534,9 +937,13 @@ mod drag_arbiter_tests {
fn a_vertical_drag_pans_immediately() {
let mut a = DragArbiter::new();
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
// The transition frame applies only the motion past `DRAG_SLOP`
// (20 - 8 = 12), not the full 20px since `press_start` -- see the
// `Pan` arm's own comment for why replaying the whole withheld
// drag in one step is the scroll-jitter bug this guards against.
assert_eq!(
a.update(Vec2::new(0.0, 20.0), t(10)),
DragOutcome::Pan(20.0)
DragOutcome::Pan(12.0)
);
// Subsequent frames keep panning, by the delta since last frame.
assert_eq!(
@@ -545,6 +952,22 @@ mod drag_arbiter_tests {
);
}
/// Direct regression test for the fix: a slow drag that crosses
/// `DRAG_SLOP` by only a fraction of a pixel must not still produce a
/// visible jump -- the amount applied on the crossing frame should
/// itself shrink toward zero as the crossing gets closer to exactly
/// `DRAG_SLOP`, rather than always dumping the whole pre-threshold
/// distance at once.
#[test]
fn crossing_the_slop_by_a_little_pans_by_a_little() {
let mut a = DragArbiter::new();
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
assert_eq!(
a.update(Vec2::new(0.0, DRAG_SLOP + 0.5), t(10)),
DragOutcome::Pan(0.5)
);
}
#[test]
fn a_horizontal_drag_with_nothing_selected_does_not_select() {
let mut a = DragArbiter::new();
@@ -603,7 +1026,7 @@ mod drag_arbiter_tests {
a.press_start(Vec2::new(0.0, 0.0), t(0), true);
assert_eq!(
a.update(Vec2::new(0.0, 20.0), t(10)),
DragOutcome::Pan(20.0)
DragOutcome::Pan(12.0)
);
}
@@ -646,7 +1069,7 @@ mod drag_arbiter_tests {
a.press_start(Vec2::new(0.0, 700.0), t(0), false);
assert_eq!(
a.update(Vec2::new(0.0, 720.0), t(10)),
DragOutcome::Pan(20.0)
DragOutcome::Pan(12.0)
);
assert!(!a.is_idle());
}
+468 -8
View File
@@ -102,7 +102,7 @@
use crate::prelude::*;
use iris_core::util::HashMap;
use std::collections::VecDeque;
use std::{collections::VecDeque, sync::Arc, time::Instant};
/// A stable identifier for a loaded row, reused across pages so that a row
/// already measured and drawn is not treated as new when data is inserted
@@ -213,6 +213,39 @@ pub struct List {
/// row is evicted (`pop_front`/`pop_back`) so this cannot grow past
/// however many rows are currently loaded.
heights: HashMap<RowKey, f32>,
/// A fling in progress, or `None` if the list is at rest -- see
/// `fling`/`tick_fling`/`is_scrolling`, IRIS_TODO.md's "swiping has no
/// momentum."
fling: Option<Fling>,
/// What `tick_fling` re-arms every frame a fling is still running, so
/// the list keeps animating without needing a caller to poll it --
/// set once via `set_redraw_handle` by whoever owns the surface this
/// list draws into (the same handle `iris::task::Tasks::redraw_handle`
/// hands out elsewhere). `None` for a list that never flings
/// (headless tests, a caller driving `tick_fling` by hand as
/// `bench_client.rs`'s scripted phases do).
redraw: Option<Arc<dyn RequestRedraw>>,
/// Whether the last `draw` found no more content above the topmost
/// visible row (its top edge at or past the viewport's own top, with
/// no `prev_slot`) -- what `tick_fling` clamps a fling moving toward
/// the start against. Stale (from whatever the last draw found) on a
/// list that hasn't drawn yet; `false` by default, matching "assume
/// there is more content until a draw proves otherwise."
at_start: bool,
/// The mirror of `at_start` for the newest end.
at_end: bool,
}
/// One in-flight fling: the physics answer (`FlingCalculator`) plus how
/// much of its total distance has already been applied to the anchor, so
/// `tick_fling` only ever moves the list by this frame's *incremental*
/// delta -- matching every other place in this widget that scrolls by
/// writing `anchor.offset`.
struct Fling {
calc: FlingCalculator,
velocity: f32,
started_at: Instant,
applied: f32,
}
impl List {
@@ -226,6 +259,10 @@ impl List {
snap_end: true,
viewport_len: 0.0,
last_viewport_len: 0.0,
fling: None,
redraw: None,
at_start: false,
at_end: false,
pending_tap: None,
extents: HashMap::default(),
heights: HashMap::default(),
@@ -317,6 +354,40 @@ impl List {
self.extents.clear();
}
/// Swap the last row's widget for a new one **without moving it**: the
/// slot index is unchanged, so an anchor already pointing at this slot
/// (in particular `snap_end`'s pinned-to-newest case) stays pinned, and
/// an anchor pointing anywhere else -- this row scrolled out of view --
/// is untouched, so nothing currently on screen moves. This is what a
/// streamed reply needs: the row whose *content* keeps changing after
/// it first appears is still the same row by position, even if its
/// `RowKey` happens to change too (rare -- only `heights`/`extents` care
/// about the key, and both are invalidated here the same way
/// `pop_back` already invalidates them for the row it removes).
/// `None` if the list is empty. O(1), same as `push_back`/`pop_back`.
pub fn replace_back(&mut self, row: ListRow) -> Option<ListRow> {
let idx = self.items.len().checked_sub(1)?;
let old = std::mem::replace(&mut self.items[idx], row);
self.heights.remove(&old.key);
self.extents.clear();
Some(old)
}
/// Drop every loaded row and reset to the same state `List::new` would
/// give -- the fallback path for a change `apply`-style incremental
/// callers can't express as a replace-or-append (RUST.md: `group_tool_runs`
/// regrouping an earlier row). `more_before`/`more_after` are left
/// alone: a full paging reset is a different operation from "the
/// content changed," and a caller that wants both calls
/// `set_more_before(None)`/`set_more_after(None)` itself.
pub fn clear(&mut self) {
self.items.clear();
self.anchor = None;
self.snap_end = true;
self.heights.clear();
self.extents.clear();
}
/// Move the anchor's edge by `amt` pixels. Positive moves later
/// content into view (mirrors `Scroll::scroll`'s sign convention).
/// Deliberately unclamped -- see the module doc's "what is not
@@ -327,6 +398,115 @@ impl List {
}
}
/// Give this list a way to ask for another frame on its own, so a
/// fling keeps animating without a caller polling it every tick --
/// see the `redraw` field's doc. Pass the same handle
/// `iris::task::Tasks::redraw_handle` hands a `spawn`ed task; a list
/// that never calls this can still `fling`, but has to be driven by a
/// caller-owned loop instead (`bench_client.rs`'s scripted phases do
/// exactly that, since they need to await settling rather than let it
/// run in the background).
pub fn set_redraw_handle(&mut self, handle: Arc<dyn RequestRedraw>) {
self.redraw = Some(handle);
}
/// Start a fling at `velocity_px_per_s` (this widget's own pixel
/// space, same sign convention as `scroll`'s `amt`: positive continues
/// moving later content into view). Cancels any fling already in
/// progress. A caller with a live touch/press must cancel this on the
/// next touch-down (`cancel_fling`) -- `AndroidFlingSpline`'s curve
/// has no idea a finger came back down, and Android's own `Scroller`
/// relies on the view calling `abortAnimation` for the same reason.
///
/// Density cancels out of the underlying spline as long as velocity
/// and the distance it produces share one pixel space (see
/// `FlingCalculator`'s own doc) -- `List` works entirely in logical
/// pixels, so `1.0` here is not a placeholder for "unknown density,"
/// it is the correct density for a self-consistent unit system.
pub fn fling(&mut self, velocity_px_per_s: f32) {
if velocity_px_per_s == 0.0 || self.anchor.is_none() {
self.fling = None;
return;
}
self.fling = Some(Fling {
calc: FlingCalculator::new(1.0),
velocity: velocity_px_per_s,
started_at: Instant::now(),
applied: 0.0,
});
}
/// Whether a fling is currently animating. What a caller's own
/// per-frame loop polls to know when to stop driving `tick_fling`
/// (`bench_client.rs`'s fling phase) or to decide whether the list is
/// "moving on its own" for any other purpose.
pub fn is_scrolling(&self) -> bool {
self.fling.is_some()
}
/// Cancel any fling in progress with no further movement -- the next
/// touch-down's job, per `fling`'s own doc.
pub fn cancel_fling(&mut self) {
self.fling = None;
}
/// Advance an in-flight fling to `now`, applying this call's share of
/// its total travel via `scroll` and re-arming this list's own redraw
/// handle (if it has one) for another frame. Returns whether the
/// fling is still going after this call -- `false` either because it
/// settled on its own spline-decided schedule or because it reached
/// `at_start`/`at_end` (the module doc's clamp: a fling must not carry
/// the list past content that does not exist, unlike an ordinary
/// touch-pan, which this widget already leaves unclamped by design).
///
/// Safe to call even with no fling active (a no-op returning `false`),
/// so a caller does not need to check `is_scrolling` first.
pub fn tick_fling(&mut self, now: Instant) -> bool {
let Some(f) = &mut self.fling else {
return false;
};
let elapsed = now.saturating_duration_since(f.started_at);
let target = f.calc.position_at(f.velocity, elapsed);
let delta = target - f.applied;
f.applied = target;
let settled_on_schedule = elapsed >= f.calc.duration(f.velocity);
let velocity = f.velocity;
self.scroll(delta);
// Clamp: a fling moving toward the start that has already reached
// it (or one moving toward the end that has already reached that)
// stops rather than continuing to spend its remaining distance on
// a part of the list that will never scroll further.
let hit_bound = (velocity < 0.0 && self.at_start) || (velocity > 0.0 && self.at_end);
if settled_on_schedule || hit_bound {
self.fling = None;
return false;
}
if let Some(redraw) = &self.redraw {
redraw.request_redraw();
}
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`
/// sentinel if set), bottom-aligned to the viewport. O(1).
pub fn jump_to_end(&mut self) {
@@ -572,9 +752,10 @@ impl List {
let axis = self.axis;
let output_len = painter.output_size().axis(axis);
let container_len = painter.region().axis(axis).len();
let density = painter.density();
let resolve = move |used: Size| -> f32 {
used.axis(axis)
.apply_rest()
.apply_rest(density)
.within_len(container_len)
.to_abs(output_len)
};
@@ -690,26 +871,33 @@ impl Widget for List {
};
let (mut top, mut bottom) = self.place(painter, anchor.slot, placement);
let mut idx = anchor.slot;
let mut idx_top = anchor.slot;
while top > 0.0 {
let Some(prev) = self.prev_slot(idx) else {
let Some(prev) = self.prev_slot(idx_top) else {
break;
};
let (t, _) = self.place(painter, prev, Placement::Bottom(top));
top = t;
idx = prev;
idx_top = prev;
}
idx = anchor.slot;
let mut idx_bottom = anchor.slot;
while bottom < self.viewport_len {
let Some(next) = self.next_slot(idx) else {
let Some(next) = self.next_slot(idx_bottom) else {
break;
};
let (_, b) = self.place(painter, next, Placement::Top(bottom));
bottom = b;
idx = next;
idx_bottom = next;
}
// What `tick_fling` clamps a fling against -- see `at_start`'s
// field doc. `top`/`bottom` are the extreme edges actually placed
// this frame, and `prev_slot`/`next_slot` returning `None` is what
// "no more content" means everywhere else in this widget.
self.at_start = self.prev_slot(idx_top).is_none() && top >= 0.0;
self.at_end = self.next_slot(idx_bottom).is_none() && bottom <= self.viewport_len;
self.update_snap_end();
Size::REST
}
@@ -1032,4 +1220,276 @@ mod tests {
assert!(moves <= 12, "n={n}: expected O(visible) moves, got {moves}");
}
}
/// The streamed-reply case (RUST.md's "streaming still costs a full
/// rebuild" fix, `transcript-ui::TranscriptScreen::apply`): a delta
/// swaps the last row's widget for a taller one, same key, same slot.
/// A list flush with its own end (the default, `snap_end`) must stay
/// flush -- the row grows *upward* from the pinned bottom edge, not
/// the other way around, exactly like an ordinary resize of that same
/// row would (`expanding_a_row_holds_the_bottom_edge_when_tap_is_lower`).
#[test]
fn replacing_the_last_row_stays_pinned_to_the_bottom() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = List::new(Axis::Y);
push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0);
let (list_weak, root) = add_list(&mut rsc, list);
let mut render = UiRenderState::new();
render.resize((100.0, 60.0));
render.update(&root, &mut rsc);
// Row 4 is flush with the viewport's bottom edge before the replace.
{
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
assert!((list_ref.extents[&4].bottom - 60.0).abs() < 0.01);
}
let (_weak, new_row) = fixed_row(&mut rsc, 40.0);
let old = rsc
.ui
.widgets
.get_mut(&list_weak)
.unwrap()
.replace_back(ListRow::new(4, new_row));
assert!(
old.is_some(),
"replace_back should hand back the row it evicted"
);
render.update(&root, &mut rsc);
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
let row4 = list_ref.extents[&4];
assert!(
(row4.bottom - 60.0).abs() < 0.01,
"still pinned to the newest end after the replace: {row4:?}"
);
assert!(
(row4.top - 20.0).abs() < 0.01,
"grew upward, from the pinned bottom edge: {row4:?}"
);
}
/// The other half of the same fix's contract: replacing a row that is
/// *not* on screen must not move anything that is. `replace_back` only
/// touches the last slot's own widget and this file's own `heights`/
/// `extents` caches for that one key -- nothing about `Anchor` changes
/// -- so the already-placed rows above it should come out at the exact
/// same boxes on the next frame.
#[test]
fn replacing_the_last_row_out_of_view_does_not_move_visible_rows() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = List::new(Axis::Y);
push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0);
let (list_weak, root) = add_list(&mut rsc, list);
let mut render = UiRenderState::new();
render.resize((100.0, 60.0));
// Settle at the default (bottom) anchor first -- `jump_to_start`
// does not touch `snap_end`, and `repair_anchor` only leaves a
// freshly-set anchor's offset alone once `viewport_len` has
// already matched `last_viewport_len` once, the same reason
// `moves_stay_o1_across_list_size` settles before the tick it
// actually measures.
render.update(&root, &mut rsc);
// Scrolled to the oldest content: rows 0,1,2 visible, row 4 is far
// below the viewport.
rsc.ui.widgets.get_mut(&list_weak).unwrap().jump_to_start();
render.update(&root, &mut rsc);
let (before0, before1, before2) = {
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
assert!(!list_ref.extents.contains_key(&4));
(
list_ref.extents[&0],
list_ref.extents[&1],
list_ref.extents[&2],
)
};
let (_weak, new_row) = fixed_row(&mut rsc, 999.0);
rsc.ui
.widgets
.get_mut(&list_weak)
.unwrap()
.replace_back(ListRow::new(4, new_row));
render.update(&root, &mut rsc);
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
for (key, before) in [(0u64, before0), (1, before1), (2, before2)] {
let after = list_ref.extents[&key];
assert_eq!(
(after.top, after.bottom),
(before.top, before.bottom),
"row {key} moved after an off-screen replace"
);
}
}
/// Enough rows, tall enough, that a fling toward the start has real
/// room to travel before `at_start` clamps it -- shared by the fling
/// tests below.
fn build_flingable_list(rsc: &mut TestRsc) -> (WeakWidget<List>, StrongWidget, UiRenderState) {
let mut list = List::new(Axis::Y);
push_rows(rsc, &mut list, &(0..200).collect::<Vec<_>>(), 20.0);
let (list_weak, root) = add_list(rsc, list);
let mut render = UiRenderState::new();
render.resize((100.0, 600.0));
render.update(&root, rsc);
(list_weak, root, render)
}
#[test]
fn fling_moves_the_list_and_then_settles() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
// A fling toward the start: negative velocity, matching `scroll`'s
// sign convention (`Selection::drag` calls `scroll(-dy)` for a
// downward finger motion revealing older content).
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(-8000.0);
assert!(rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
let start = Instant::now();
let mut last_still_scrolling = true;
for step in 0..600 {
let now = start + std::time::Duration::from_millis(step * 16);
last_still_scrolling = rsc.ui.widgets.get_mut(&list_weak).unwrap().tick_fling(now);
render.update(&root, &mut rsc);
if !last_still_scrolling {
break;
}
}
assert!(
!last_still_scrolling,
"fling never settled within 600 steps"
);
assert!(!rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
}
#[test]
fn fling_distance_is_positive_toward_the_end() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
// Start scrolled away from the newest end so there is room for an
// end-ward fling to actually move.
rsc.ui.widgets.get_mut(&list_weak).unwrap().jump_to_start();
render.update(&root, &mut rsc);
let before = rsc.ui.widgets.get(&list_weak).unwrap().extents[&0];
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(8000.0);
let start = Instant::now();
for step in 0..600 {
let now = start + std::time::Duration::from_millis(step * 16);
let still = rsc.ui.widgets.get_mut(&list_weak).unwrap().tick_fling(now);
render.update(&root, &mut rsc);
if !still {
break;
}
}
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
// Row 0 either scrolled out of the loaded extents (flung well past
// it) or moved upward (smaller top) -- either way, real motion
// happened toward the end rather than staying put.
if let Some(after) = list_ref.extents.get(&0) {
assert!(
after.top < before.top,
"fling toward the end did not move content up"
);
}
}
#[test]
fn cancel_fling_stops_it_with_no_further_movement() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(-8000.0);
let start = Instant::now();
rsc.ui
.widgets
.get_mut(&list_weak)
.unwrap()
.tick_fling(start + std::time::Duration::from_millis(16));
render.update(&root, &mut rsc);
assert!(rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
rsc.ui.widgets.get_mut(&list_weak).unwrap().cancel_fling();
assert!(!rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
let before = rsc.ui.widgets.get(&list_weak).unwrap().extents[&199];
// A tick after cancelling must be a no-op -- this is what a fresh
// touch-down relies on to stop a fling in its tracks.
let still = rsc
.ui
.widgets
.get_mut(&list_weak)
.unwrap()
.tick_fling(start + std::time::Duration::from_millis(200));
render.update(&root, &mut rsc);
assert!(!still);
let after = rsc.ui.widgets.get(&list_weak).unwrap().extents[&199];
assert_eq!((before.top, before.bottom), (after.top, after.bottom));
}
#[test]
fn fling_toward_the_start_stops_at_the_first_row() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
// An enormous velocity that would travel far past all 200 rows if
// unclamped -- this is exactly what IRIS_TODO.md's "way faster...
// better for stress testing" fling asks for.
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(-50_000.0);
let start = Instant::now();
for step in 0..2000 {
let now = start + std::time::Duration::from_millis(step * 16);
let still = rsc.ui.widgets.get_mut(&list_weak).unwrap().tick_fling(now);
render.update(&root, &mut rsc);
if !still {
break;
}
}
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
assert!(
list_ref.at_start,
"fling should have clamped at the first row"
);
let first = list_ref.extents[&0];
assert!(
first.top >= -0.5,
"clamped fling overshot the first row's 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"));
}
}
+4 -3
View File
@@ -17,14 +17,15 @@ impl Widget for Aligned {
// already-resolved region double-applies that composition and is
// wrong for any widget nested below the root.
let used = painter.widget(&self.inner);
let density = painter.density();
let region = match self.align.tuple() {
(Some(x), Some(y)) => used.to_uivec2().align(RegionAlign { x, y }),
(Some(x), Some(y)) => used.to_uivec2(density).align(RegionAlign { x, y }),
(Some(x), None) => {
let x = used.x.apply_rest().align(x);
let x = used.x.apply_rest(density).align(x);
UiRegion::new(x, UiSpan::FULL)
}
(None, Some(y)) => {
let y = used.y.apply_rest().align(y);
let y = used.y.apply_rest(density).align(y);
UiRegion::new(UiSpan::FULL, y)
}
(None, None) => UiRegion::FULL,
+10 -9
View File
@@ -9,12 +9,12 @@ pub struct MaxSize {
impl MaxSize {
/// Caps a reported length at `max`, comparing in pixels since `Len`'s
/// rel/abs/rest components are not otherwise comparable.
fn clamp(len: Len, max: Option<Len>, output: f32) -> Len {
fn clamp(len: Len, max: Option<Len>, output: f32, density: f32) -> Len {
let Some(max) = max else {
return len;
};
let len_px = len.apply_rest().to_abs(output);
let max_px = max.apply_rest().to_abs(output);
let len_px = len.apply_rest(density).to_abs(output);
let max_px = max.apply_rest(density).to_abs(output);
if len_px > max_px { max } else { len }
}
@@ -24,11 +24,11 @@ impl MaxSize {
/// start, if it does not. Needed so the child is never painted bigger
/// than the size this widget reports for it -- see the identical
/// requirement noted on `Sized::draw`.
fn clamp_region(offered_px: f32, max: Option<Len>, output: f32) -> UiSpan {
fn clamp_region(offered_px: f32, max: Option<Len>, output: f32, density: f32) -> UiSpan {
let Some(max) = max else {
return UiSpan::FULL;
};
let max_scalar = max.apply_rest();
let max_scalar = max.apply_rest(density);
let max_px = max_scalar.to_abs(output);
if offered_px > max_px {
max_scalar.align(AxisAlign::Neg)
@@ -41,15 +41,16 @@ impl MaxSize {
impl Widget for MaxSize {
fn draw(&mut self, painter: &mut Painter) -> Size {
let output = painter.output_size();
let density = painter.density();
let offered = painter.px_size();
let region = UiRegion {
x: Self::clamp_region(offered.x, self.x, output.x),
y: Self::clamp_region(offered.y, self.y, output.y),
x: Self::clamp_region(offered.x, self.x, output.x, density),
y: Self::clamp_region(offered.y, self.y, output.y, density),
};
let used = painter.widget_within(&self.inner, region);
Size {
x: Self::clamp(used.x, self.x, output.x),
y: Self::clamp(used.y, self.y, output.y),
x: Self::clamp(used.x, self.x, output.x, density),
y: Self::clamp(used.y, self.y, output.y, density),
}
}
}
+57 -44
View File
@@ -7,9 +7,12 @@ pub struct Pad {
impl Widget for Pad {
fn draw(&mut self, painter: &mut Painter) -> Size {
let used = painter.widget_within(&self.inner, self.padding.region());
let width = self.padding.left + self.padding.right;
let height = self.padding.top + self.padding.bottom;
let density = painter.density();
let used = painter.widget_within(&self.inner, self.padding.region(density));
let width =
self.padding.left.apply_rest(density).abs + self.padding.right.apply_rest(density).abs;
let height =
self.padding.top.apply_rest(density).abs + self.padding.bottom.apply_rest(density).abs;
Size {
x: used.x + Len::abs(width),
y: used.y + Len::abs(height),
@@ -17,23 +20,29 @@ impl Widget for Pad {
}
}
/// Each side is a `Len`, not a bare `f32`, so `.pad(dp(10))` resolves
/// against the display's density the same way any other size does -- see
/// `Len::dp`'s field doc. `.pad(10)` (a bare number) still works via
/// `From<T: UiNum>` below, unchanged: it becomes an `abs` (physical-pixel)
/// `Len`, exactly as a bare number always has meant elsewhere in this
/// crate.
pub struct Padding {
pub left: f32,
pub right: f32,
pub top: f32,
pub bottom: f32,
pub left: Len,
pub right: Len,
pub top: Len,
pub bottom: Len,
}
impl Padding {
pub const ZERO: Self = Self {
left: 0.0,
right: 0.0,
top: 0.0,
bottom: 0.0,
left: Len::ZERO,
right: Len::ZERO,
top: Len::ZERO,
bottom: Len::ZERO,
};
pub fn uniform(amt: impl UiNum) -> Self {
let amt = amt.to_f32();
pub fn uniform(amt: impl Into<Len>) -> Self {
let amt = amt.into();
Self {
left: amt,
right: amt,
@@ -41,80 +50,84 @@ impl Padding {
bottom: amt,
}
}
pub fn region(&self) -> UiRegion {
pub fn region(&self, density: f32) -> UiRegion {
let mut region = UiRegion::FULL;
region.x.start.abs += self.left;
region.y.start.abs += self.top;
region.x.end.abs -= self.right;
region.y.end.abs -= self.bottom;
region.x.start.abs += self.left.apply_rest(density).abs;
region.y.start.abs += self.top.apply_rest(density).abs;
region.x.end.abs -= self.right.apply_rest(density).abs;
region.y.end.abs -= self.bottom.apply_rest(density).abs;
region
}
pub fn x(amt: impl UiNum) -> Self {
let amt = amt.to_f32();
pub fn x(amt: impl Into<Len>) -> Self {
let amt = amt.into();
Self {
left: amt,
right: amt,
top: 0.0,
bottom: 0.0,
top: Len::ZERO,
bottom: Len::ZERO,
}
}
pub fn y(amt: impl UiNum) -> Self {
let amt = amt.to_f32();
pub fn y(amt: impl Into<Len>) -> Self {
let amt = amt.into();
Self {
left: 0.0,
right: 0.0,
left: Len::ZERO,
right: Len::ZERO,
top: amt,
bottom: amt,
}
}
pub fn top(amt: impl UiNum) -> Self {
pub fn top(amt: impl Into<Len>) -> Self {
let mut s = Self::ZERO;
s.top = amt.to_f32();
s.top = amt.into();
s
}
pub fn bottom(amt: impl UiNum) -> Self {
pub fn bottom(amt: impl Into<Len>) -> Self {
let mut s = Self::ZERO;
s.bottom = amt.to_f32();
s.bottom = amt.into();
s
}
pub fn left(amt: impl UiNum) -> Self {
pub fn left(amt: impl Into<Len>) -> Self {
let mut s = Self::ZERO;
s.left = amt.to_f32();
s.left = amt.into();
s
}
pub fn right(amt: impl UiNum) -> Self {
pub fn right(amt: impl Into<Len>) -> Self {
let mut s = Self::ZERO;
s.right = amt.to_f32();
s.right = amt.into();
s
}
pub fn with_top(mut self, amt: impl UiNum) -> Self {
self.top = amt.to_f32();
pub fn with_top(mut self, amt: impl Into<Len>) -> Self {
self.top = amt.into();
self
}
pub fn with_bottom(mut self, amt: impl UiNum) -> Self {
self.bottom = amt.to_f32();
pub fn with_bottom(mut self, amt: impl Into<Len>) -> Self {
self.bottom = amt.into();
self
}
pub fn with_left(mut self, amt: impl UiNum) -> Self {
self.left = amt.to_f32();
pub fn with_left(mut self, amt: impl Into<Len>) -> Self {
self.left = amt.into();
self
}
pub fn with_right(mut self, amt: impl UiNum) -> Self {
self.right = amt.to_f32();
pub fn with_right(mut self, amt: impl Into<Len>) -> Self {
self.right = amt.into();
self
}
}
impl<T: UiNum> From<T> for Padding {
/// Covers both a bare number (`.pad(8)`, via `Len`'s own `From<N: UiNum>`
/// blanket -- an `abs`/physical-pixel `Len`) and a `Len` directly
/// (`.pad(dp(10))`) with the one impl, since `Len: Into<Len>` is the
/// reflexive case of the same bound.
impl<T: Into<Len>> From<T> for Padding {
fn from(amt: T) -> Self {
Self::uniform(amt.to_f32())
Self::uniform(amt.into())
}
}
+1 -1
View File
@@ -43,7 +43,7 @@ impl Widget for Scroll {
self.content_len = used
.axis(axis)
.apply_rest()
.apply_rest(painter.density())
.within_len(container_len)
.to_abs(output_len);
+3 -2
View File
@@ -17,12 +17,13 @@ impl Widget for Sized {
// learn its size, then moves it into place with a pure
// translation; that translation is only valid if what got painted
// is already the reported size, anchored the same way both times.
let density = painter.density();
let mut region = UiRegion::FULL;
if let Some(x) = self.x {
region.x = x.apply_rest().align(AxisAlign::Neg);
region.x = x.apply_rest(density).align(AxisAlign::Neg);
}
if let Some(y) = self.y {
region.y = y.apply_rest().align(AxisAlign::Neg);
region.y = y.apply_rest(density).align(AxisAlign::Neg);
}
let used = painter.widget_within(&self.inner, region);
Size {
+16 -10
View File
@@ -4,12 +4,18 @@ use std::marker::PhantomData;
pub struct Span {
pub children: Vec<StrongWidget>,
pub dir: Dir,
pub gap: f32,
/// A `Len` (not a bare `f32`) so `dp(4)` resolves against the display's
/// density the same way any other size in the tree does -- see
/// `Len::dp`'s field doc. Only the `abs` component (folded from `dp` at
/// draw time, `Widget::draw` below) is meaningful here; `rel`/`rest`
/// were never supported for a gap and still are not.
pub gap: Len,
}
impl Widget for Span {
fn draw(&mut self, painter: &mut Painter) -> Size {
let axis = self.dir.axis;
let gap = self.gap.apply_rest(painter.density()).abs;
// Phase 1: draw each child once, at the ambient (unmodified, full)
// region a size-only query used to see before this migration, to
@@ -25,7 +31,7 @@ impl Widget for Span {
.map(|child| painter.widget(child).axis(axis))
.collect();
let gap_total = self.gap * self.children.len().saturating_sub(1) as f32;
let gap_total = gap * self.children.len().saturating_sub(1) as f32;
let total = lens.iter().fold(Len::abs(gap_total), |s, &l| s + l);
// Phase 2: place each child for real, using the lengths just
@@ -54,7 +60,7 @@ impl Widget for Span {
child_region.flip(axis);
}
let used = painter.widget_within(child, child_region);
start.abs += self.gap;
start.abs += gap;
let ortho = used.axis(!axis);
if ortho.rel > 0.0 || ortho.rest > 0.0 {
@@ -82,12 +88,12 @@ impl Span {
Self {
children: Vec::new(),
dir,
gap: 0.0,
gap: Len::ZERO,
}
}
pub fn gap(mut self, gap: impl UiNum) -> Self {
self.gap = gap.to_f32();
pub fn gap(mut self, gap: impl Into<Len>) -> Self {
self.gap = gap.into();
self
}
@@ -103,7 +109,7 @@ impl Span {
pub struct SpanBuilder<State, const LEN: usize, Wa: WidgetArrLike<State, LEN, Tag>, Tag> {
pub children: Wa,
pub dir: Dir,
pub gap: f32,
pub gap: Len,
_pd: PhantomData<(State, Tag)>,
}
@@ -129,13 +135,13 @@ impl<State, const LEN: usize, Wa: WidgetArrLike<State, LEN, Tag>, Tag>
Self {
children,
dir,
gap: 0.0,
gap: Len::ZERO,
_pd: PhantomData,
}
}
pub fn gap(mut self, gap: impl UiNum) -> Self {
self.gap = gap.to_f32();
pub fn gap(mut self, gap: impl Into<Len>) -> Self {
self.gap = gap.into();
self
}
}
+87 -1
View File
@@ -32,6 +32,15 @@ pub struct TextEdit {
#[cfg_attr(target_os = "android", allow(dead_code))]
history: Vec<(String, Option<Selection>)>,
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,
}
@@ -48,6 +57,7 @@ impl TextEdit {
selection: None,
history: Default::default(),
double_hit: None,
press_origin: None,
mode,
}
}
@@ -141,7 +151,8 @@ impl<'a> TextEditCtx<'a> {
fn layout(&mut self) -> &Layout<UiColor> {
let attrs = self.text.view.attrs.clone();
let width = self.text.view.wrap_width();
self.text.view.buf.shape(self.data, &attrs, width);
let density = self.data.density;
self.text.view.buf.shape(self.data, &attrs, width, density);
self.text.view.buf.layout()
}
@@ -167,6 +178,20 @@ impl<'a> TextEditCtx<'a> {
self.text.selection = None;
}
/// [`set`](Self::set) plus a fresh set of [`SpanStyle`]s in one call --
/// what a streamed transcript row needs, since its markdown re-renders
/// to a new string *and* a new span list on every delta and the two
/// have to land together (a stale span list drawn against new text can
/// point past its end). Used by `transcript-ui`'s incremental apply
/// (RUST.md's "streaming still costs a full rebuild" fix) rather than
/// tearing the row's widget down and rebuilding it from scratch.
pub fn set_with_spans(&mut self, text: &str, spans: Vec<SpanStyle>) {
let text = self.string(text);
self.text.view.buf.set_text(text);
self.text.view.buf.set_spans(spans);
self.text.selection = None;
}
pub fn motion(&mut self, motion: Motion, select: bool) {
let Some(sel) = self.text.selection else {
return;
@@ -683,6 +708,67 @@ mod tests {
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]
fn motion_moves_the_caret_and_shift_extends_a_span() {
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
//! 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.
//!
//! **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::*;
/// 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.edit(rsc).text()`) and clear it afterward
/// (`field.edit(rsc).set("")`).
pub struct Composer {
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
@@ -39,10 +83,20 @@ where
.label("Message")
.add(rsc);
let bar: WeakWidget = (field.pad(12).width(rest(1)),)
.span(Dir::RIGHT)
// One widget: an opaque bar sized to its own content (`.background`'s
// `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)))
.add(rsc);
(Composer { field }, bar)
let outer_pad: WeakWidget<Pad> = content.pad(Padding::ZERO).add(rsc);
(Composer { field, outer_pad }, outer_pad)
}
+234
View File
@@ -60,6 +60,12 @@ pub struct TranscriptScreen {
pub list: WeakWidget<List>,
pub composer: composer::Composer,
selection: Rc<RefCell<Selection>>,
/// How many times [`Self::apply`] has fallen back to a full rebuild --
/// `Cell` rather than requiring `&mut self`, matching every other
/// method here (the real state lives behind `list`/`selection`'s own
/// interior mutability, per `push_row`'s existing `&self`). Drained by
/// [`Self::take_rebuilds`].
rebuilds: std::cell::Cell<usize>,
}
impl TranscriptScreen {
@@ -75,6 +81,93 @@ impl TranscriptScreen {
(self.list)(rsc).push_back(ListRow::new(key, widget));
}
/// Apply the effect of one more folded event without rebuilding the
/// whole screen -- RUST.md's "streaming still costs a full rebuild"
/// fix. `old`/`new` are `client_core::transcript_fold::fold_event`'s
/// own before/after item lists (never grouped into rows -- that
/// happens here, over both, so the common tail cases can be told
/// apart; `group_tool_runs` is pure bookkeeping over already-folded
/// items, no widget is built doing it).
///
/// Three cases, cheapest first:
/// - **nothing changed**: no-op.
/// - **pure append** (a still-open reply's row now closed and stable,
/// a new tool call, a new message): every new row is `push_back`ed,
/// same cost as [`Self::push_row`].
/// - **only the last row's content changed** (the common case: a delta
/// folded into a still-open assistant message): that one row is
/// rebuilt (`row::build_row`, the same path a fresh row goes
/// through) and swapped in with [`List::replace_back`] -- every
/// other row is untouched, so nothing else redraws or moves. Any
/// further new rows are appended after it, for the (also common)
/// case of a delta that both finishes the open reply and starts the
/// next row in the same event.
///
/// Anything else -- a row *before* the tail changed, which only
/// happens when `group_tool_runs` regroups already-seen items (a tool
/// run's calls that used to be separate rows join once the run closes)
/// -- falls back to a full rebuild: every row is dropped
/// (`List::clear`) and rebuilt from `new`. Counted in
/// [`Self::take_rebuilds`] so a caller (a report, a test) can see how
/// often the fallback actually fires rather than assuming it never
/// does.
pub fn apply<Rsc: HasEvents>(
&self,
rsc: &mut Rsc,
old: &[client_core::transcript_fold::TranscriptItem],
new: &[client_core::transcript_fold::TranscriptItem],
) where
Rsc::State: FocusHost,
{
use client_core::transcript_fold::group_tool_runs;
let old_rows = group_tool_runs(old);
let new_rows = group_tool_runs(new);
match diff_rows(&old_rows, &new_rows) {
RowDiff::Unchanged => {}
RowDiff::Appended { common } => {
// Pure append: every already-drawn row is byte-for-byte the
// same `FoldedRow` it was last time.
for row in &new_rows[common..] {
self.push_row(rsc, row);
}
}
RowDiff::ReplaceLast { common } => {
// Only the tail row's content changed -- rebuild that one
// row and swap it in place, keeping every row before it
// untouched.
let old_key = row::row_key(&old_rows[common].key());
let (new_key, widget) =
row::build_row(rsc, self.list, self.selection.clone(), &new_rows[common]);
if new_key != old_key {
self.selection.borrow_mut().unregister(old_key);
}
let evicted = (self.list)(rsc).replace_back(ListRow::new(new_key, widget));
drop(evicted); // frees the old row's widget, same as a pop would
for row in &new_rows[common + 1..] {
self.push_row(rsc, row);
}
}
RowDiff::Rebuild => {
// A row before the tail changed (a regroup) -- nothing
// short of a full rebuild expresses that.
self.rebuilds.set(self.rebuilds.get() + 1);
(self.list)(rsc).clear();
for row in &new_rows {
self.push_row(rsc, row);
}
}
}
}
/// How many times [`Self::apply`] has fallen back to a full rebuild
/// since the last call, reset to 0 by reading it -- the same
/// take-and-reset shape `AccessTree::take_rebuilds` already uses (I4).
pub fn take_rebuilds(&self) -> usize {
self.rebuilds.replace(0)
}
/// The concatenated text of whatever is currently selected across one
/// or more rows, `None` if nothing is -- what a copy command reads.
pub fn selected_text(&self, rsc: &mut impl UiRsc) -> Option<String> {
@@ -139,7 +232,148 @@ where
list,
composer,
selection,
rebuilds: std::cell::Cell::new(0),
},
tree,
)
}
/// What changed at the tail between two folded row lists -- the decision
/// [`TranscriptScreen::apply`] acts on. Kept as its own pure function, no
/// widget and no `Rsc`, so the three cases can be tested directly against
/// synthetic `Vec<FoldedRow>`s (below) rather than needing a full widget
/// harness to exercise logic that never touches one.
#[derive(Debug, PartialEq, Eq)]
enum RowDiff {
/// `old` and `new` are the same length and every row is identical.
Unchanged,
/// Rows `[common..]` of `new` are new; everything before `common` is
/// byte-for-byte the same `FoldedRow` `old` already had.
Appended { common: usize },
/// Row `common` is the only one whose content differs; anything past
/// it in `new` is a pure append after the replacement.
ReplaceLast { common: usize },
/// A row *before* the tail differs -- only `group_tool_runs` regrouping
/// an earlier run does this, and nothing short of a full rebuild
/// expresses it.
Rebuild,
}
fn diff_rows(old: &[FoldedRow], new: &[FoldedRow]) -> RowDiff {
let common = old
.iter()
.zip(new.iter())
.take_while(|(a, b)| a == b)
.count();
if common == old.len() && common == new.len() {
RowDiff::Unchanged
} else if common == old.len() {
RowDiff::Appended { common }
} else if !old.is_empty() && common == old.len() - 1 && common < new.len() {
// The `common < new.len()` guard is what tells "the tail row's
// content changed" apart from "the tail row was removed and
// nothing replaced it" (a shrinking list) -- the latter has
// nothing at `new[common]` to rebuild into place.
RowDiff::ReplaceLast { common }
} else {
RowDiff::Rebuild
}
}
#[cfg(test)]
mod diff_tests {
use super::*;
use client_core::transcript_fold::TranscriptItem;
fn user(seq: u64, text: &str) -> FoldedRow {
FoldedRow::Single(TranscriptItem::UserMsg {
seq,
text: text.to_string(),
attachments: Vec::new(),
})
}
fn assistant(seq: u64, text: &str, settled: bool) -> FoldedRow {
FoldedRow::Single(TranscriptItem::AssistantMsg {
seq,
text: text.to_string(),
settled,
})
}
fn tool(seq: u64, run_id: &str) -> TranscriptItem {
TranscriptItem::ToolRun {
seq,
id: format!("id{seq}"),
run_id: run_id.to_string(),
tool: "grep".to_string(),
input: "x".to_string(),
output: String::new(),
done: false,
asks: Vec::new(),
images: Vec::new(),
}
}
#[test]
fn identical_lists_are_unchanged() {
let rows = vec![user(1, "hi"), assistant(2, "hello", true)];
assert_eq!(diff_rows(&rows, &rows.clone()), RowDiff::Unchanged);
}
#[test]
fn an_empty_list_growing_by_one_is_an_append_from_zero() {
let old: Vec<FoldedRow> = Vec::new();
let new = vec![user(1, "hi")];
assert_eq!(diff_rows(&old, &new), RowDiff::Appended { common: 0 });
}
#[test]
fn a_new_message_after_a_settled_reply_is_a_pure_append() {
// The row that used to be the tail (a now-closed assistant
// message) is unchanged; a new user message is appended after it
// -- the transition every reply's *last* delta makes once the
// next turn starts.
let old = vec![user(1, "hi"), assistant(2, "hello", true)];
let new = vec![user(1, "hi"), assistant(2, "hello", true), user(3, "and?")];
assert_eq!(diff_rows(&old, &new), RowDiff::Appended { common: 2 });
}
#[test]
fn a_delta_into_the_open_reply_is_a_last_row_replace() {
// The common streaming case: the assistant message's key (its
// first delta's seq) never changes, only its text grows.
let old = vec![user(1, "hi"), assistant(2, "hel", false)];
let new = vec![user(1, "hi"), assistant(2, "hello", false)];
assert_eq!(diff_rows(&old, &new), RowDiff::ReplaceLast { common: 1 });
}
#[test]
fn a_delta_that_both_settles_the_reply_and_starts_the_next_row_is_still_a_replace() {
// `ReplaceLast` only claims the row it names; `apply` appends
// whatever comes after it separately -- this just confirms the
// diff still recognises the replace even with a trailing append.
let old = vec![user(1, "hi"), assistant(2, "hel", false)];
let new = vec![user(1, "hi"), assistant(2, "hello", true), user(3, "and?")];
assert_eq!(diff_rows(&old, &new), RowDiff::ReplaceLast { common: 1 });
}
#[test]
fn a_tool_run_closing_and_joining_an_earlier_call_is_a_regroup_fallback() {
// Two separate `Single` rows for the same run id become one
// `Tools` row once `group_tool_runs` sees them adjacent -- that
// changes row 0, not just the tail, so nothing short of a full
// rebuild expresses it.
let old = vec![FoldedRow::Single(tool(1, "run-a")), user(2, "meanwhile")];
let new = vec![FoldedRow::Tools(vec![tool(1, "run-a"), tool(3, "run-a")])];
assert_eq!(diff_rows(&old, &new), RowDiff::Rebuild);
}
#[test]
fn shrinking_the_list_is_a_rebuild() {
let old = vec![user(1, "hi"), assistant(2, "hello", true)];
let new = vec![user(1, "hi")];
assert_eq!(diff_rows(&old, &new), RowDiff::Rebuild);
}
}
+11 -2
View File
@@ -60,6 +60,15 @@ fn item_content(item: &TranscriptItem) -> (Option<&str>, String) {
TranscriptItem::PeerNote { from, text, .. } => (Some(from.as_str()), text.clone()),
TranscriptItem::Note { text, .. } => (None, text.clone()),
TranscriptItem::ClearedNote { .. } => (None, "_Context cleared._".to_string()),
// Epoch seconds as-is until the port has a relative-time formatter
// (P1); the Compose `LimitRow` draws it as a countdown.
TranscriptItem::LimitNote { resets_at, .. } => (
None,
match resets_at {
Some(at) => format!("_Usage limit reached; resets at {at:.0} (epoch seconds)._"),
None => "_Usage limit reached._".to_string(),
},
),
TranscriptItem::CompactedNote {
pre_tokens,
post_tokens,
@@ -165,8 +174,8 @@ where
(header, field.width(rest(1)))
.span(Dir::DOWN)
.gap(4)
.pad(10)
.gap(dp(4))
.pad(dp(10))
.add_strong(rsc)
.any()
}
+26 -1
View File
@@ -41,6 +41,12 @@ pub struct Selection {
/// pan wanting the same touch gesture). See `drag` below, and
/// `iris::sense::DragArbiter`'s own doc for the decision itself.
arbiter: DragArbiter,
/// Tracks the last ~100ms of this gesture's pan deltas (in the same
/// signed units `list.scroll` takes), so a release that turns out to
/// have been panning can hand `List::fling` a realistic initial
/// velocity instead of one frame's noisy last delta --
/// IRIS_TODO.md's "swiping has no momentum."
velocity: VelocityTracker,
}
impl Default for Selection {
@@ -55,6 +61,7 @@ impl Selection {
rows: BTreeMap::new(),
anchor: None,
arbiter: DragArbiter::new(),
velocity: VelocityTracker::new(),
}
}
@@ -178,9 +185,21 @@ impl Selection {
CursorSense::PressStart(_) => {
let already_selected = self.has_selection(ui);
self.arbiter.press_start(pos_window, now, already_selected);
self.velocity.reset();
// A fresh touch-down cancels any fling still coasting from
// the previous gesture -- `List::fling`'s own doc, and
// Android's `Scroller::abortAnimation` for the same reason.
list(ui).cancel_fling();
self.arbiter.update(pos_window, now)
}
CursorSense::PressEnd(_) => {
// A fling only ever follows a pan -- never a selection
// that happened to end with the finger still moving, and
// never a tap/long-press that never left `Undecided`.
if self.arbiter.is_panning() {
let v = self.velocity.velocity();
list(ui).fling(v);
}
self.arbiter.release();
return;
}
@@ -198,13 +217,19 @@ impl Selection {
_ if self.arbiter.is_idle() => {
let already_selected = self.has_selection(ui);
self.arbiter.press_start(pos_window, now, already_selected);
self.velocity.reset();
list(ui).cancel_fling();
self.arbiter.update(pos_window, now)
}
_ => self.arbiter.update(pos_window, now),
};
match outcome {
DragOutcome::Undecided => {}
DragOutcome::Pan(dy) => list(ui).scroll(-dy),
DragOutcome::Pan(dy) => {
let amt = -dy;
self.velocity.add_sample(amt, now);
list(ui).scroll(amt);
}
DragOutcome::SelectStart => {
// Grep-able on "iris selection" the way the frame report is
// on "iris frame report" -- selection has no accessibility