A row was one TextEdit holding the whole message, so every delta
re-shaped every paragraph of a long reply through parley -- the one phase
where iris trails Compose on the phone (p50 18.2ms vs 13.4ms, bench v2).
- client-core/src/markdown_blocks.rs: split a message into its top-level
blocks with their source, through the same pulldown-cmark the renderer
parses with so the two cannot disagree about where a block starts, plus
common_prefix. Appending markdown can rewrite an earlier block (a
trailing --- turns the paragraph above into a heading), so the fast
path compares the prefix it keeps rather than assuming it -- with the
test that says so.
- transcript-ui: a row is a Span of one TextEdit per block;
RowBlocks::apply_delta replaces the block a delta lands in;
TranscriptScreen keeps the tail row's blocks, seeded in build_tree as
well as push_row (a screen opened onto a streaming reply took the
rebuild path for its first delta otherwise, with nothing to say so).
- A block is the selection unit: Selection is keyed by (RowKey, u32),
which is reading order at both levels, and the pointer-captured half of
a drag resolves the block under the finger from its drawn box
(Selection::locate) instead of from the row's extent.
Pass condition: a_delta_into_a_long_reply_redraws_the_same_widgets_as_a_short_one
drives a real UiRenderState and asserts the draw count for a delta into a
100-paragraph (3,000+ char) reply equals the count for a one-paragraph
one. 30 either way; it read 630 against 30 twice on the way there.
Emulator stream phase, same AVD before and after: p50 61.5 -> 54.5ms,
p90 211.7 -> 113.1ms, p99 342.6 -> 137.4ms, worst 403.6 -> 143.0ms, 202
-> 293 frames in the same 21 seconds. Selection across blocks verified
with a real long-press drag.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Wrapping the composer's field in .scrollable().masked() needed three
layout defects fixed first, each with a headless regression test that was
confirmed to fail without its fix:
- MaxSize/Sized reported a caller's declared dp length unresolved, and
Span places a child from the abs/rel of what it reported, so dp(168)
was worth zero: the bar got a slot of nothing the moment its content
passed six lines and the Scroll inside measured its container at -63px
(container=-63 content=415.8 amt=478.8 on the emulator). Len::fold_dp,
used on the way out, plus a debug_assert in draw_inner that a reported
Size carries no dp -- the rule is about every widget, not those two.
- Masked allocated a fresh mask slot per draw, and draw_inner's
unchanged-region fast path does not revisit descendants, so they kept
clipping against a box the bar had moved away from: four live mask
entries, none of them current, and the field drew nothing.
ActiveData::own_mask, allocated once and rewritten in place.
- mov updates active.region and accumulates the same delta on the move
slot, and resolved_region added both, so a panned widget's own hit box
sat at twice the pan -- the composer's field was untappable after a
drag. ActiveData::move_applied.
Scroll itself measured the right number by a misleading route; it is
written against painter.px_size() now and still reports its content's
size, since reporting the container makes the answer a function of
itself.
Verified on this checkout's emulator: swipe 540 1200 -> 540 1460 moved
the field's Message box 31,1041..1048,1509 -> 31,1131..1048,1651 with its
height unchanged at 468px.
run-bench.sh polled logcat for a prefix copy_report also logs at startup,
so it printed a report that had never been run.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
IRIS_TODO.md's "the composer has no touch-drag scroll". `Scroll::drag`
takes its pan from the same `sense::DragGesture` `List` is driven by --
arbitration, DRAG_SLOP, velocity and pointer capture all stay in sense.rs
and only what a committed pan *means* is decided per caller -- and
`WidgetLike::scrollable()` registers it beside the wheel handler it already
registered, so every scroll area pans on a finger with nothing added at the
call site. No fling: `Scroll` has no per-frame tick to animate one and the
areas it wraps are at most a screenful. `Scroll::amt()` exposes the pan
position.
`attr.rs`'s `on_press` treated an already-focused field as the plain
click_or_drag case, so every Pressing frame extended a selection. It now
applies the same DRAG_SLOP rule its unfocused branch already did: a press
past the slop vertically abandons its pending selection for the rest of the
gesture, so the scroll area around the field wins it. That is Android
EditText's own behaviour and it is what lets a swipe up over the composer
scroll instead of dragging a highlight through what you typed.
Also fixed, found doing it: `ActiveData::mask` stored the mask a widget
*set* rather than the one it was drawn *under*, and `redraw` feeds that
field back in as the inherited mask -- so a targeted redraw of any `Masked`
handed it its own mask and aborted on `set_mask`'s nested-mask assert. A
real abort on the emulator, `assertion failed: self.mask == MaskIdx::NONE`.
And the per-frame orphan guard from 76b1f99 is now a count comparison
(O(active widgets)); the O(primitives) walk only runs to build the failure
message, because running it per frame made a debug build on the emulator too
slow to finish a bench run at all.
Tests: four in scroll.rs (pan past the slop, a tap inside it, a horizontal
drag, the end clamp), `a_finger_drag_over_a_scroll_area_pans_it` in
sense_tests.rs driving the whole registration/dispatch/capture path (fails
with "got 0" without the new registration), and
`redrawing_a_masked_widget_does_not_nest_its_own_mask` in layout_tests.rs
(aborts on the pre-fix code).
The composer itself is deliberately still not `.scrollable()`: `Scroll`
measures against the window rather than its own offered box, so inside the
`MaxSize` capping it at six lines it pans the field out of the bar --
measured, reverted and written down in RUST.md and DECISIONS.md.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`draw_inner` read `needs_redraw` without consuming it, and used it to skip
the whole `if let Some(active)` block -- including the `remove(id, false)`
that frees a redrawn widget's previous primitives. So a widget that was
both already active and marked dirty, and was reached by an *ancestor's*
draw rather than by `redraw_updates` picking it first, drew a second full
set of primitives and then had `active.insert` overwrite the only handles
that could ever have freed the first set. Those primitives stay in the
layer's instance buffer for the life of the process, with a leaked move
slot and leaked mask refs, drawn every frame at whatever region they last
had -- and `List` sets no mask, so a row measured at `GENEROUS_PADDING`
leaves its ghost outside the list's own box.
That is the doubled `Compacted:` row in docs/bench/iris-phone-v2-2026-09-06.md:
overlapping copies inside the transcript and one more below the composer.
Fixed by consuming the mark (`needs_redraw.remove`) at the top of
`draw_inner` -- this call *is* the redraw it asked for -- and freeing the
old primitives on the dirty path too.
Guarded so it cannot come back silently: `UiRenderState::orphaned_primitives`
walks every layer's live instances and names any whose owner is no longer
active or no longer holds a handle to them, and `update` `debug_assert!`s it
empty every frame (debug builds only). New regression test
`an_ancestor_redrawing_a_dirty_row_leaves_no_stale_copy` in list.rs fails on
the pre-fix code with "1 primitive(s) survived their own widget's redraw".
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
TextEditCtx::select compared the tap against the laid-out text's own box
and cleared the selection for anything outside it. An empty field lays
out to a zero-width box, so tapping the composer granted focus and opened
the keyboard with no caret, and insert_str returns early without one --
every keystroke went nowhere and no glyph was ever emitted. Parley clamps
a point outside the layout by itself, and a press reaching select() has
already been hit-tested to the widget, so there was nothing for the
'outside' branch to mean.
insert_str now debug_asserts rather than dropping input silently, and
UiRenderState::draw_started -- a re-entrancy guard whose test was written
after its own remove(), so it could never fire, and which grew by one
entry per widget ever drawn -- is restored to what it was meant to be:
inserted around Widget::draw, removed when it returns, asserted empty at
the top of every update.
The empty benchmark-report TextEdit held .height(rest(1)) beside
content.height(rest(2)), so it reserved a third of the window at every
launch and pushed the composer two thirds down -- Iris's 11:39 phone
report. It is sized to its content now, capped and scrollable, and sits
above the transcript rather than under the composer.
New log::info! lines for one insets change, one surface_changed, one
renderer build and one surface_destroyed, each with the glyph/atlas
counts, so a phone's adb logcat can answer the app-switch text loss the
emulator cannot reproduce.
Finding 1 (the real crash): Selection::clear() drops rows and anchor,
called from TranscriptScreen::apply's Rebuild arm right before
List::clear() -- push_row re-registers survivors as it rebuilds each row.
Fixes a WeakWidget outliving the row group_tool_runs regrouped away,
which panicked the next long-press anywhere. New apply_tests test builds
a real TranscriptScreen, forces the regroup, and confirms no panic.
Findings 2-5: debug_assert!s on List::place's slot, List::fling and
FlingCalculator's velocity finiteness, VelocityTracker::add_sample's
chronological order, and FrameReport::mark_phase's non-decreasing
start_index. Finding 7: bench_client.rs's battery_line guard restructured
so the empty check can't be separated from its unwraps by a future edit.
Findings 9/10: new List tests pinning tick_fling's per-tick deceleration
and replace_back's evicted-key cleanup with a different key than the
existing tests use. IRIS.md's replace_back/clear/apply entry gained the
side-table-clearing note the Docs finding asked for.
Also records this pass's DragGesture-merge verification in RUST.md (tap
stays vs swipe doesn't, a real fling keeps moving after release, keyboard
cycles confirmed via on_insets_changed) and annotates the two IRIS_TODO.md
phone-report items it targets.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review of 73251d6's port of TranscriptSource/joinPages.
`TranscriptSource::page` answered `before == 0` with an empty `Vec`, which
is the same value it answers "this conversation has no more history" with.
That is the state the Kotlin keeps apart: `loadOlderPage` returns false at
`oldestSeq == 0` *without* touching `moreHistory`, and returns false on an
empty page *by latching it*. Collapsing the two moved AGENTS.md's paging
bug one layer down rather than fixing it. `page` returns `OlderPage` now --
`Events(vec![])` is the start of the conversation, `NothingLoaded` is not
an answer about the conversation at all.
`join_pages`' `debug_assert!` on seq ordering across the boundary is not a
true invariant: a peer note carries the seq its turn began at, which can be
older than the page it arrived in, so an ordinary transcript would have
panicked a debug build there. Replaced with the one the function exists to
enforce -- no tool id surviving in both halves.
`fetch_transcript_lines` stores `RawValue`'s exact server bytes, so the
"neither source can produce a newline" comment in `SessionCache::append`
now rests on the server's serializer staying compact rather than on a
local normalization. Checked with a `debug_assert!` in `append` and
`store_page` rather than trusted.
Tests for the failure half, which the port had none of: a 500 mid-page, a
cached line this build cannot read, and the `after` bound in the case that
actually carries one (the existing test asserted only the case with no
bound). `cargo fmt`, `cargo clippy --all-targets`, `cargo test` (112) clean
in client-core; `cargo check -p desktop-app` clean.
Closes docs/RUST.md's "client-core prerequisites for P1" box: the
cache-vs-server stitching TranscriptSource.kt does, and the
joinPages/healSplitMessage/adoptRun page-boundary healing
TranscriptItems.kt does, both ported into client-core with no UI
framework dependency.
Neither Kotlin file had a JVM unit test of its own, so the port used the
Kotlin source and AGENTS.md's "things that have bitten" paging incidents
as the spec instead of a test-for-test transcription. Both regressions
get a dedicated test: TranscriptSource::page refuses before == 0 before
touching the cache or the network (loadOlderPage's incident), and
adopt_run now runs on every page join rather than only the one where a
split call was found (the "one run drawn as two" incident).
fetch_transcript_lines (api.rs, additive) pairs each transcript line with
the exact server bytes via serde_json::value::RawValue rather than
re-serializing a parsed Value, so a cached line and a live SSE frame for
the same event agree byte-for-byte -- the fetch_transcript_page other
callers under iris/ depend on is untouched.
client-core: 85 -> 109 tests. cargo test/clippy --all-targets/fmt clean.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Generalizes drag arbitration into a default-input DragGesture with
pointer capture and CursorSense::Drop, and opts MainActivity into
edge-to-edge so IME insets are redelivered. See e12c708.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Iris asked (2026-09-06) that dragging be part of iris's default input
system rather than duplicated per app: "anything that provides good
performance and can be generalized well is part of iris rather than the
app." DragArbiter and VelocityTracker (both already in iris::sense) are
now bundled into a new DragGesture, which also takes exclusive pointer
capture (UiRenderState::capture_pointer/release_pointer/captured_pointer)
the moment a gesture commits to panning or selecting, and delivers a new
CursorSense::Drop -- not PressEnd -- to the captured widget when the
button lifts, wherever on screen that happens to be.
This directly targets the phone bench's "finger flings do nothing":
per-widget hit testing silently drops a gesture the instant the pointer
moves off every registered region, which a fast pan/fling does routinely
(crossing several virtualised rows, or ending off the loaded content
entirely) -- so PressEnd, and the velocity/fling-start decision hanging
off it, was frequently never delivered at all. Capture targets List's own
stable id (List::key_at resolves the row-under-pointer from its
extents), not a row's, since List retires rows mid-drag as content
scrolls.
transcript-ui::Selection::drag now only decides pan-vs-select from
DragGesture's outcome; row.rs's per-row registration is only ever a
gesture's first frame, with lib.rs registering the List-level
continuation once. New tests: sense_tests.rs's two pointer-capture
regressions, list.rs's replacing_the_last_row_many_times_does_not_leak_primitives
(a P0 stale-primitives diagnostic -- passes, pinning the widget-arena
layer as not the leak). MainActivity.java opts into edge-to-edge
(Window::setDecorFitsSystemWindows(false), API 30+, no new dependency)
so window insets are redelivered on every change including a pure IME
toggle -- the named-but-untried fix for the phone bench's "keyboard:
could not be shown" and the emulator's identical non-confirmation.
cargo fmt/clippy/test clean across the iris workspace.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>