RUST.md's "Shell lost" section and IRIS_TODO.md's matching paragraph both
said item 4's fix was written but never built or tested. It was committed
in ba2afba with its test passing, so both were stale the moment that
landed and read as if nothing had been run at all.
Replaced with one section per item, saying what was fixed, what was
measured on this checkout's emulator and what the phone still has to
settle: items 2 and 3 ticked with their numbers, item 4 ticked on the code
with phone confirmation still owed (no Vulkan adapter here), item 1 left
open with the exact logcat line for Iris to look at. The two pre-existing
faults found on the way -- the 16-deep move chain and the API-29 JNI calls
-- are recorded where the next reader will hit them.
IRIS.md gains the public-surface entry: `Widget::tick`,
`UiData::animate`/`tick_animations`, `FlingCalculator`'s density and
coefficient, and `MOVE_CHAIN_LIMIT`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
56 KiB
iris: known problems and things still to build
Iris's own list for the library, recorded 2026-09-04 in her words where it matters, so the agents working through RUST.md pick these up in a sensible order rather than rediscovering them. Each item says where it sits in the order and what "done" looks like. Tick and date them in place.
Fix
-
request_deviceasked for compute-shader limits it never uses (2026-09-05).Limits::default()(bothiris/src/android/render.rsandiris/src/default/render.rs) requests desktop-tier compute limits unconditionally, even though nothing iniris/iris-corecreates aComputePipelineor writes a@computeshader stage — confirmed by grepping the whole tree, not assumed. That crashed device creation outright on the Android emulator's software GL path (EMU_GPU=software,--features force-gles): SwiftShader's GL reports itself as OpenGL ES 3.0, which has no compute shaders, so the adapter's real limit is 0 against the unconditional request for 65535 — the same would happen on any real GLES-3.0-only Android device. Fixed by a new, sharediris_core::device_limits()(iris/core/src/render/mod.rs) that zeros exactly the sixmax_compute_*fields rather than switching to a downlevelLimitspreset —downlevel_webgl2_defaults()also zerosmax_storage_buffers_per_shader_stage, whichshader.wgsl's vertex stage needs (fourvar<storage>buffers), so that preset would trade this crash for a bind-group-layout one on the same hardware.rigs/gpu-probe's own hand-mirroredLimits(it is deliberately its own crate, not able to calldevice_limits()directly) was updated to match. SeeDECISIONS.mdand RUST.md's I5 box for the account, including what could not be re-verified on-device this pass (the emulator was in concurrent use by another session). -
Input does not fall through by input type (2026-09-04).
SensorUi::run_sensors(src/default/sense.rs) used to set "consumed, stop checking lower layers" from mere hover — a widget registered for nothing butclick()blocked aScrollmeant for whatever was behind it, since "the cursor is over this widget" and "this widget handled the event" were the same check. Fixed by judging consumption per input kind: with no button transition and no scroll happening this frame ("momentary" activity), the topmost hovered widget still wins, same as before; when something momentary is happening, only a widget whose registered senses actually include a matching non-hover one (checked via a newTypeEventManager::registered, which lists what a widget registered without running anything) consumes it, so a widget with onlyHovering/click handlers can no longer block a scroll from reaching a list underneath.iris/src/sense_tests.rsbuilds a button-over-a-listStackwith a plainHasEventsimpl (no GPU or window) and checks both directions: a scroll over the button reaches the list, and a real click still reaches the button — confirmed to fail on the pre-fix code and pass after. -
Appending one image to an already-loaded list rebuilds every other image's bind group (2026-09-05, fixed 2026-09-05). Found by the benchmark below:
GpuTextures::update(core/src/render/texture.rs) triggeredrebuild_image_bind_groups— a loop over every live standalone image, rebuilding itsBindGroup— whenever the sharedmasksormove_offsetsGPU buffer was resized (masks_resized || moves_resizedinUiRenderNode::update,core/src/render/mod.rs), and a widget getting its first move-offset slot (LAYOUT.md section 2 — every widget gets one on first draw) could be exactly what grows that buffer. So one new message with one new image, appended to a transcript that already has N images loaded, did not cost O(1): it cost onecreate_imagefor the new image plus onemake_image_bind_groupper existing image, because the new widget's own move slot pushed the arena past its capacity. Measured directly iniris/examples/bench_images.rs: appending a 1,001st image to 1,000 already-settled ones reported 1,001 bind-group creates for that one frame, not 1 (./run-bench.sh images, frame 5 in the transcript below).Fix:
masks/move_offsetsnever belonged in a standalone image's own bind group (group 2) in the first place — the group also holds that image's own texture view, which is the only thing that is genuinely per-image, so a buffer shared by everything forced a rebuild of every group the moment it moved. Gave masks/move_offsets their own bind group (group 3 inshader.wgslandUiRenderNode:masks_layout/masks_group), bound once per frame inUiRenderNode::drawrather than once per draw call, instead of duplicating them into every per-image group.GpuTexturesand its image bind groups now know nothing about either buffer —rebuild_image_bind_groupsis called only fromgrow_array(the atlas array texture growing, which genuinely does change what every image's own bind group must reference) — so a masks/move_offsets resize now touches exactly one bind group, ever, regardless of how many images are live. Numbers after the fix, same benchmark and command:./run-bench.sh images frame=1 bind_group_creates=1000 (cold load, unchanged) frame=2 bind_group_creates=0 (was 1000 -- see the item below) frame=3 bind_group_creates=0 frame=4 bind_group_creates=0 (append one image here) frame=5 bind_group_creates=1 (was 1001) frame=6 bind_group_creates=0run-headless.sh tabs --shotstill 27266 bytes, byte-for-byte unchanged, confirming the bind-group restructuring changed nothing about what is drawn. -
Bind-group creation takes two frames to reach the steady state, not one (2026-09-05, closed by the fix above, 2026-09-05). Same benchmark: loading 1,000 images cold used to report 1,000 creates on frame 1 (expected —
create_image, one per new image) and again 1,000 on frame 2, before settling to 0 from frame 3. This wasrebuild_image_bind_groupsfiring a second time for the same masks/move-offsets buffer-growth reason as the item above, confirming the guess recorded here — the two were exactly the same root cause measured two different ways. Frame 2 now reports 0 (see the numbers above); not a separate fix. -
A read-only text display has no widget of its own — P0's bench report area is a
TextEditstanding in for one (2026-09-05). The only way to get selectable text on screen today is.editable(...)plus.attr::<Selectable>(())(Selectableis only implemented forTextEdit,iris/src/attr.rs), which also makes the field focusable — tapping the bench report opens the soft keyboard over text nothing lets you type into. Harmless for a bench-only debug screen (not fixed this pass), but a real "selectable, not editable" text primitive would remove the keyboard side effect and is worth having before another screen wants the same thing (P1's own transcript rows already read their content from aTextEditfor 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.
-
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 throughSelection::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. -
Scrolling down sometimes jitters the text, fixed 2026-09-06. Root-caused by reading
DragArbiter::update'sUndecided-to-Panningtransition rather than by an on-device trace (no emulator was used this pass): it was the first named suspect, not the second.self.laststays at the press origin for everyUndecidedframe (nothing pans while the gesture might still be a selection), so the frame that finally crossesDRAG_SLOPreturnedPan(dy)withdymeasured frompress_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 pastDRAG_SLOPon 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. -
Composing text held back until a space, caret not moving, fixed 2026-09-06.
InputMethodManager.updateSelectionwas 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. -
Swipe over the composer summons the keyboard, fixed 2026-09-06.
Selector/Selectablenow wait for a completed tap -- see IRIS.md's 2026-09-06 entry and RUST.md's P0 box, item 5. Verified viadumpsys input_method'smInputShownon the emulator, not yet on the phone. -
Text disappears again after leaving and returning to the app, fixed 2026-09-06.
GlyphAtlas::clear/Textures::reseton 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 -- root-caused and fixed 2026-09-06. Not the renderer at all: the composer's buffer was empty the whole time.
TextEditCtx::select(iris/src/widget/ text/edit.rs) compared the tap against the laid-out text's box and setselection = Nonefor anything outside it -- and an empty field's layout is a zero-width box, so tapping an empty composer granted focus and opened the keyboard while leaving no caret;insert_strreturns early with no caret, so every keystroke after that was dropped in silence. Gboard's suggestion strip is its own composing state, not a read of our buffer, which is what made the earlier pass conclude the buffer held the text. Fixed by letting parley clamp a tap outside the layout to the nearest cursor position (a press that reachesselecthas already been hit-tested to the widget, so there is no "outside"), plus adebug_assert!ininsert_strso an insert with no caret fails at the mistake instead of dropping input -- it immediately caughtlayout_tests::composing_text_after_a_keyboard_resize_...typing into an unfocused field. Three new tests inedit.rs(tapping_an_empty_field_places_a_caret_so_typing_lands,tapping_past_the_end_of_the_text_clamps_to_the_end,dragging_without_a_previous_selection_selects_nothing); the first fails on the pre-fix code. Emulator evidence:adb shell input textaftertap 'Message'now shows the text in the bar (/tmp/final-typing.png) and logsiris text render: chars=5 ... glyphs=5, againstglyphs=0on every keystroke before.The old, superseded diagnosis, kept because it was wrong in an instructive way: 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 forcedforce-glesbackend (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 (oneStack-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. Done 2026-09-06.
field.scrollable().masked()intranscript-ui/src/composer.rs: a finger drag inside the bar pans the message, the bar stays capped at six lines, and a vertical drag in the focused field no longer extends a selection (AndroidEditText's own behaviour). Verified on this checkout's emulator with thetranscript-screen bench force-glesdebug build -- six repetitions of a 13-word sentence typed in, thenui-trace record --do "swipe 540 1200 540 1460 300": the field'sMessagebox moved31,1041..1048,1509->31,1131..1048,1651(the content panned down with the finger) with its height unchanged at 468px (the bar did not grow), and the two screenshots either side show different text in the same band. Three real defects had to be fixed first, each with a headless regression test iniris/src/layout_tests.rsand each confirmed to fail without its fix (docs/RUST.md's plan box has the measurements): aMaxSizereporting its cap as an unresolveddp(Len::fold_dp), aMaskedallocating a fresh mask slot per draw (ActiveData::own_mask), and a panned widget's own hit box moving twice (move_applied).Scrollitself turned out to measure the right number by a misleading route -- it is written againstpainter.px_size()now, and the claim below that it "measures against the window" was wrong. The grey background was not missing -- that note (written here on 2026-09-06 and repeated as still open) is withdrawn. Re-measured the same day on the same AVD by decoding the screencap rather than reading it: the bar isrgb(41,40,49), the declaredUiColor::new(40, 40, 46)after sRGB rounding, full width and y2245..y2365 on 1080x2424, with the field at31,2277..1048,2329and the 63px nav strip below it. It is dark by design and sits on black, which is very likely what the earlier reading was: at a glance the band and the background are hard to tell apart. If it should read as a bar rather than as a slightly different black, the colour is the thing to change, not the tree.
From the phone, 2026-09-06, 11:39 (build delivered 02:07, commit 543f6d9)
Iris's report on the build with the composing-text, tap-vs-swipe and atlas-reset fixes, with a screenshot, verbatim. Each is open until an agent ticks it here with the evidence.
- "The app definitely does not start with keyboard spacing
correct. This is how it looks without me doing anything initially."
Not an inset bug at all -- fixed 2026-09-06. The black third is the
bench shell's own empty benchmark report pane:
bench_client.rs's root tree gave it.height(rest(1))besidecontent.height(rest(2)), so an emptyTextEditreserved a third of the window at every launch and pushed the composer up by exactly that. Measured on this checkout's emulator at the phone's own size (1080x2424, density 420, gesture nav), which reproduced Iris's screenshot exactly: newiris insets:log line reportedbottom=63 ime_bottom=0at launch (a nav bar, no keyboard -- so the inset the composer was fed was never large), whileui-trace show -m Message --field boxput the field at31,1488..1048,1540on a 2282px-tall surface, 789px clear of the bottom -- that pane's third. Unit mixing checked explicitly and cleared:set_bottom_insettakes physical px and storesLen::abs,MainActivity.java's1/0ime_bottomonly ever reachesinsets.bottom.max(ime_bottom)and> 0.0, and everydpin the composer resolves at layout time. Fix: the report pane is sized to its content (.max_height(dp(260)) .scrollable()), and moved above the transcript so it cannot eat the composer's nav-bar clearance. After: field box31,2277..1048,2329, grey bar ending at device y2361 with the 63px nav strip below it (/tmp/fix1.pngthis pass). The screenshot shows the composer bar (the grey band) sitting about two thirds of the way down a 704x1568 screen, with black below it to the bottom, and the transcript ending at "Claude / Results" just above it -- at launch, no keyboard. So the composer's bottom padding, which the 2026-09-06 rebuild tied to the IME/nav-bar inset, is being fed a large value at start on the phone. Suspects, in order: the initial inset delivery on the phone (GrapheneOS, gesture navigation) versus the emulator;ime_bottomnow carrying a1/0boolean through a field the composer may still read as pixels or dp; a stale value from before the firston_insets_changed. Reproduce with the phone's screen size and density on the emulator before guessing. - [~] "Swiping still gets caught by the grey bar but keeps working
after I go past it." Improved 2026-09-06 by the focused-field rule
below, still needs her phone to close.
attr.rs'son_presstreated an already-focused composer as the plain drag-to-select case, so a swipe starting inside it dragged a highlight through the typed text for the whole gesture; it now abandons that the moment the press passesDRAG_SLOPvertically (AndroidEditText's own rule), which removes one of the two things that made the bar feel like it caught the swipe. The residualDRAG_SLOPmeasured from the boundary crossing, described below, is unchanged. Original note follows. Not closeable from the emulator, annotated 2026-09-06 after theDragGesturemerge.attr.rs'son_pressnever callscapture_pointerand never consumes aPressingframe pastDRAG_SLOP(it just stops watching), so once the finger's current position leaves the composer's box and enters the list's,Liststarts receiving ordinary hit-testedPressingframes there --DragArbiter::is_idle()'s 2026-09-05 recovery (a missedPressStart) picks it up rather than leaving it stuck. What this does not do is what "wherever it began" implies literally:DragArbiter::press_startrestarts from the boundary-crossing position, not from the original touch-down inside the composer, so the pan still needs a freshDRAG_SLOPof travel measured from the boundary rather than from the start of the gesture -- composer and list are adjacent, non-overlapping widgets (lib.rs's(list, composer_bar).span(Dir::DOWN)), and only the composer forwarding its own drag to the list would remove that residual slop entirely, which is more than this pass's merge changes. RUST.md's merge-pass box has the reasoning in full and an emulator swipe confirming the composer's own box never moves/resizes during it; whether the residual slop is still perceptible as "caught" needs Iris's phone, since the emulator's per-widget boundary is a few dp wide and easy to cross without noticing on a real screen too. - "Flinging still does not work." No longer expected to reproduce
after the
DragGesturemerge (e12c708, pointer capture +CursorSense::Drop), 2026-09-06. Emulator evidence (RUST.md's merge-pass box, check (b)): a realui-tracefinger swipe followed by screenshot-hash sampling caught a post-release frame distinct from the drag's own last frame in one run, and every run showed 28-32render()frames per gesture against an idle baseline of 0 and ~8 expected from the drag alone -- redraw kept being requested well past the finger lifting, which only happens while a fling is still animating. Left unticked in spirit until Iris's phone confirms it, since only she can say whether it feels like a fling now; the emulator's screenshot timing could not always catch the tail of a fast-settling one visually (same caveat noted in RUST.md). - [~] "Text still disappears if I leave and come back to the app."
Instrumented 2026-09-06 so the phone can answer it, since no
emulator here has a Vulkan adapter.
iris/src/android/view.rsnow logs onelog::info!line per surface event with the glyph/atlas counts:iris surface: surface_destroyed, tearing the renderer down (glyphs_cached=387 atlas_pages=1),iris surface: surface_changed 1080x2424 already_live=false glyphs_cached=387 atlas_pages=1,iris surface: new renderer built (Gl), clearing glyph atlas: glyphs=387 pages=1, plusiris insets: ... window=(1080, 2424)on every insets change. That is the emulator's own healthy app-switch cycle, verified this pass (home, reopen, screenshot: all text intact,/tmp/appswitch.png). The one line to look for on the phone isalready_live=:trueon the return from backgrounding would mean the surface came back without asurface_destroyed, sosurface_changedreconfigured a renderer whose Vulkan swapchain and atlas textures belong to a window that is gone -- the reuse branch never clears the atlas, by design.falsewith nonew renderer builtline after it would mean the renderer failed to rebuild. Either answer names the fix; guessing between them from here does not. TheGlyphAtlas::clear/Textures::resetfix was verified on the emulator underforce-glesonly; the phone runs Vulkan. So either the reset is not reached on the phone's path (a different surface- lifecycle sequence --surface_destroyed/surface_createdordering, or the renderer not being rebuilt but its textures lost), or the CPU glyph cache and the GPU atlas still disagree after it. Needs logging of the renderer lifecycle on the phone build, readable fromadb logcatwhen Iris next runs it, since no emulator here has a Vulkan adapter under host GPU.
From the phone, 2026-09-06, 22:16 (build from 20303e0, delivered via ai-app-bench 95e25fe)
Iris's report, verbatim, with a screenshot. Phone: Mali-G715 (Vulkan),
content_scale: 2.55, 120Hz. Open until ticked with phone-side evidence.
-
"Fling still doesn't work." (Three defects fixed 2026-09-07; open until the phone says so. The line to look for:
adb logcat | grep "iris drag release"--samples=1orspan=0.0msmeans the batched samples are not reaching the tracker there, while a sensible span withv=in the thousands andoutcome=Released(Some(…))means the gesture was measured right and anything still wrong is downstream.) Second report; the emulator'sui-traceswipe flings (verified 2026-09-06 withrender()counts), a finger on the phone does not. What differs: a real flick at 120Hz is batched by Android into fewMotionEvents with historical samples (getHistoricalX/Y/EventTime), and can be DOWN, one or two MOVEs, UP insideDRAG_SLOP's worth of frames; aui-traceswipe is many evenly-spaced MOVEs. Suspects, in order:android/sense.rsreading only each event's final position (the velocity tracker sees two samples, or one); the release path starting a fling only from a gesture already inPanning, so a flick that crosses the slop on its last sample is treated as a tap;ACTION_CANCEL/pointer-capture delivering noDrop. Log the release decision (samples, span, velocity, outcome) atinfoso the next logcat settles it. -
"I can't reopen keyboard by tapping on message box after it already happened once." (Fixed 2026-09-07:
attr.rs's already- focused branch callsfocus_gainedon a tap insideDRAG_SLOP. Emulator: first tapmInputShown=true, back gesture, second tapmInputShown=true. Negative control with that one call removed leaves the second tap atfalse; a horizontal and a vertical swipe over the focused field both leave it atfalse, so the earlier "swiping over the input bar brings up the keyboard" has not returned.) The field stays focused after the keyboard is dismissed (back gesture, or the IME's own hide), soon_press's already-focused branch never requests the IME again. Android'sEditTextshows the IME on every tap of a focused field; do the same (FocusHost: a tap on a focused field requests the IME, idempotent when it is already shown). -
"Message box does not push up the scroll area." (Fixed 2026-09-07: height and visibility are two JNI values now. Emulator:
iris insets: … bottom=883 ime_bottom=883 ime_visible=true, composer box31,2277..1048,2329->31,1457..1048,1509, and the list follows because it isrest(1)in the sameSpan.) SinceMainActivitywent edge-to-edge (e12c708),adjustResizeno longer resizes the window, so the app owns the IME inset -- butime_bottomis passed through JNI as the boolean1/0(the 2026-09-06 "(b)" fix), so nothing has the inset's height to pad the transcript and composer with. Pass both:isVisible(ime())andgetInsets(ime()).bottomin px; the list's bottom padding and the composer's position follow the height, the visibility drives the boolean theimePaddingrule in AGENTS.md's "Things that have bitten" describes. -
"Picture is what happens if I leave the app and come back, which completely removes text, and then I tap on the debug info. The textures are definitely getting cooked for some reason after leaving the app and resuming." Screenshot: every glyph drawn before the resume is fragments; the diagnostics text drawn after is perfect; the report says
atlas format: Rgba8Unorm, views live: 0. Reading:Textures::reset/GlyphAtlas::clearon the new renderer emptied the GPU atlas, but the per-widget cached text primitives (TextView's render cache -- the onec3cfc67's shape counter is keyed on) still carry the old atlas coordinates and are re-submitted as-is; only widgets drawn fresh after the resume shape and upload again. Fix: a renderer rebuild invalidates every cached text render (one generation counter on the atlas, checked atTextView::render, or a full-tree redraw with caches dropped), with adebug_assert!that no submitted glyph quad references an atlas generation older than the live one. Reproducible on the emulator by forcing a renderer rebuild (home + return, orsurface_destroyed/surface_created) on a screen with text already drawn -- the earlier "verified" home/reopen check screenshotted the emulator's GLES path, where a resume may not destroy the surface at all.Fixed in
ba2afba, withclearing_the_atlas_re_renders_cached_text_ instead_of_reusing_itrun and passing (2026-09-07). Ticked on the code; still wants phone-side confirmation -- no emulator here has a Vulkan adapter, and the GLES path may not destroy the surface at all, so the emulator cannot reproduce the state Iris photographed.The reading above is right and the mechanism is one step narrower than "cached text primitives".
IrisViewPeer::surface_changed(iris/src/android/view.rs) does already force a full-tree redraw after a rebuild: it callsrender.resize(...)unconditionally, which setsUiRenderState::resized, which makes the nextupdatetakeredraw_allrather thanredraw_updates. So every widget'sdrawreally does run again after the resume. What survives it is one cache further in:TextView::render(iris/src/widget/text/mod.rs) returns its cachedRenderedTextwhenever the wrap width, buffer and attrs are unchanged -- true of every pre-resume row -- soTextData::placeis never reached, nothing is re-rasterised into the fresh atlas, and the old atlas'suv_min/uv_max/layerare re-submitted verbatim. Only text whose content changed after the resume (the diagnostics pane Iris tapped) re-shapes, which is exactly the split in her screenshot.Painter::glyphshas one call site in the whole workspace, that one, so there is no second holder of aRenderedTextto fix.The fix, in
ba2afba:GlyphAtlas::generation, bumped byGlyphAtlas::clear;RenderedText::generationrecording which atlas its glyphs were placed against;Painter::atlas_generation();TextView::render's cache key gains it; and adebug_assert_eq!inPainter::glyphsthat a submitted quad's generation is the live one. Headless testclearing_the_atlas_re_renders_cached_text_instead_of_reusing_it(iris/src/widget/text/mod.rs): draw,atlas.clear(),resize, draw again, and assert the atlas holds the same glyph count again -- it stays at 0 without the fix, because the cache short-circuits beforeplace.
Build
-
Benchmarks, not unit tests, run on demand (2026-09-05; a
benches/or a script underiris/, never incargo test). The scenario that matters most is a message list — chat apps and this app's transcript alike — stressed with many messages and many images. One case in particular: resizing an input box (typing enough text to grow it) that pushes a long list of messages above it must stay very fast and recalculate almost nothing — a move of everything above, not a re-layout. That is exactly the O(1) move chain in LAYOUT.md; the benchmark is what proves it. Done when the numbers are in this file with the command, and the input-box case reports draws re-run, not just frame time.Built as two rigs, chosen per scenario by whether a real
wgpudevice is needed (UiRenderState/Widgetstouch no GPU or window, so most of this runs as an ordinary binary — the same propertylayout_tests.rsrelies on):iris/benches/message_list.rs— a plainInstant-timed binary ([[bench]] harness = falseiniris/Cargo.toml), not criterion: see the file's own header for why (short version — every scenario here reduces to a countUiRenderState::take_countersalready produces, which criterion's statistical machinery adds nothing to and which a new dependency is not worth pulling in for). Covers (a) first-frame cost of a message list of N wrapped-text rows (one in 20 also carrying a small in-memory image) for N = 100/1,000/10,000; (b) per-frame cost of scrolling that list, 200 ticks; (c) the input-box case — a fixed-height field at the bottom of the screen growing by a line 40 times, with the message list above it filling the rest of the screen. Run:cd iris && cargo bench --bench message_list(always release —cargo benchbuilds thebenchprofile, which is optimized).iris/examples/bench_images.rs— needs a real device, so it runs throughiris/run-headless.sh bench_images, printingUiRenderNode::take_image_bind_group_creates()(a new counter, added incore/src/render/texture.rsandcore/src/render/mod.rs, mirroringUiRenderState::take_counters) each frame. Covers (d): 1,000 image rows, checked both cold (does bind-group creation reach zero once loaded) and after appending one more image once settled (does that stay cheap) — the second question is what actually matters for a live transcript and is what turned up the two Fix items above.iris/run-bench.sh [list|images]runs either or both and is what to run before/after touchingScroll,Span,Sized, the move-offset chain, orGpuTextures.
Numbers (2026-09-05, release,
cargo bench/run-headless.sh, this VM: AMD Ryzen 7 3800X, 8 cores, rustc 1.98.0 nightly-2026-09-03):cd iris && cargo bench --bench message_list (a) first frame, N=100: 30.30ms draws=227 rewrites=15 moves=0 (a) first frame, N=1000: 186.04ms draws=2252 rewrites=150 moves=0 (a) first frame, N=10000:1770.36ms draws=22502 rewrites=1500 moves=0 (b) scroll, N=100/1000/10000, 200 ticks each: draws=200 rewrites=0 moves=200 (identical at every N) per-tick average: 0.0002ms (identical at every N) (c) input grows 40 lines, N=100/1000/10000 rows above it: draws=320 rewrites=40 moves=160 (identical at every N) per-line average: 0.0012-0.0013ms (identical at every N) cd iris && ./run-bench.sh images (2026-09-05, before the fix) frame=1 bind_group_creates=1000 (cold load) frame=2 bind_group_creates=1000 (see Fix item above) frame=3 bind_group_creates=0 frame=4 bind_group_creates=0 (append one image here) frame=5 bind_group_creates=1001 (see Fix item above) frame=6 bind_group_creates=0 cd iris && ./run-bench.sh images (2026-09-05, after the fix) frame=1 bind_group_creates=1000 (cold load, unchanged -- genuine work) frame=2 bind_group_creates=0 frame=3 bind_group_creates=0 frame=4 bind_group_creates=0 (append one image here) frame=5 bind_group_creates=1 (one image's own create_image, O(1)) frame=6 bind_group_creates=0Reading it: (a) is real, necessary work — shaping and laying out N never-before-seen text rows — and scales with N as it must, ~10x cost per 10x N. (b) and (c) are the pass conditions that matter: both are exactly flat across N = 100 to 10,000, confirming LAYOUT.md's O(1) move chain holds for both scrolling and for a growing input box pushing the message list — draws/moves per tick or per line do not grow with list size, and the per-operation cost (a fraction of a microsecond) is nowhere near a frame budget. (d)'s cold-load and steady-state halves behave as designed; its append half did not, until the fix above moved masks/move_offsets out of the per-image bind group — now flat at O(1) the same way (b) and (c) are.
-
I5's transcript screen (
iris/transcript-ui/, 2026-09-05) — what it left, each recorded at the point in the code it would go rather than silently dropped. See RUST.md's I5 box for the full account of what was built (the screen,SpanStyle, cross-row selection, the growing composer).- Android integration for this screen — done, 2026-09-05.
iris-android-app'stranscript-screenCargo feature (transcript_client.rs) runs this screen against a realai-serverthroughclient-core, confirmed on-device: real scrolling, real touch-drag panning, tap-by-name on the composer. Two real bugs found and fixed along the way (a missingINTERNETpermission; a background-thread redraw request that crashed via aLooperrequirement, fixed by routing throughView::post_delayed— seeIRIS.md'sTasks::redraw_handleentry). See RUST.md's I5 box, "The Android integration, done 2026-09-05" for the full account. - A render-time number for iris, comparable to Compose's
transcript-bench.shreport — instrumentation done and a real number obtained, 2026-09-05 (later the same day); the clean comparable loop is not.iris_core::FrameReport(iris/core/src/render/ frame_report.rs,IRIS.md's new entry) times every frame fromrender()'s redraw start to afterqueue.submit+present(), exposed as two named on-screen controls ("Frame report", "Reset frame report"). Driven against a real on-device touch-drag it readframes=34 janky%=61.76 p50=26.5ms p90=48.0ms p99=98.1ms worst=98.1ms— real, not inferred, but accumulated across several gestures rather than one clean 24-swipe loop, because of the new finding below. See RUST.md's I5 box, "Update, 2026-09-05, later the same day" for the full account. - New, 2026-09-05: intermittent touch delivery to iris's
SurfaceViewunder this checkout'sEMU_GPU=softwareemulator. The same swipe coordinates, confirmed (by scanning a screenshot column for the first non-black pixel) to sit over real row text, sometimes produced 30+ real frames and a screenshot diff and sometimes produced zero of either, across otherwise-identicalui-traceinvocations. Not the already-understood "already at that scroll edge" case (reproduced with content confirmed taller than the viewport, in both directions). Leading candidate, not yet confirmed: this checkout's emulator was independently observed at ~78% of one CPU core, continuously, while idle on-screen — SwiftShader's software rasterisation is CPU-bound by design, and a synthetic touch competing with that load for delivery is plausible but unmeasured during a failing gesture (the standing rule against diagnosing from after-the-fact measurements applies here). Needs a sampler (load,dumpsys input, a-i 0ui-tracecapture) running while a failing gesture is driven, and ideally a comparison under-gpu host(real Vulkan) to see whether it is specific to software rendering. This is what blocks the clean, comparable 24-swipe loop above. - Long-press-then-drag-to-select — confirmed on-device, 2026-09-05
(later the same day).
ui-tracegained aholddrag X1 Y1 X2 Y2 HOLD_MS MOVE_MSaction (emulator-tools, additive, extends the sameMotionEvent/injectInputEventmechanismswipealready used): press, hold pastLONG_PRESS, move, release, as one continuous touch. Driven against a real row (holddrag 300 1850 300 2050 600 300) it producediris selection: begin at row ...then a sequence ofiris selection: extend to row ...log lines (transcript-ui/src/selection.rs, a new smalllogdependency since selection has no accessibility label of its own yet — see the next item), and a screenshot taken right after shows the expected highlighted selection spanning multiple rows.DragArbiter's own unit tests already covered this sequence against a synthetic clock; this is the first time it has been driven by a real device touch. - Touch-drag panning over a row's own rendered text — done,
2026-09-05.
row.rsused to registerCursorSense::click_or_drag()on each row'sTextEditfor cross-row selection;TextEdit::draw'spainter.child_layer()(iris/src/widget/text/edit.rs:87) meant that registration woncore/src/sense.rs::run_sensors's per-layer arbitration on every frame it was pressed, not just the frame the press started, so a list pan gesture registered onListitself never got a turn while a row was under the finger. Fixed withiris::sense::DragArbiter(recorded inIRIS.md), one small state machine per list deciding pan vs. select the way Android does (a vertical drag pans immediately; a stationary press heldLONG_PRESS(500ms) starts a selection which further drag extends; a horizontal drag while something is already selected extends immediately) —transcript-ui/src/selection.rs'sSelection::dragis the one place every row's drag now routes through. 8 new unit tests (iris/src/sense.rs'sdrag_arbiter_tests);cargo fmt/clippy/test --workspaceandcargo ndk(bothirisandtranscript-ui) all clean;run-headless.shscreenshot byte-identical to before the change (38578 bytes). See RUST.md's I5 box, "Gap closed, 2026-09-05". - Intermittent touch-scroll dropout — root-caused and fixed,
2026-09-05. Not the coalesced-
ACTION_MOVEhypothesis the earlier pass suspected (ruled out): a gesture'sACTION_DOWNcan land on a row's own padding/gap or its header, which noCursorSensecovers, soDragArbiternever getspress_startand sits inIdle(answersUndecidedforever) for that whole gesture. Fixed via a newDragArbiter::is_idle()thatSelection::drag(transcript-ui/src/selection.rs) checks to recover a missed press on the nextPressingframe. Four new unit tests. See RUST.md's I5 box, "Touch-scroll dropout root-caused, 2026-09-05", for the trace and what a peer session sharing this checkout's emulator mid-pass prevented from being re-verified end-to-end (the aggregateiris-scroll.shthree-run confirmation and a re-taken FrameReport row) — a future pass should finish that once the emulator is free. - Row-level accessibility names. The composer carries
.label("Message"); transcript rows do not carry a.label()of their own yet, soWidgets::named()(I4) does not include them —row.rs'sbuild_text_rowis where one would go, keyed to something stable per row (its sender + a short excerpt, matching what a screen reader announcing a chat message would say). - A tappable link — done 2026-09-06 (P1a).
TextEditCtx:: byte_at(pos, size)answers which byte a tap landed on without handing out the parley layout,GestureOutcome::Tappedsays the press committed to neither a pan nor a selection, andiris::platform::OpenUrlis the capability each backend implements (xdg-open/open/start; anACTION_VIEWintent on Android, deferred toafter_inputthe waypending_show_keyboardis). - A background chip behind inline code. Still needs per-range
glyph geometry — a run's boxes, not one offset — which
TextEditCtxdoes not expose outsideiris::widget::text(edit.rs'slayout()helper is private). The same primitiveTextEdit::draw's own selection highlight uses internally,iris/src/widget/text/edit.rs:99.byte_atabove deliberately did not open that up: a tap needs one offset and a chip needs the run. Selection's anchor-row shortcut. The row a drag started in is selected in full (select_all) the moment the drag leaves it, rather than "from the click point to whichever edge points away from the drag" — needs the same privatelayout()access as the item above.selection.rs's module doc has the exact reasoning.- Syntax highlighting inside a fenced code block — done
2026-09-06 (P1a).
client_core::highlight::spans_ofby language, converted from its char indices toSpanStyle's byte offsets, in the same Catppuccin paletteTheme.ktuses. A language the scanner has no rules for stays plain rather than being coloured by the nearest one's.
- Android integration for this screen — done, 2026-09-05.
-
Masks defined relative to each other. Wanted: mask A multiplies by something and also applies mask B — a mask can reference a parent mask, the way the move chain references a parent offset. Today masks are independent regions. Design it beside the move chain (same shape: a parent index and a bounded walk in the shader); do it when a real widget needs it, not before.
-
Positions as a single float per scroll. Iris raised, and half rejected, letting a scroll update one float rather than positions: input handling cares about most elements in a list, so absolute positions must be computed on the CPU anyway. LAYOUT.md's design already lands here (GPU walks the chain, CPU resolves on demand for hit tests). Keep the CPU resolution lazy and per query; do not materialise every row's absolute position per frame.
-
Animations, last. Cosmetic, so after everything above. Must be modular — a piece of the library rather than a core part forced into everything, the same way input is. Whatever the mechanism, a widget that does not animate must pay nothing and import nothing for it.
Found by P1a (2026-09-06)
-
Rectclaimed to be size-independent, and it is not. ARectfills whatever region it is handed, sodraw_inner's size-independent fast path -- which rewrites primitives withr.outside(&from).within(®ion)rather than redrawing -- could not reproduce itsdraw, and a.background(rect(..))kept the size of the provisional full-region passSpandoes in phase 1. One fenced code block's panel covered every block below it and every row below that. Fixed iniris/src/widget/rect.rs; the reason is written at the definition. Suspect the same cause for anything else tinted with a background rect. -
A wrapped transcript row tripped
reposition's debug assert. Settled 2026-09-06 by giving the move slot one owner instead of two.movaccumulates a delta on it,repositionoverwrote it, and both legitimately land on one widget in one frame:List::place's Bottom-known branch offers a row a same-size box that has moved (mov), then corrects the placement inside it when the row's cached height no longer matches what the row reports (reposition). The slot now always meansmove_applied + repositioned(ActiveData::repositioned,iris/core/src/ui/render_state.rs), sorepositionadds the move rather than dropping it -- the assert is gone and the arithmetic is right. Test:a_widget_moved_by_its_parent_and_then_placed_inside_it_lands_at_the_placementinlayout_tests.rs, which lands the child at the offered position (-100px) instead of the placement (100px) without the fix, and adebug_assert_eq!inrepositionthat nothing but those two ever writes the slot. Verified with the.wrap(true)repro (draws, no panic) and an emulator bench run with assertions live. -
Desktop colours are washed out: the winit surface is sRGB and the shader writes the palette's bytes as linear. Mocha Crust (17,17,27) is drawn as (73,73,91), measured off
run-headless.sh --shot. Android is correct, so this is the surface format rather than the palette -- but it makes the desktop build useless as a colour reference, which is exactly what P1a needed it for when the emulator could not draw glyphs. -
Every glyph was a solid box on the GLES backend -- iris's bug, not the emulator's. Fixed 2026-09-06. The atlas is one
texture_2d_arrayandGpuTextures::newcreated it with one layer; wgpu-hal picks the GL target from the descriptor ((false, 1) => TEXTURE_2D), so under GLES that array was aGL_TEXTURE_2Dbound to the shader'ssampler2DArray, the unit was incomplete, everytextureSamplereturned (0,0,0,1), anddraw_glyph'scolor.a *= texel.afilled the quad.MIN_ARRAY_LAYERS = 2iniris/core/src/render/texture.rs, with adebug_assert!atcreate_array_texture. Vulkan (the phone, the desktop's default backend) was never affected. Reproduce the class in seconds without an emulator:iris'sforce-glesfeature now switches the desktop backend too --./run-headless.sh transcript --shot /tmp/x.png -- -p transcript-ui --features iris/force-gles. -
The bench report pane draws over the transcript rows instead of replacing them. Visible on the emulator for the first time now that glyphs render there (
/tmp/emu-final.png, 2026-09-06): after a bench run the report's lines and the transcript's occupy the same rows in the top third of the screen, both legible, neither on top. Pre-existing -- the same overlap is in a screenshot taken before the move-slot fix -- so it is its own item, most likely the report pane not masking or not claiming its region.
Found by P1b (2026-09-06), all with a headless repro
Each was found by looking at iris/run-headless.sh transcript -- -p transcript-ui rather than at a diff, and each is worked around in
transcript-ui/src/tool.rs rather than fixed here. docs/RUST.md's P1b box
has the fuller account.
- A
SpanofPadded children inside anotherSpanplaces those children a slot out of step. Each child drew its content one sibling's height below its own box. Repro:IRIS_TOOLS_EXPANDED=1 iris/run-headless.sh transcript --shot /tmp/x.png -- -p transcript-uiwithtool.rs's group built asSpan(DOWN)[header, Pad(Span(DOWN) [cards]), bar]instead of the singleSpanit uses now. Bisected: removing the innerSpanfixes it, and so does removing the children's ownPad; the backgroundStack, theSizedwrappers and theWidgetPtrper child make no difference. Not themov-vs-repositionfaultf5b8893fixed -- it survives that commit. The workaround costs the group the 4dp inset its Compose counterpart holds its cards off the edge by, so this is worth fixing. scrollable_on(Axis::X)on a non-editableTextdraws nothing. The panel is drawn and the text inside it is not. A markdown fence does the same to aTextEditand is fine, so it is the widget kind rather than the chain.tool.rs'sraw_blockismasked()only until this is fixed, which means a long command is clipped rather than pannable.- No overflow ellipsis.
TextAttrscan wrap or not wrap; there is no "one line, ellipsised" the waymaxLines = 1+TextOverflow. Ellipsisgives Compose. A tool card's summary is clipped instead, so nothing on screen says it was cut. Whichever end is cut has to be a choice when this lands: a path is identified by its tail, a command by its head. - A drawn chevron.
Chevron.ktdraws its own strokes precisely because a chevron from a font is a glyph a system font may not have -- and the bundledNotoSans-Regular.ttfindeed has no U+25B8/25BE/25B4, whileNotoSansMono-Regular.ttfdoes.tool.rssets the mark in the monospace face as a result. A real fix needs a line/path primitive; iris has rects, text and textures only. - A tool card's text is not selectable.
Selectionis keyed(RowKey, block index)and a card has no markdown blocks, so nothing in a card registers. Compose'sSelectionContainercovers tool output, which is the text people most want to copy. Needs a key for "the nth text of this row" that a card can mint without colliding with a message's blocks.
Build (for the port)
Widgets RUST.md's "The port, in order (decided 2026-09-05)" needs and
iris does not have yet, one entry per gap, named against the P-step that
first needs it. Move an entry up to "Fix" or tick it in place once built;
do not duplicate it there.
- A history-paging cushion measured in on-screen viewports, not a
row count. (P1.)
iris::widget::Listhas no equivalent of the Compose app'sHISTORY_SCREENS— AGENTS.md's "Things that have bitten" is explicit that a fixed row count under-fills a screen on a tool-heavy transcript and over-fills one on a text-heavy one, so whatever loads the next page has to ask the list how many viewports are actually on screen, not assume a constant. - A scaled thumbnail/image widget for an in-transcript image.
(P1.)
SessionImage.kt's bitmap decode-and-downscale has no iris counterpart; iris's own image widget (used bybench_images.rs) draws a loaded texture but does nothing about sourcing or scaling one from a server-produced attachment. - A modal/dialog primitive. (P1, reused by P3 and
P5.) Needed for the session settings dialog,
UsageDialog's equivalent, and the delete-with-deleteForeignconfirmation with its toggle switch. Build once, wherever it is first needed, rather than once per screen that wants one. - A horizontal gauge/bar widget. (P1.) For
SessionUsageBar's equivalent — a bounded fill reflecting a fraction, nothing fancier. - A
BusyItemequivalent: a dimmed row carrying an operation label that does not block its list's own scroll/drag. (P3.) The Compose version tried an overlay first and it swallowed the drag along with the tap (AGENTS.md's "Shared appearance") — worth not repeating that attempt in iris before building the row-level version directly. - A toggle switch. (P3.) For the delete dialog's
deleteForeigncontrol; iris has no switch/checkbox widget yet as far as this pass found.
Reconsider
WidgetView. Iris is unsure of it: what she wants is an easy way to compose a widget from others (a button is the main case). With sizing folded intodraw, composing may be easy enough thatViewis 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
- A third length kind beside relative and pixels, so display scales
"just work". Done 2026-09-06 —
Len::dp/len_fns::dp, resolved againstUiRenderState/Painter::density()atapply_resttime; text additionally rasterises at the resolved (physical) size instead of scaling a low-resolution bitmap afterward, which was making text blurry.Span::gap/Paddingmoved fromf32toLenso they takedp(...)too; transcript-ui's row/composer padding and one example migrated.emwas not added — nothing in this pass needed a text-relative unit, anddp'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'sdp/ CSS's reference pixel is the standard (1 unit = 1/160 in), withemas the text-relative option — so a widget author writes16.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 andtranscript-uiuse 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. Done 2026-09-06 -- a row is a column of one
TextEditper markdown block (client_core::markdown_blocks,row::RowBlocks::apply_delta), so a delta re-shapes the last block and keeps every earlier block's layout. A block is the selection unit now (Selection'sSelKey); selection across blocks and rows still works, checked on the emulator with a real long-press drag. Pass condition met ina_delta_into_a_long_reply_redraws_the_same_widgets_as_a_short_one: a delta into a 100-paragraph reply redraws the same widget count as one into a one-paragraph reply (30 either way). 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. docs/RUST.md's Task B box has the detail and the two dead ends. The phone is the measurement that decides it -- these are emulator numbers and only the ratio transfers.The original entry, for the record: 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::applyreplaces 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-blockTexts rather than oneTextEditfor 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.