Baseline had panic=abort only. Measured each setting in order (docs/RUST.md's new "APK size (2026-09-07)" subsection has the full table and crate breakdown): strip=true, lto="fat", codegen-units=1, opt-level="s" take libmain.so from 18,546,488 to 11,193,608 bytes (-39.7%) and the release APK from 20,678,956 to 13,326,076 bytes (-35.5%), arm64-v8a. opt-level="z" was measured (another ~800KB) but not adopted without a frame-time check. Investigated naga/wgpu backend features and tabs-ui/tabs-screen as trim candidates; both are already fully eliminated by the linker on Android (0 symbols in `llvm-nm` on the baseline .so), so no Cargo feature change would shrink the binary -- left as documented findings rather than a diff. Embedded Noto Sans fonts (3.6 MB) and the wgpu/naga/font-shaping stack account for most of what remains vs. Compose, which borrows the platform's own renderer and fonts for free; recorded honestly in the doc rather than trimmed, since subsetting fonts or dropping a backend would change what iris can render.
6699 lines
414 KiB
Markdown
6699 lines
414 KiB
Markdown
# Moving the app to Rust
|
||
|
||
Working document for the question Iris asked on 2026-09-04: what are the
|
||
options for switching the phone app to Rust, ideally pure Rust with one UI
|
||
framework shared with a future winit-based desktop application, at full
|
||
feature parity and without giving up anything native, performance
|
||
especially. Constraints she set: no Dioxus and nothing that draws through a
|
||
WebView; **no UI DSL** (which rules out Makepad and Slint); the result
|
||
should stay lightweight; platform-specific pieces are fine to maintain;
|
||
reimplementing a framework piece from scratch where it does not fit is
|
||
fine; effort and elapsed time do not matter, long-term robustness does;
|
||
this clone is where things get tried before anything is committed to
|
||
`ai-app`. Her own library, [iris](https://github.com/cat16/iris), is the
|
||
**in-house framework to be built up** for this, with Masonry as the
|
||
yardstick it is measured against.
|
||
|
||
Decisions get a date and a reason here, the way `PLAN.md` does. A section
|
||
describes something that has been tried only where it says so, with a date.
|
||
|
||
## Keep this file current as you work
|
||
|
||
**This file is the handoff, and it is meant to let a session be cleared.**
|
||
Write each result into it *as you get it*, not at the end: the box ticked
|
||
or the reason it could not be, the measurement with its number, the
|
||
decision with its date and what it rejected, and anything that cost time to
|
||
find out. Then a session that has filled its context can be cleared and the
|
||
next one can pick up from this file alone, which is much cheaper than
|
||
carrying a long conversation or re-deriving what was already measured.
|
||
|
||
Two things that follow. Write for somebody who was not here — name the
|
||
command, the file and the number rather than "the fix" or "the earlier
|
||
run". And write the failures and the dead ends too: "Venus is blocked by
|
||
the emulator, not by Mesa" and "the present mode was not the cause" are
|
||
worth as much as the successes, because they are what stops the next
|
||
session spending an afternoon on them again.
|
||
|
||
## Where things stand (2026-09-06, orchestrator plan)
|
||
|
||
Written by the design agent on picking the branch up after a `/clear`, so
|
||
the next session can resume from here. P0 is delivered and Iris's phone
|
||
report v2 is in (`docs/bench/iris-phone-v2-2026-09-06.md`); **P1 stays
|
||
gated on her verdict**, so this pass works the P0 defects and the pure
|
||
prerequisites in this order. Each item is ticked here by the agent that
|
||
closes it.
|
||
|
||
### APK size (2026-09-07)
|
||
|
||
Iris's question: the iris bench APK is about double the Compose bench APK
|
||
(20.6 MB vs 10.1 MB, both release). Measured before this pass: 18,088 KiB of
|
||
`lib/arm64-v8a/libmain.so`, stored uncompressed (`extractNativeLibs=false`),
|
||
plus 2 MB of dex; the Compose APK's dex is 25 MB raw, compressed to 9 MB in
|
||
the APK. `iris/android-app/Cargo.toml`'s `[profile.release]` set only
|
||
`panic = "abort"` -- no `lto`, no `codegen-units`, no `strip`, default
|
||
`opt-level`. All numbers below are `arm64-v8a` release, built with
|
||
`./build-apk.sh release` (this checkout, `nice -n 10`, no `CARGO_TARGET_DIR`
|
||
override, reusing the incremental `target/`), and are the raw file sizes
|
||
(`ls -la`), not what `du` would round to.
|
||
|
||
| profile.release | APK bytes | `libmain.so` bytes | delta vs previous |
|
||
|---|---|---|---|
|
||
| `panic="abort"` only (baseline) | 20,678,956 | 18,546,488 | -- |
|
||
| + `strip = true` | 16,435,156 | 14,302,688 | -4,243,800 |
|
||
| + `lto = "fat"` | 15,751,212 | 13,618,744 | -683,944 |
|
||
| + `codegen-units = 1` | 15,185,204 | 13,052,736 | -566,008 |
|
||
| + `opt-level = "s"` | 13,326,076 | 11,193,608 | -1,859,128 |
|
||
| + `opt-level = "z"` (not adopted, see below) | 12,507,276 | 10,374,808 | -818,800 |
|
||
|
||
Adopted: `strip = true`, `lto = "fat"`, `codegen-units = 1`, `opt-level = "s"`.
|
||
Baseline to final: `libmain.so` **18,546,488 -> 11,193,608 bytes (-39.7%)**,
|
||
APK **20,678,956 -> 13,326,076 bytes (-35.5%)**.
|
||
|
||
**`opt-level = "z"` was measured but not adopted.** It is smaller still --
|
||
another 818,800 bytes off `libmain.so` (7.9 MiB total vs `s`'s 8.7 MiB) --
|
||
but `z` trims more aggressively than `s` in ways that can cost frame time
|
||
(fewer inlines, more size-motivated codegen choices, per rustc's own docs),
|
||
and this pass did not have an iris-side frame-time benchmark run against
|
||
it (the app's own `run-bench.sh`/render report was not exercised here,
|
||
per this task's scope, and this checkout has no emulator currently up).
|
||
Trading an unmeasured runtime cost for ~700 KB more off the download is not
|
||
a call to make blind, so `s` is what shipped, and `z` is left as something
|
||
to try only alongside a `transcript-bench.sh`/`stream-bench.sh`-equivalent
|
||
run for iris to confirm it does not regress.
|
||
|
||
Not stripped (baseline) had a live `.symtab` (`llvm-readelf -S`): section
|
||
25, `SYMTAB`, 0x17ed40 bytes (~1.49 MiB) covering 65,223 raw symbols. `strip
|
||
= true` removes it at build time -- notably, `stripReleaseDebugSymbols`
|
||
(AGP's own strip task) had already logged "Unable to strip the following
|
||
libraries, packaging them as they are: libmain.so" on the baseline, so
|
||
Cargo's own strip is also the fix for that.
|
||
|
||
Baseline section sizes (`llvm-readelf -S`, before any profile change):
|
||
|
||
| section | bytes |
|
||
|---|---|
|
||
| `.text` | 6,463,032 |
|
||
| `.rodata` | 6,548,192 |
|
||
| `.eh_frame` | 721,872 |
|
||
| `.data.rel.ro` | 441,328 |
|
||
| `.gcc_except_table` | 13,652 |
|
||
| `.symtab` | 1,563,456 |
|
||
|
||
No `bloaty` on this machine (`which bloaty` empty); used
|
||
`llvm-nm -S --size-sort -C` on the baseline (unstripped) `.so`, summed by
|
||
the symbol's leading crate/module name. 7.02 MB of the 18.5 MB `.so` carries
|
||
a name at all (the rest is `.rodata` blobs -- embedded data, padding,
|
||
relocations -- that never get a symbol). Top 8 by that accounting:
|
||
|
||
| crate | bytes (named symbols only) |
|
||
|---|---|
|
||
| `naga` | 1,100,099 |
|
||
| `core` (std) | 698,862 |
|
||
| `wgpu_core` | 599,720 |
|
||
| `harfrust` | 372,694 |
|
||
| `alloc` | 347,324 |
|
||
| `read_fonts` | 326,824 |
|
||
| `wgpu_hal` | 299,648 |
|
||
| `skrifa` | 291,332 |
|
||
|
||
Also notable further down: `hashbrown` 244,712, `jni` 199,660, `std`
|
||
199,126, `serde` 168,256, `zeno` 157,320, `pulldown_cmark` 116,568,
|
||
`iris_core` 95,112, `parley` 78,816, `iris` 76,616, `swash` 72,940,
|
||
`serde_json` 72,312, `fontique` 45,464.
|
||
|
||
**The other ~11.5 MB of `.rodata`/unnamed data is mostly the embedded
|
||
fonts**: `iris/core/src/primitive/text.rs` `include_bytes!`s six Noto Sans
|
||
TTFs (`iris/core/assets/fonts/`) -- Regular/Bold/Italic/BoldItalic for Noto
|
||
Sans plus Regular/Bold for Noto Sans Mono -- totalling **3.6 MB** of raw
|
||
font data (`du -ch iris/core/assets/fonts/*.ttf`). That is real render
|
||
data, not something to strip: unlike the Compose app's Nerd Fonts icon
|
||
subset (`app/build-icon-font.sh`, which subsets because the app only ever
|
||
draws ~100 fixed glyphs), iris's Noto Sans embedding backs arbitrary text
|
||
in a chat transcript, so a subset would have to be a Unicode-coverage
|
||
subset (Latin/Latin-Extended/common punctuation, dropping CJK/Cyrillic/etc)
|
||
rather than a fixed-codepoint one -- a real behaviour change (text in a
|
||
language outside the subset would fall back to tofu or a missing glyph) and
|
||
out of scope for a size-only pass. Left as a follow-up, flagged for Iris:
|
||
subsetting would plausibly save 1-2 MB but changes what scripts render
|
||
correctly, which is a product decision.
|
||
|
||
**naga/wgpu backend features: investigated, not trimmed, because the
|
||
trim would not change the binary.** `iris/core/Cargo.toml` and
|
||
`iris/Cargo.toml` depend on `wgpu = "28.0.0"` with default features, which
|
||
via `wgpu`'s own defaults (`dx12`, `metal`, `gles`, `vulkan`, `wgsl`,
|
||
`webgpu`) forward `naga/hlsl-out`, `naga/msl-out`, `naga/glsl-out`,
|
||
`naga/spv-out`, `naga/wgsl-in`, `naga/wgsl-out` -- Cargo feature
|
||
unification is not per-target, so all of those are nominally "on" for the
|
||
Android build too, not just the ones Android actually uses (`glsl-out` for
|
||
GLES, `spv-out` for Vulkan). But `wgpu-hal`'s own `build.rs`
|
||
(`cfg_aliases!`) gates the *modules* themselves on the real target:
|
||
`dx12: target_os = "windows" AND feature = "dx12"`, `metal: target_vendor =
|
||
"apple" AND feature = "metal"` (`wgpu-hal-28.0.0/build.rs`,
|
||
`wgpu-hal-28.0.0/src/lib.rs`'s `#[cfg(dx12)] pub mod dx12;` etc). So on
|
||
`aarch64-linux-android` the dx12/metal modules never compile, nothing calls
|
||
into `naga::back::hlsl` or `naga::back::msl`, and the linker's normal
|
||
dead-code elimination already drops them: `grep -c
|
||
"naga::back::hlsl\|naga::back::msl\|naga::front::spv\|naga::front::glsl"
|
||
/tmp/nm_size.txt` on the **baseline** (no LTO yet) `.so` returns **0** --
|
||
none of that code reached the linked binary in the first place. `regex`
|
||
(pulled in transitively by `env_filter`, which `android_logger` uses for
|
||
`RUST_LOG`-style filtering) is in the same position: present in
|
||
`Cargo.lock` but only a handful of small generic-drop symbols in `nm`, not
|
||
a real contributor. Neither is worth a Cargo-level feature trim (which
|
||
would also need a per-target dependency table to avoid stripping dx12/metal
|
||
off the desktop build, adding real complexity for a change that measures
|
||
as zero). No emulator use was needed for this finding since no feature
|
||
flag changed; the earlier per-step size measurements (strip/LTO/cgu/opt-level)
|
||
were likewise not re-verified on the emulator, since this task's brief
|
||
scoped emulator use to the naga-trim step specifically, and that step's
|
||
answer was "don't."
|
||
|
||
**`tabs-ui`/`tabs-screen`: also investigated, also already dead.**
|
||
`build-apk.sh`'s default features are `"transcript-screen bench"`, passed
|
||
without `--no-default-features`, so the crate's own `default =
|
||
["tabs-screen"]` (`iris/android-app/Cargo.toml`) is *also* on for every
|
||
build this script produces, including the bench APK. `src/lib.rs`'s doc
|
||
comment already says the three screens are mutually exclusive at runtime
|
||
(`ActiveClient` gives `bench` priority over `transcript-screen`, which
|
||
takes priority over the default `tabs-screen`), and checking the actual
|
||
`#[cfg(...)]` gates confirms it is mutually exclusive at *compile* time
|
||
too: the `Client` struct and its one call to `tabs_ui::build` are behind
|
||
`#[cfg(not(feature = "transcript-screen"))]`, which is false whenever
|
||
`transcript-screen` is on, so that code does not even get generated, let
|
||
alone linked. `llvm-nm -C` on both the baseline and the final `.so` confirm
|
||
it: `grep -ci "tabs_ui\|sungals"` is **0** in both. So there is nothing to
|
||
trim here either -- `tabs-ui` and its `sungals.png` (8.9 KB) never reach
|
||
the linked binary in a `transcript-screen`/`bench` build, regardless of the
|
||
feature being nominally "on" in `Cargo.toml`.
|
||
|
||
**Comparison Iris asked for, honestly**: most of the remaining ~13.3 MB vs
|
||
Compose's 10.1 MB is not a build-settings gap, it is what each app links.
|
||
Compose's APK carries ~9 MB of *compressed* dex and links Android's own
|
||
platform renderer, text shaper (HarfBuzz/Minikin) and font files from the
|
||
system image at zero cost to the APK -- none of that is bytes Compose ships.
|
||
Iris ships its own copy of all of that: `wgpu`+`naga`+`wgpu_hal` (a
|
||
software/hardware-portable GPU backend and shader cross-compiler, roughly
|
||
2 MB of named symbols alone), `harfrust`+`read_fonts`+`skrifa`+`swash`+
|
||
`parley`+`fontique` (a full third-party font-loading/shaping/rasterizing
|
||
pipeline, another ~1.2 MB of named symbols), and 3.6 MB of embedded font
|
||
data because it cannot borrow the platform's fonts the way Compose does.
|
||
Build settings (this pass) closed real ground -- 39.7% off `libmain.so` --
|
||
but did not remove any of those linked systems, because removing them would
|
||
mean iris stops being a self-contained native renderer, which is the whole
|
||
point of the port (`AGENTS.md`'s "no Dioxus and nothing that draws through
|
||
a WebView", `no-dioxus-or-webview-ui-true-native-only` memory). Install size
|
||
(what `dumpsys package`/`du` on the installed `lib/arm64-v8a/` directory
|
||
would show) was not separately measured this pass: with
|
||
`extractNativeLibs=false` the `.so` is mapped directly out of the APK
|
||
rather than copied onto disk a second time, so install size tracks the APK
|
||
size closely for the native library and is not a second, larger number the
|
||
way it would be under the old `extractNativeLibs=true` default -- checking
|
||
this precisely needs the emulator, which this task scoped to the naga-trim
|
||
verification only.
|
||
|
||
Committed: `iris/android-app/Cargo.toml`'s `[profile.release]` now reads
|
||
`panic = "abort"`, `strip = true`, `lto = "fat"`, `codegen-units = 1`,
|
||
`opt-level = "s"`, with a comment naming the measured savings.
|
||
|
||
### Queue, 2026-09-07 (orchestrator)
|
||
|
||
In order; two builders at a time. Each is ticked here by the agent that
|
||
closes it.
|
||
|
||
- [x] Test rig, layers 1 and 2 ("Three test layers" below), landed 2026-09-07.
|
||
- [ ] Fling parity with Compose, and the phone's keyboard push-up, with
|
||
insets shown in the diagnostics overlay. Running, in a worktree.
|
||
- [ ] Rows at the transcript's top edge: culled too early in one state,
|
||
drawn through the header in the other (docs/IRIS_TODO.md, 2026-09-07).
|
||
First after the rig lands, using its layer-1 harness.
|
||
- [ ] Phone logging through Dev Updater (Iris has no logcat; see
|
||
docs/TODO.md and the memory note): research how Dev Updater shows an
|
||
app's runtime log, design the smallest route (the app keeps its own
|
||
recent log; a debug button copies it; Dev Updater reads it), write
|
||
the decision in docs/DECISIONS.md, build it.
|
||
- [ ] Masks with a shape -- docs/LAYOUT.md "Masks with a shape (decided
|
||
2026-09-07)". A mask references a primitive already drawn
|
||
(rect SDF, texture or glyph alpha), chained and multiplied; `.masked()`
|
||
points at the widget's own primitives; hit-testing applies the shape.
|
||
- [ ] Compose app: the `Reversed range` crash in `ToolInput.highlighted`
|
||
(docs/TODO.md). Main branch, not rustify.
|
||
|
||
### Desktop and phone share the code (Iris, 2026-09-07)
|
||
|
||
Iris plans to develop a desktop app as well, and asked that most code be
|
||
sharable between desktop and phone. The workspace already has that
|
||
shape -- `iris`, `client-core`, `transcript-ui` and `tabs-ui` are
|
||
platform-free, and `android-app`/`desktop-app` are the entry points --
|
||
so the rule is about keeping it: **a platform crate holds only what the
|
||
platform forces.** Today that is JNI, the IME and insets bridge, the
|
||
surface lifecycle and the bench JNI on Android; winit, argv and the
|
||
config file on the desktop. **What differs is the screen layout**, since a phone
|
||
screen with a finger and a desktop screen with a mouse want different
|
||
arrangements -- a session list beside the transcript rather than a
|
||
screen behind it, hover states, keyboard shortcuts. **What does not
|
||
differ is everything a layout is built from**: the widgets (a tap
|
||
button, a text field, a list, a card, a tool-call row), gestures,
|
||
folding, paging, selection, and the styling -- colours, spacing, type,
|
||
the surface ladder -- which is the exact same code on both, never a
|
||
desktop palette beside a phone one. Those are written once in a shared
|
||
crate, with a platform trait underneath when a behaviour genuinely
|
||
differs (`FocusHost`, `OpenUrl`, and the insets/`ime_visible`
|
||
feed are the existing examples). Two checks before finishing a change
|
||
under `iris/`: does `desktop-app` still build and run with it, and is
|
||
any UI logic newly in `android-app` that a desktop would also need?
|
||
The bench client (`android-app/src/bench_client.rs`, ~1000 lines) is
|
||
the first thing to look at moving, since a desktop bench on the same
|
||
fixture is layer 2 of the test rig below.
|
||
|
||
### Three test layers, cheapest first (decided 2026-09-07; layers 1 and 2 built the same day)
|
||
|
||
Iris's suggestion, adopted and layered: test at the cheapest layer that
|
||
can answer the question, and go up only when it cannot. The emulator
|
||
costs minutes a cycle; the desktop window seconds; the headless harness
|
||
runs inside `cargo test`.
|
||
|
||
1. **Headless, in-process, no compositor and no GPU -- the default.**
|
||
`iris::harness` (`iris/src/harness.rs`), plus the fixture crate it
|
||
opens. `Harness::new(size, density)` builds an `Rsc`, a
|
||
`UiRenderState` and a state whose `FocusHost`/`OpenUrl` *record* what
|
||
the platform was asked for; `frame(t_ms)`/`frames_until(..)` run
|
||
frames on a clock the test owns, and `replay(&TouchScript)` feeds a
|
||
recorded gesture one sample at a time exactly as
|
||
`IrisViewPeer::on_touch_event` replays Android's historical samples.
|
||
The recordings are plain `t_ms action x y` files under
|
||
`iris/transcript-fixture/touch/`, and `flick-120hz.touch` is the
|
||
phone's own shape: DOWN, four samples 4ms apart, UP, 20ms in total.
|
||
|
||
cd iris && cargo test -p transcript-fixture
|
||
|
||
runs in about a second and asserts (a) the flick releases with a real
|
||
velocity (`List::fling_velocity`, which only `Released(Some(v))`
|
||
fills), (b) the list travels and settles inside the AOSP spline's own
|
||
`FlingCalculator::duration`, (c) a tap moves nothing and opens no
|
||
link, (d) a long-press-then-drag leaves selected text and does not
|
||
pan, and (e) the composer clears a simulated 1000px IME inset
|
||
(`Composer::set_bottom_inset`). Each was confirmed to fail without
|
||
its subject rather than assumed: dropping `animate(id)` from
|
||
`Selection::drag` -- the phone's own "fling does nothing" defect --
|
||
and starting the fling curve at the wall clock each fail only the
|
||
flick test; flinging on `Tapped` fails only the tap test; a 5s
|
||
`LONG_PRESS` fails only the selection test; a `set_bottom_inset` that
|
||
ignores its argument fails only the composer test.
|
||
|
||
**What still cannot be answered below layer 3**: nothing renders
|
||
here, so anything about pixels -- glyph rasterisation, the atlas,
|
||
stale or duplicated primitives, colour, the surface lifecycle, the
|
||
renderer rebuild -- is invisible to layer 1 and only *looked at* in
|
||
layer 2. Frame *times* are not measurable at either: layer 1 does no
|
||
GPU work at all and layer 2 runs a debug build on this VM's virtio
|
||
GPU, so a number from either is not the phone's. Anything JNI (the
|
||
IME, real insets, the clipboard, battery) is layer 3 by construction:
|
||
layer 1 records that the platform was asked and layer 2 has no
|
||
Android platform to ask.
|
||
|
||
2. **A phone-shaped desktop window under headless sway -- for looking.**
|
||
|
||
cd iris && ./run-headless.sh phone --phone --shot /tmp/p.png -- -p transcript-fixture
|
||
|
||
About 15 seconds warm. `--phone` sets the private sway output to
|
||
1080x2424@120Hz and exports `IRIS_SCALE=2.55`, which reaches iris the
|
||
way `DisplayMetrics.density` does on Android
|
||
(`iris::default::content_scale`) -- the desktop backend now lays out
|
||
in physical pixels with a density instead of dividing into a separate
|
||
logical space, so both platforms run one path. `transcript-fixture`'s
|
||
`phone` example opens the same screen from the same bytes as layer 1
|
||
and the Android bench.
|
||
|
||
A gesture on screen uses the *same recordings*:
|
||
|
||
./run-headless.sh phone --phone --replay transcript-fixture/touch/flick-120hz.touch \
|
||
--shot /tmp/p.png -- -p transcript-fixture
|
||
|
||
writes `/tmp/p-before.png` and `/tmp/p.png` either side of the flick;
|
||
looked at 2026-09-07, the list moved back about seven turns of the
|
||
fixture and settled.
|
||
|
||
**`swaymsg seat - cursor` cannot drive it, and that cost an hour.**
|
||
This compositor runs the headless backend with no input devices
|
||
(`WLR_LIBINPUT_NO_DEVICES=1`, `LIBSEAT_BACKEND=noop`): the cursor
|
||
commands all report `success` and nothing whatever reaches the
|
||
client, with `swaymsg -t get_seats` showing `capabilities: 0` as the
|
||
only sign. wlroots 0.19 dropped `WLR_HEADLESS_INPUTS`, and ydotool's
|
||
uinput device would be ignored by a compositor that is not reading
|
||
libinput. `iris/rig-input`'s `replay-touch` uses the
|
||
**virtual-pointer protocol** instead, which is a client protocol and
|
||
needs neither devices nor root, and it parses `iris::harness`'s own
|
||
`TouchScript`. Two traps inside it, both found by printing winit's
|
||
events: a button sent in the same frame as the motion that first puts
|
||
the pointer over the window is dropped (the client sees the enter,
|
||
the moves and the *release*, never the press), so the pointer is
|
||
positioned and left to settle 200ms first; and a leftover window from
|
||
an earlier manual run **tiles beside the new one**, halving the width
|
||
and producing a screenshot that looks exactly like a duplicated-
|
||
primitive rendering bug -- `swaymsg -t get_tree` and `pgrep -af
|
||
examples/phone` are the check.
|
||
|
||
3. **The Android emulator -- platform plumbing and the final pass.**
|
||
JNI, IME, insets, surface lifecycle, the renderer rebuild, and one
|
||
verification run before a build goes to the phone. Not for iterating
|
||
on layout.
|
||
|
||
### The 2026-09-07 phone report on `ed04d4c`: the fling was linear, and the keyboard is a targetSdk
|
||
|
||
Iris's three lines on the `ed04d4c` build (Pixel 9 Pro XL, GrapheneOS
|
||
Android 17, Mali-G715, `content_scale` 2.55, 120Hz): item 4 (the resume
|
||
glyph corruption) **is fixed**, confirmed on the phone; "flinging now does
|
||
technically do something, but it seems to just be linear velocity with an
|
||
abrupt stop"; and "similarly, the keyboard raising up does not push things
|
||
upwards."
|
||
|
||
**The fling was exactly, arithmetically linear.** Not approximately.
|
||
`android_fling_spline::distance_fraction(t)` returned `t` for every `t`,
|
||
which is a constant-speed slide for the full `duration()` and then a stop
|
||
at full distance -- Iris's sentence, read straight off the code. Two
|
||
transposed halves of one AOSP loop did it, and they compounded:
|
||
|
||
1. AOSP's `SplineOverScroller` static initialiser **solves** the bisection
|
||
on the `P1`/`P2` curve and **samples** `SPLINE_POSITION[i]` from the
|
||
tension curve (`coef * ((1-x) * START_TENSION + x) + x³`); the second
|
||
half of the loop does the reverse to build `SPLINE_TIME`. iris had both
|
||
halves solving on the tension curve and sampling `P1`/`P2` -- so its two
|
||
loops were the *same computation*, and `SPLINE_POSITION == SPLINE_TIME`
|
||
element for element.
|
||
2. The lookup then bracketed `t` between **`SPLINE_TIME` entries** and
|
||
interpolated `SPLINE_POSITION`. AOSP brackets between the even time
|
||
steps `index / N` and `(index + 1) / N` (`SPLINE_TIME` is used only by
|
||
`adjustDuration`, which iris has no analogue of). With the two arrays
|
||
identical, `d_inf + (d_sup - d_inf)(t - t_inf)/(t_sup - t_inf)` reduces
|
||
to `t_inf + (t - t_inf)` = `t`.
|
||
|
||
Every test the calculator had compared it with itself -- monotonic, signed,
|
||
integrates to the closed form, per-tick deltas non-increasing -- and all of
|
||
them pass on a linear curve. That is the shape to distrust: `<=` is not
|
||
deceleration.
|
||
|
||
**Sources, read rather than remembered.** `frameworks/base`
|
||
`core/java/android/widget/OverScroller.java` from
|
||
`android.googlesource.com` (`?format=TEXT`, base64), and
|
||
`androidx.compose.animation:animation:1.12.0`'s `SplineBasedDecay.kt` and
|
||
`FlingCalculator.kt` out of the `-sources.jar` on
|
||
`dl.google.com/dl/android/maven2` (there is no androidx checkout here and
|
||
`cs.android.com` is JS-only; `androidx.tech` is now a parked domain serving
|
||
an unrelated site). The two agree line for line, which is why iris ports
|
||
one curve rather than two. The formulas, for the record:
|
||
|
||
P1 = START_TENSION * INFLEXION = 0.5 * 0.35
|
||
P2 = 1 - END_TENSION * (1 - INFLEXION) = 1 - 1 * 0.65
|
||
SPLINE_POSITION[i]: solve coef*((1-x)P1 + xP2) + x³ = i/100 for x,
|
||
then take coef*((1-x)ST + x) + x³
|
||
physical_coeff = 9.80665 * 39.37 * density * 160 * 0.84
|
||
l = ln(0.35 * |v| / (0.015 * physical_coeff))
|
||
distance = 0.015 * physical_coeff * exp(rate/(rate-1) * l)
|
||
duration = exp(l / (rate - 1)), rate = ln(0.78)/ln(0.9)
|
||
at time t: index = floor(100 * t/duration)
|
||
vcoef = (POS[index+1] - POS[index]) * 100
|
||
position = distance * (POS[index] + (t/duration - index/100) * vcoef)
|
||
speed = vcoef * distance / duration
|
||
|
||
**What changed.** `iris/src/sense.rs`'s `android_fling_spline` builds one
|
||
table, indexed by even time steps, and `sample(t)` answers AOSP's
|
||
`distanceCoef`/`velocityCoef` pair; `FlingCalculator` gained `velocity_at`
|
||
beside `position_at`. `iris/benches/fling_spline_reference.py` is an
|
||
independent hand transcription of both sources and prints the numbers the
|
||
tests assert on -- checked in because "numbers computed by the code under
|
||
test" is exactly how the last three tests passed through this defect.
|
||
Tests: `the_spline_matches_aosps_own_table` (the curve is not the
|
||
identity: 27.4% of the distance at a tenth of the time, 85.8% at half),
|
||
`a_flick_decelerates_the_way_aosp_says_it_does` (11064px/s at density
|
||
2.55: 6334px over 1.636s, speed 9202 -> 4733 -> 2650 -> 951px/s), and
|
||
`tick_fling_applies_shrinking_incremental_deltas` strengthened from
|
||
"non-increasing" to "the last delta is under 80% of the first". **Negative
|
||
control run**: with `sample` forced back to returning `t`, exactly those
|
||
three fail and the other eleven pass.
|
||
|
||
**Emulator evidence (API 36 AVD, debug, `force-gles`, 2026-09-07).** A
|
||
`ui-trace` swipe of 900px in 120ms releases at `v=3750` and the new
|
||
`iris fling tick:` debug line reports, frame by frame,
|
||
`speed=-3746 -> -2624 -> -1834 -> -1144 -> -752 -> -449 -> -243 -> -83px/s`
|
||
over 32 frames ending at `t=0.664s`, with the per-frame `dy` falling
|
||
`94 -> 34 -> 20 -> 13 -> 8 -> 4.4px`. **The end**: the same flick in the
|
||
other direction, from a list already at its newest end, produces exactly
|
||
one tick and stops -- no overshoot. **A finger during a fling**: swipe,
|
||
then a tap 200ms later, ends the fling at `t=0.248s` and 11 ticks instead
|
||
of running its full 0.55s.
|
||
|
||
**The keyboard: the bench app targeted SDK 34.** `app/build.gradle` said
|
||
`targetSdk = 34` while `compileSdk` was 37 and the Compose app in `app/`
|
||
targets 37 -- and that Compose app's keyboard *does* push its transcript up
|
||
on Iris's phone. Below target 35 a window keeps the legacy behaviour, where
|
||
`adjustResize` shrinks the window for the IME so `getInsets(ime()).bottom`
|
||
measures the overlap with an already-shrunk window and is zero;
|
||
`MainActivity`'s `setDecorFitsSystemWindows(false)` opts out of that and
|
||
still takes on the API 36 emulator here, which is why every test run showed
|
||
the push-up working. It is deprecated as of API 35 and Android 17 is where
|
||
it appears no longer to. Fixed by `targetSdk = 37`, where edge-to-edge is
|
||
not opt-in.
|
||
|
||
That is a *reading*, not a measurement -- no Android 17 device is reachable
|
||
from here -- so the second half of the change is making the phone able to
|
||
answer it. `MainActivity` now also registers a
|
||
`WindowInsetsAnimation.Callback` (`DISPATCH_MODE_CONTINUE_ON_SUBTREE`,
|
||
`onProgress` forwarding, `onEnd` re-reading `getRootWindowInsets` so an
|
||
interrupted animation cannot leave a frozen value), which delivers the IME
|
||
height on devices where only the animation path carries it and, on every
|
||
device, makes the push-up *animate* with the keyboard: the emulator log now
|
||
shows `ime_bottom=509, 663, 833, 881, 883` instead of one jump to 883. And
|
||
`insets::Shared::updates` counts every dispatch, which
|
||
`AndroidUiState::insets_report()` puts in the **Diagnostics pane**:
|
||
|
||
insets: dispatches=27 left=0 top=142 right=0 bottom=63 ime_bottom=0 ime_visible=false
|
||
|
||
Screenshot-verified on the emulator. Iris has no logcat, and "the listener
|
||
never fired" and "it fired with a zero height" look identical on screen;
|
||
`dispatches=0` prints its own sentence instead of the numbers, since those
|
||
would be defaults rather than measurements.
|
||
|
||
**What to look at on the next build.** Open the keyboard, press
|
||
`Diagnostics`, screenshot the `insets:` line. `ime_bottom` in the hundreds
|
||
with the composer risen: fixed. `dispatches` climbing but `ime_bottom=0`:
|
||
the targetSdk reading was wrong and the window is still being resized.
|
||
`dispatches=0`: the listener is not being called at all, which is a
|
||
different fault from either. For the fling, a flick should now visibly
|
||
slow before it stops rather than running out at speed.
|
||
|
||
### The 22:16 phone report, worked 2026-09-06/07
|
||
|
||
Iris's four items are listed in docs/IRIS_TODO.md's "From the phone,
|
||
2026-09-06, 22:16"; this is what was found and what was run. Item 4 was
|
||
committed on its own (`ba2afba`); items 1-3 and everything below landed
|
||
together after the emulator evidence.
|
||
|
||
**Item 4, text cooked after a resume -- root cause, fixed in `ba2afba`.**
|
||
Not "the cached text primitives are never redrawn": they *are*.
|
||
`IrisViewPeer::surface_changed` (`iris/src/android/view.rs`) calls
|
||
`render.resize(...)` on every surface event including the new-renderer
|
||
branch, which sets `UiRenderState::resized`, which makes the next
|
||
`update` take `redraw_all` rather than `redraw_updates` -- so after a
|
||
resume every widget's `draw` runs again. The stale coordinates come from
|
||
one cache further in: `TextView::render` (`iris/src/widget/text/mod.rs`)
|
||
returns its cached `RenderedText` whenever the wrap width, buffer and
|
||
attrs are unchanged, so `TextData::place` is never reached, no glyph is
|
||
re-rasterised into the fresh atlas, and the *previous* atlas's
|
||
`uv_min`/`uv_max`/`layer` go straight back to the GPU. Text whose content
|
||
changed after the resume -- the diagnostics pane Iris tapped -- re-shapes
|
||
and is therefore perfect, which is exactly the split in her screenshot.
|
||
The fix is one mechanism: a `generation` counter on `GlyphAtlas`, bumped
|
||
by `clear`, recorded on each `RenderedText`, added to `TextView::render`'s
|
||
cache key, with a `debug_assert_eq!` in `Painter::glyphs` that a submitted
|
||
quad's generation is the live one. Test
|
||
`clearing_the_atlas_re_renders_cached_text_instead_of_reusing_it`, run and
|
||
passing; **still needs phone-side confirmation**, since no emulator here
|
||
has a Vulkan adapter and the GLES path may not destroy the surface at all.
|
||
|
||
**Item 2, the keyboard would not reopen -- fixed and confirmed.**
|
||
`attr.rs`'s `on_press`, already-focused branch, now calls `focus_gained`
|
||
on a tap that stays inside `DRAG_SLOP`, which is what Android's own
|
||
`EditText` does (`showSoftInput` is idempotent). Emulator, 2026-09-07:
|
||
first tap `mInputShown=true`; back gesture; second tap `mInputShown=true`
|
||
and the composer rises again. **Negative control run**: with that one call
|
||
removed and nothing else changed, the second tap leaves
|
||
`mInputShown=false` -- Iris's report exactly. **The case the fix had no
|
||
reason to touch**, also run: a horizontal swipe across the focused
|
||
composer and a vertical swipe out of it both leave `mInputShown=false`, so
|
||
her earlier "if I swipe over the input bar it brings up the keyboard" has
|
||
not come back.
|
||
|
||
**Item 3, the IME height -- fixed and confirmed.** `MainActivity.java`
|
||
sends `getInsets(ime()).bottom` *and* `isVisible(ime())` as two separate
|
||
values (the height used to be sent as the boolean 1/0, which is why
|
||
nothing could pad by it); `Insets`/`WindowInsets` carry both, and
|
||
`bench_client.rs` reads the boolean for its state machine and the height
|
||
for `Composer::set_bottom_inset`. The list follows for free -- it is
|
||
`.height(rest(1))` in the same `Span` as the composer bar, so the bar
|
||
growing shrinks the list. Emulator, 2026-09-07:
|
||
`iris insets: ... bottom=883 ime_bottom=883 ime_visible=true`, and the
|
||
composer's box moves from `31,2277..1048,2329` to `31,1457..1048,1509` --
|
||
820px, which is 883 less the 63px navigation bar it was already clearing.
|
||
Screenshot checked: the transcript ends above the composer, which sits on
|
||
the keyboard.
|
||
|
||
**Item 1, the fling -- two more defects behind the first, all three
|
||
fixed here; the phone is what settles it.** The velocity half is what the
|
||
report predicted: `on_touch_event` read only each `MotionEvent`'s final
|
||
position, so a batched 120Hz flick fed the tracker one sample and
|
||
`velocity()` answered 0.0. It now replays every historical sample
|
||
(`getHistoricalAxisValue`/`getHistoricalEventTimeNanos`) through the
|
||
sensor pass, `CursorState` carries the sample's *own* time (so a replay
|
||
loop's speed cannot become the measured velocity), and the press itself is
|
||
a sample, as Android's own `VelocityTracker` does with `ACTION_DOWN`.
|
||
`iris drag release: samples=… span=…ms v=… outcome=…` logs the decision.
|
||
Then the emulator showed the two the report could not have known about:
|
||
|
||
1. **Nothing ever advanced a fling.** `List::fling` sets the state;
|
||
`tick_fling` moves it; and `tick_fling`'s only caller in the workspace
|
||
was `bench_client.rs`'s own fling phase, which drives it in a loop.
|
||
So the benchmark flung and a finger never did -- and the earlier
|
||
"verified flinging on the emulator with `render()` counts" was that
|
||
benchmark measuring itself. Measured before the fix: frames stop on
|
||
the same millisecond as `iris drag release`. iris now has one
|
||
animation mechanism -- `Widget::tick(now) -> bool`, ids registered
|
||
with `UiData::animate`, drained each frame by
|
||
`UiData::tick_animations`, which both backends call before the draw
|
||
and re-request a frame from while it answers true. `List::tick` is
|
||
`tick_fling`; `Selection::drag` registers on `Released(Some(v))`.
|
||
Test: `a_registered_fling_is_driven_by_tick_animations_and_then_
|
||
unregisters`, confirmed to fail without the registration.
|
||
2. **The fling lasted 45 seconds.** Visible only once flings animated at
|
||
all. Two causes, both in `FlingCalculator`: `List::fling` hardcoded
|
||
`FlingCalculator::new(1.0)` while the velocity it is fed is in
|
||
physical pixels (`List` reads `painter.density()` now), and
|
||
`physical_coefficient` multiplied by `FLING_FRICTION` (0.015) where
|
||
AOSP multiplies by its own tuning constant **0.84** -- a coefficient
|
||
56x too small, put through `exp(ln(…)/(rate-1))`. Every existing test
|
||
compared the calculator with itself (monotonic, signed, integrates to
|
||
the closed form) and so passed throughout;
|
||
`a_flick_lasts_what_aosps_own_formula_says_it_does` pins the absolute
|
||
numbers against AOSP's formula worked by hand. Emulator after both:
|
||
release at `v=11064`, frames for **1.62s**, then none -- against
|
||
AOSP's own 1.586s for that velocity at density 2.75.
|
||
|
||
**What Iris should look for on the phone**: `adb logcat | grep "iris
|
||
drag release"`. `samples=1` or `span=0.0ms` means the historical
|
||
replay is not reaching the tracker on her device; a sensible
|
||
`samples`/`span` with `v=` in the thousands and `outcome=Released(Some
|
||
(…))` means the gesture is measured correctly and anything still wrong
|
||
is downstream of it. `outcome=Tapped` means the flick never crossed
|
||
the slop.
|
||
|
||
**Two things found on the way, both pre-existing at `ba2afba`.**
|
||
|
||
- **`MOVE_CHAIN_LIMIT` was 16 and the composer's chain is 17.** Tapping
|
||
the composer in any debug build aborted on `resolve_move_chain`'s
|
||
assert; in a release build (what Iris runs) the walk simply stops
|
||
summing, on the CPU *and* in shader.wgsl, so a widget past the bound
|
||
draws and hit-tests short by whatever the outer slots held, with
|
||
nothing on screen to say so. Both constants are 64 now, and the assert
|
||
prints the chain (`64(0, 0) -> 63(0, 0) -> … -> 0(0, 0)`) so a cycle
|
||
and an honestly-deep tree can be told apart -- which is how this one
|
||
was: 17 distinct slots.
|
||
- **`minSdk` is 29**, up from 26. `getEventTimeNanos` and
|
||
`getHistoricalEventTimeNanos` are API 29, and a missing JNI method
|
||
there is a hard crash on the first touch rather than a degraded fling.
|
||
`build-apk.sh`'s `cargo ndk -P` matches.
|
||
|
||
Still open and **pre-existing**: the composer bar's grey background is not
|
||
drawn on the `transcript-screen bench` build, so the transcript shows
|
||
through where the bar should be (`Stack{StackSize::Child(1)}` is the thing
|
||
to look at). Unchanged by any of the above.
|
||
|
||
### Task A, closed 2026-09-06: the composer scrolls on a finger
|
||
|
||
`iris/transcript-ui/src/composer.rs` is `field.scrollable().masked()` now.
|
||
Verified on this checkout's emulator -- the evidence and the numbers are
|
||
in docs/IRIS_TODO.md's ticked "composer has no touch-drag scroll" item.
|
||
|
||
**The premise the task was given under was wrong, and that is worth
|
||
recording**: `Scroll` did *not* measure against the window. Its
|
||
`used.within_len(container).to_abs(output_size)` came to exactly
|
||
`abs + rel * container_px` -- the right number by a route that reads as if
|
||
the window were the container, which is what cost a session. It is
|
||
`painter.px_size()` and `to_abs(container_len)` now: same arithmetic,
|
||
stated the way the invariant is. `Scroll` also still reports its
|
||
**content's** size upward, deliberately -- reporting the container makes
|
||
the answer a function of itself (the bar is sized *from* that report, so
|
||
it collapses to nothing and never recovers; measured in the headless
|
||
harness before the shape was settled).
|
||
|
||
What actually broke the composer was three separate defects, each now
|
||
carrying a headless regression test in `iris/src/layout_tests.rs` that was
|
||
confirmed to fail without its fix:
|
||
|
||
1. **A `MaxSize` reported its cap as an unresolved `dp`.**
|
||
`Span::draw` places a child from the `abs`/`rel` of the length it
|
||
reported, so `dp(168)` was worth **zero** and the bar got a slot of
|
||
nothing the instant its content passed six lines; the `Scroll` inside
|
||
then measured its container at **-63px** (the padding subtracted from
|
||
nothing) and panned the whole message out of view. Emulator log, before
|
||
the fix: `container=-63 content=415.8 amt=478.8`. Fixed by
|
||
`Len::fold_dp` (new), used by `MaxSize` and `Sized` on the way out, and
|
||
guarded for every widget by a `debug_assert!` in
|
||
`UiRenderState::draw_inner` that a reported `Size` carries no `dp`.
|
||
Test: `a_dp_cap_is_reported_in_pixels_so_a_span_can_place_it`.
|
||
2. **A `Masked` allocated a fresh mask slot on every draw.**
|
||
`draw_inner`'s unchanged-region fast path means its descendants are
|
||
mostly *not* redrawn with it, so they kept clipping against the slot
|
||
they were first drawn under -- measured on the composer's tree at
|
||
**four live mask entries, none of them the widget's current box**, and
|
||
the field drew nothing at all. The slot is allocated once and rewritten
|
||
in place now (`ActiveData::own_mask`, `Painter::set_mask`), with its
|
||
path out in `remove`'s `undraw` branch. Test:
|
||
`a_masked_widget_keeps_one_mask_slot_that_is_always_its_own_region`.
|
||
3. **A panned widget's own hit box moved twice.** `mov` updates
|
||
`active.region` *and* accumulates the same delta on the widget's move
|
||
slot, and `resolved_region` added both -- so after a finger pan the
|
||
composer's field was untappable, while its descendants were fine (which
|
||
is why `hit_testing_follows_a_scrolled_widget`, which checks a
|
||
descendant, never saw it). `ActiveData::move_applied` records the part
|
||
of the slot's delta `region` already accounts for. Test:
|
||
`a_panned_widgets_own_hit_box_moves_exactly_once` (fails at exactly
|
||
2x the pan without it).
|
||
|
||
Still open, and **pre-existing** (present in the build before this change,
|
||
so not the scroll area's doing): the composer bar's grey background is not
|
||
drawn on the `transcript-screen bench` build, so the message reads as
|
||
white text over the transcript. `Stack{StackSize::Child(1)}` is the thing
|
||
to look at.
|
||
|
||
Rig fix on the way past: `iris/android-app/run-bench.sh` polled logcat for
|
||
`"iris bench report:"`, which `copy_report` also logs at startup
|
||
("nothing to copy -- run the benchmark first"), so it returned instantly
|
||
and printed a report that had never been run. It polls for the report's
|
||
own first line now.
|
||
|
||
### Task B, closed 2026-09-06: a streamed delta costs one markdown block
|
||
|
||
A transcript 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 trailed Compose on Iris's phone. A row is a **column of
|
||
one `TextEdit` per top-level markdown block** now, and a delta that lands
|
||
in the last block is one `set_with_spans` on that block.
|
||
|
||
- **`client-core/src/markdown_blocks.rs`** is the split: `split_blocks`
|
||
(top-level blocks with their source, via the same `pulldown-cmark` the
|
||
renderer parses with, so the two cannot disagree about where a block
|
||
starts) and `common_prefix`. Seven tests, including the one that says
|
||
the fast path must **compare** rather than assume: appending `---` under
|
||
a paragraph turns that paragraph into a heading, so an already
|
||
laid-out block is not always still what it was.
|
||
- **`iris/transcript-ui/src/row.rs`** builds the column and owns
|
||
`RowBlocks::apply_delta`; **`lib.rs`** keeps the *tail* row's blocks
|
||
(`TranscriptScreen::tail`) since that is the only row a delta reaches.
|
||
- **A block is the selection unit**, not a row: `Selection` is keyed by
|
||
`SelKey = (RowKey, u32)`, which compares in reading order at both
|
||
levels so every range query in that file is unchanged. The list-level
|
||
(pointer-captured) half of a drag resolves the block under the finger
|
||
from its drawn box (`Selection::locate`) instead of doing arithmetic
|
||
from the row's extent.
|
||
|
||
**Pass condition, met**: `a_delta_into_a_long_reply_redraws_the_same_widgets_as_a_short_one`
|
||
(`transcript-ui/src/lib.rs`) drives a real `UiRenderState` and asserts the
|
||
`Widget::draw` count for one delta into a 100-paragraph (3,000+ character)
|
||
reply equals the count for the same delta into a one-paragraph reply.
|
||
**30 either way.** It is a real test, not a tautology: it read **630
|
||
against 30** at three points on the way -- once because `Span`'s measure
|
||
pass redrew every child, and once because `build_tree` did not seed
|
||
`tail`, so the first delta after opening a screen took the rebuild path
|
||
with nothing on screen or in `take_rebuilds()` to say so.
|
||
|
||
Two things tried and dropped, so the next session does not redo them.
|
||
`Painter::measure` (a container asking a clean child for its size instead
|
||
of drawing it provisionally) fixed one of the 630s but the test passes
|
||
without it once the `tail` seeding is right, so it was removed rather than
|
||
kept on speculation. And the emulator's own numbers say the remaining
|
||
cost is not in the block split.
|
||
|
||
**Verified on the emulator** beyond the counter: the transcript draws its
|
||
blocks with their own spacing (heading, prose, fence), and
|
||
`ui-trace record --do "holddrag 300 700 700 1000 700 600"` logs
|
||
`iris selection: begin at row (3187, 0)` then `extend to row (3187, 1)`
|
||
with the highlight crossing from the heading into the code block -- a
|
||
selection that spans blocks, which is what the re-key had to keep.
|
||
|
||
### Bench, stream phase, before and after Task B (emulator, 2026-09-06)
|
||
|
||
`iris/android-app/build-apk.sh debug --abi x86_64 --features
|
||
"transcript-screen bench force-gles"` + `run-bench.sh`, this checkout's
|
||
AVD. Emulator absolutes transfer nothing; the before/after ratio on the
|
||
same emulator does.
|
||
|
||
Same AVD, same fixture, same build flags, 20 minutes apart. Emulator
|
||
absolutes transfer nothing; the ratio does.
|
||
|
||
before after
|
||
stream: 202 frames over 21.0s stream: 293 frames over 21.0s
|
||
late: 197 (97.5%) late: 285 (97.3%)
|
||
p50 61.5ms p50 54.5ms (-11%)
|
||
p90 211.7ms p90 113.1ms (-47%)
|
||
p99 342.6ms p99 137.4ms (-60%)
|
||
worst 403.6ms worst 143.0ms (-65%)
|
||
|
||
The tail is where the whole-message re-layout lived, and it is where the
|
||
change shows: 91 more frames delivered in the same 21 seconds. The p50
|
||
moves least, which is consistent -- a delta into a *short* message never
|
||
cost much. **The phone number is Iris's to take**; nothing here is a
|
||
statement about her device.
|
||
|
||
### Verification pass over Tasks A and B, 2026-09-06
|
||
|
||
Read of `git diff fb6b459..HEAD -- iris/ client-core/` against LAYOUT.md,
|
||
TEXTURES.md, IRIS.md/DECISIONS.md's 2026-09-06 entries and CODE_RULES.md,
|
||
with the emulator. **Verdict: deliverable to the phone.** One real defect
|
||
found and fixed, two missing guards added, one open item closed as stale.
|
||
|
||
1. **The block model is correct.** `split_blocks` was checked against the
|
||
shapes a real transcript has -- a fence with blank lines, a `---`
|
||
inside a fence, a nested list, a fence directly under a heading, a
|
||
table, a quote -- and against the property `apply_delta` rests on, at
|
||
**every character boundary** of a message containing all of them:
|
||
growing a message may rewrite its last block and never an earlier one,
|
||
or `common_prefix` says so. No defect (`client-core`'s
|
||
`every_prefix_of_a_streamed_message_keeps_all_but_its_last_block`,
|
||
commit `a56a928`). A delta closing a fence, a delta mid-word and a
|
||
stream ending inside an unterminated fence are each their own test.
|
||
|
||
2. **`iris/core/src/ui/render_state.rs`, `draw_inner`'s size-independent
|
||
fast path: fixed** (commit `e63e923`). It rewrites the widget's own
|
||
primitives in place and writes **no** move-slot delta, so unlike `mov`
|
||
there is nothing for `move_applied` to count; `167862c` counted one
|
||
anyway, and `resolved_region` then subtracted a distance the chain
|
||
never held. Every such widget's hit box sat short of its drawing by
|
||
its last step, with the drawing correct -- nothing on screen to say
|
||
so. `Span` reaches this on the **first frame** of any tree containing
|
||
a `Rect` (the `.background(rect(..))` idiom, list row tints), because
|
||
it measures each child at the full region and then places it. Pinned
|
||
by `a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at`,
|
||
the sibling of `a_panned_widgets_own_hit_box_moves_exactly_once` on the
|
||
branch that fix had no reason to touch.
|
||
|
||
3. **Selection across blocks is sound; its rebuild path had no test**
|
||
(commit `155d899`). `SelKey = (RowKey, u32)` orders lexicographically,
|
||
which is reading order at both levels, so `begin`/`extend`/`locate`
|
||
and the range queries carry over unchanged; `selected_text` joining
|
||
with a blank line is right for blocks as well as rows, since that is
|
||
how markdown separates them. The gap was the tail rebuilt under the
|
||
**same key with fewer blocks** -- the dropped blocks keep pointing at
|
||
widgets `replace_back`'s drop frees, and `Selection::begin` resolves
|
||
every registered handle on an ordinary press, so the next tap anywhere
|
||
panics. `e1030d6`'s unconditional `unregister` is correct and now has
|
||
`a_tail_rebuilt_with_fewer_blocks_leaves_none_of_them_in_selection`,
|
||
confirmed to fail (3 blocks still registered, expected 1) without it.
|
||
`Selection::registered_blocks` is the test-only accessor that lets it
|
||
assert the contract rather than only that nothing panicked.
|
||
|
||
4. **The three new `debug_assert!`s are whole-set, not one member.**
|
||
`Len::fold_dp`'s is in `draw_inner` after *every* `Widget::draw`, so
|
||
it governs the set by construction; `Pad` and `Span` were checked and
|
||
already fold through `apply_rest`, and `Sized`/`MaxSize` are the two
|
||
that reported a caller-written `Len` raw. `own_mask`'s reuse lives
|
||
inside `Painter::set_mask` itself, whose only caller is
|
||
`widget/mask.rs`. `move_applied` has exactly two writers, `mov` and
|
||
`reposition` (now one, after finding 2), and `resolved_region` is the
|
||
only reader -- `window_region` goes through it.
|
||
|
||
5. **The O(last block) claim now holds for parley, by counter**
|
||
(commit `c3cfc67`). `take_counters` gained a fourth number, text
|
||
shapes, bumped in `Painter::render_text` -- which `TextView::render`
|
||
only reaches on a cache miss, so it counts shapes and not requests. A
|
||
draw counter cannot stand in for it either way. Measured: **one delta
|
||
into a 100-paragraph reply shapes exactly 1 text layout, the same as
|
||
into a one-paragraph reply.**
|
||
|
||
6. **The composer bar's grey background *is* drawn** -- IRIS_TODO.md's
|
||
"still open, and pre-existing" note is stale and has been corrected.
|
||
Measured by decoding the screencap rather than eyeballing it: the bar
|
||
is `rgb(41,40,49)` (the declared `40,40,46` after sRGB rounding),
|
||
**full width, y2245..y2365** on the 1080x2424 AVD, with the field at
|
||
`31,2277..1048,2329` and the 63px nav strip below. Whatever the note
|
||
saw, Task A's `MaxSize`/`own_mask` fixes closed it.
|
||
|
||
**Checks run**: `cargo fmt --all --check` clean in both workspaces;
|
||
`cargo clippy --workspace --all-targets` warning-free (only the
|
||
pre-existing future-incompat note about `wgpu`/`naga`/`winit`);
|
||
`cargo test` 81 (iris) + 13 (iris-core) + 20 (transcript-ui) + 123
|
||
(client-core), all passing. One bench run on this checkout's AVD with the
|
||
assertions live, debug x86_64 `force-gles`, no abort and nothing in
|
||
logcat: **stream: 298 frames over 21.0s, late 287 (96.3%), p50 52.8ms,
|
||
p90 108.1ms, p99 137.3ms, worst 148.9ms** -- reproducing the "after"
|
||
column above.
|
||
|
||
|
||
- [x] **Merge the `DragGesture` work** -- done 2026-09-06 (merge commit
|
||
`f802de9`, `git merge --no-ff worktree-agent-a754368325fa06839`,
|
||
clean, no conflicts across the 8 files `e12c708` touched). Targets
|
||
two of the four bench-v2 defects: finger flings dropped by
|
||
per-widget hit testing (pointer capture + `CursorSense::Drop`), and
|
||
IME insets never redelivered (`MainActivity.java` edge-to-edge).
|
||
**Tap-vs-swipe/`DragGesture` overlap, reasoned through**: `attr.rs`'s
|
||
`on_press` (composer focus) and `sense.rs`'s `DragArbiter`/
|
||
`DragGesture` (list pan-vs-select) do not share a mechanism, but
|
||
they don't need to -- `on_press` never calls `capture_pointer`, so
|
||
it only ever sees an ordinary per-frame hit-tested `Pressing`/
|
||
`PressEnd` (`run_sensors`' `region.contains(cursor.pos)` check,
|
||
unaffected by capture unless *this* widget requested it), the same
|
||
as before `DragGesture` existed. The two only interact where a
|
||
gesture starts on the composer and travels into the list's region;
|
||
`run_sensors` already delivers `Pressing` to whichever widget's
|
||
*current* position contains the pointer, so `List` starts getting
|
||
frames the instant the finger crosses the boundary -- with no
|
||
`PressStart` of its own, which is exactly what `DragArbiter::
|
||
is_idle()`'s 2026-09-05 recovery branch exists for. No consolidation
|
||
needed; `DRAG_SLOP` is already the one shared constant (`attr.rs`
|
||
imports it from `sense.rs`, not a second copy).
|
||
**Checks, 2026-09-06 merge pass**: `cargo fmt --all` clean;
|
||
`cargo clippy -p iris -p iris-core -p transcript-ui --all-targets`
|
||
and the same for `-p desktop-app -p tabs-ui`, zero warnings beyond
|
||
the pre-existing external-crate future-incompat notice
|
||
(naga/wgpu/wgpu-core/wgpu-hal/winit); `cargo test --lib -p iris -p
|
||
iris-core -p transcript-ui` and `-p desktop-app -p tabs-ui`, 97
|
||
passed/0 failed, including the review-fix tests below.
|
||
`cargo test --workspace`/`cargo clippy --workspace --all-targets`
|
||
(the full-workspace forms, which also build `iris`'s winit examples)
|
||
were abandoned after 40+ minutes each stuck compiling one example
|
||
binary with `uptime` reading a load average of 66-78 on this 8-core
|
||
VM (3-4 concurrent peer `cargo`/`cargo check` invocations the whole
|
||
session) -- `ps -o time` on the stuck `rustc` showed 2 seconds of
|
||
accumulated CPU time after 38 minutes of wall time, confirming
|
||
scheduler starvation rather than a hang. The per-package `--lib`
|
||
form above is what actually exercises the changed code and finished
|
||
in under 4 minutes warm. `android-app` (`iris-android-app`) is
|
||
excluded from the host workspace (`iris/Cargo.toml`, needs the NDK
|
||
target) and is covered instead by the APK build below, which
|
||
compiles it for `x86_64-linux-android`.
|
||
|
||
**Emulator checks, 2026-09-06** (this checkout's `ai-app-2` AVD,
|
||
`iris/android-app/build-apk.sh debug --abi x86_64 --features
|
||
"transcript-screen bench force-gles"` -- plain Vulkan crashed on
|
||
this AVD's boot this pass, `wgpu_core::instance: enabled backend
|
||
Vulkan has no adapters`, unrelated to this merge and worked around
|
||
with `force-gles` the way I5's own box already documents for this
|
||
hardware):
|
||
- **(a) tap-vs-swipe still holds.** Fresh app launch, `dumpsys
|
||
input_method`'s `mInputShown=false` at rest. `ui-trace record
|
||
--do "swipe 540 1510 540 700 200"` (a swipe starting on the
|
||
composer's own box, read from `ui-trace show -m Message --field
|
||
box` as `31,1488..1048,1540`) leaves `mInputShown=false` and the
|
||
box unmoved (no keyboard-driven resize). `ui-trace record --do
|
||
"tap 540 1510"` on the same field then reads `mInputShown=true`.
|
||
Matches `20b1225`'s original result -- the `DragGesture` merge
|
||
did not disturb it, confirming the reasoning above.
|
||
- **(b) a real finger fling keeps the list moving after release.**
|
||
Screenshot-hash sampling (`adb exec-out screencap`, `md5`, since
|
||
transcript rows carry no per-row accessibility label yet -- I5's
|
||
own leftover -- so `ui-trace show` cannot track them) at ~40-60ms
|
||
intervals through and after a fast `swipe 540 1400 540 400 120`
|
||
(with room to scroll confirmed by a preceding slow drag) caught
|
||
two *distinct* post-release frames in one run (a settle-position
|
||
beyond the raw drag's own last frame), and every run showed
|
||
28-32 `iris::android::view: render()` log lines per gesture
|
||
against an idle baseline of 0 in 1.5s and roughly 8 expected from
|
||
a bare 120ms drag's own `Pressing` frames alone -- i.e. redraw
|
||
kept being requested well past the finger lifting, which only
|
||
happens while `List::tick_fling` is still returning `true`.
|
||
Some runs' screenshots showed only the drag's own jump with nothing
|
||
further *visibly different*, which is consistent with a real but
|
||
small/fast-settling fling (a modest synthetic-touch velocity's
|
||
spline tail moves little per frame) rather than absence of one --
|
||
the render-count signal did not vary between those runs and the
|
||
one with a visible second frame. Recorded as confirmed, with that
|
||
caveat, rather than measured to a number; a phone verification
|
||
(Iris's own report closes this properly) is still open per
|
||
`IRIS_TODO.md`'s item.
|
||
- **(c) `on_insets_changed` fires on an IME toggle, with confirmed
|
||
cycles.** `run-bench.sh`'s report: `keyboard: shown 4/5, hidden
|
||
5/5 (confirmed via on_insets_changed)` -- the "could not be
|
||
shown" unknown-state line (`bench_client.rs::run_keyboard_phase`)
|
||
did not fire, unlike the pre-`DragGesture` build this same report
|
||
format existed for.
|
||
Worktrees removed after the checks above: `agent-a754368325fa06839`
|
||
(the source branch, its own emulator stopped first via `cd` into
|
||
it + `emu down`), `agent-a27094a7db775552a`, `agent-a1ff0294b6c29127e`,
|
||
`agent-a9002910a315fe719` -- each confirmed `git rev-list --count
|
||
rustify..<branch>` = 0 and no uncommitted changes first; their
|
||
branches deleted too. `agent-a16b22e34539b810e` and
|
||
`agent-a6e37a2335f436d08` left alone -- both `git worktree list`
|
||
`locked` to a live peer agent.
|
||
- [x] **Fix `docs/REVIEW-2026-09-06.md`**, done 2026-09-06, after the
|
||
merge (finding 1's shape and location in `selection.rs`/`lib.rs`
|
||
were unchanged by the merge, which touched `Selection` but not
|
||
`apply`'s `Rebuild` arm). All ten findings fixed -- new
|
||
`Selection::clear()` for finding 1 (the simplest option the review
|
||
named: clear the same way `List::clear()` clears the list, let
|
||
`push_row` re-`register` survivors), five `debug_assert!`s
|
||
(2-5, plus 7's restructure), and three new tests (8, 9, 10),
|
||
confirmed with the `apply_tests::a_row_dropped_by_a_regroup_does_
|
||
not_outlive_itself_in_selection` test passing (it exercises exactly
|
||
finding 1's shape: build a real `TranscriptScreen`, force the same
|
||
regroup `diff_tests` already covers, `apply`, then a surviving
|
||
row's `begin` -- panics pre-fix, per the review's own test-8 ask).
|
||
`docs/IRIS.md`'s 2026-09-05 entry gained the line the review's
|
||
"Docs" section asked for. See `docs/REVIEW-2026-09-06.md`'s own "Fixed, 2026-09-06"
|
||
section for the per-finding account. Committed together with the
|
||
review file.
|
||
- [~] **Iris's 11:39 phone report on the 02:07 build** (four items,
|
||
verbatim in `IRIS_TODO.md`'s "From the phone, 2026-09-06, 11:39"):
|
||
composer floating two thirds down the screen at launch with black
|
||
below it; a swipe starting on the composer held until the finger
|
||
leaves it; no fling (expected, `DragGesture` unmerged); text still
|
||
lost on app-switch on the phone despite the emulator-verified
|
||
atlas reset. The first and last are the same class as the next
|
||
box and go to that agent; the middle two are the merge box's.
|
||
- [~] **Stale primitives and invisible composer text**, 2026-09-06:
|
||
**typed text is fixed and was never a renderer bug at all**; the two
|
||
duplicate-drawing halves are **not reproducible** on this checkout's
|
||
emulator any more and are recorded below with what changed.
|
||
|
||
**1. Typed text (P0 box item 2, `IRIS_TODO.md`'s own item) --
|
||
fixed.** 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 set `selection = None` for
|
||
anything outside it; an empty field lays out to a zero-width box, so
|
||
tapping an empty composer granted focus and opened the keyboard with
|
||
no caret, and `insert_str` returns early without one -- every
|
||
keystroke was dropped in silence. **Gboard's suggestion strip is
|
||
Gboard's own composing state, not a read of our buffer**, which is
|
||
what made the earlier pass conclude the buffer held the text and
|
||
send the search downstream into the renderer; the `accessibility`
|
||
dump saying `text=""` for the `Message` node was the first
|
||
contradicting evidence. Parley clamps a point outside the layout by
|
||
itself, and a press reaching `select` has already been hit-tested to
|
||
the widget, so the "outside" branch had nothing left to mean. Three
|
||
new tests in `edit.rs`, one of which fails on the pre-fix code, plus
|
||
a `debug_assert!` in `insert_str` so an insert with no caret fails at
|
||
the mistake rather than dropping input -- it caught
|
||
`layout_tests::composing_text_after_a_keyboard_resize_...` typing
|
||
into an unfocused field the moment it was added. **Emulator
|
||
evidence**: `ui-trace record --do "tap 'Message'"` then `adb shell
|
||
input text` shows the text in the bar with a caret
|
||
(`/tmp/final-typing.png`) and logs `iris text render: chars=5 ...
|
||
glyphs=5`, against `glyphs=0` per keystroke before.
|
||
|
||
**2. The header drawn twice after a keyboard resize (this box's own
|
||
"(a)") no longer has a path to happen on this emulator, for a
|
||
measured reason**: since `MainActivity.java` went edge-to-edge
|
||
(`e12c708`), **opening the keyboard no longer resizes the surface at
|
||
all**. Measured: `render()` reports `out_size=(1080, 2282)` unchanged
|
||
across an IME open, while the new `iris insets:` line reports
|
||
`bottom=63 ime_bottom=0` -> `bottom=883 ime_bottom=1`. So the IME is
|
||
an inset now, not a `surface_changed`, and the two-phase `Span::draw`
|
||
the duplicate was blamed on is not re-entered. Reproduction attempts
|
||
this pass, all negative: `tap 'Message'` + `ui-trace elements`
|
||
(exactly one "Run benchmark" in every frame of the trace), a
|
||
screenshot with the keyboard open, and a real `adb shell wm size
|
||
1080x2200` *while the keyboard was open* (a genuine
|
||
`surface_changed`) -- one header row, no stray copy
|
||
(`/tmp/resize-dup.png`).
|
||
|
||
**3. The `Compacted:` row drawn twice on Iris's phone is still
|
||
open**, and nothing here reproduces it. What was ruled out this
|
||
pass: the widget arena (`list.rs`'s
|
||
`replacing_the_last_row_many_times_does_not_leak_primitives`), the
|
||
`top_bar` rebuild (`last_top_pad`, a previous pass), and now the
|
||
keyboard-resize trigger above. One real defect *was* found by
|
||
reading the path and is fixed, though it cannot be shown to be her
|
||
bug: `UiRenderState::draw_started` -- the guard whose whole job is
|
||
"do not redraw a widget an ancestor is drawing right now, or one of
|
||
the two copies is orphaned" -- **tested its own set after removing
|
||
the id from it**, so the test was constant `false` and the guard
|
||
could never fire, while the set grew by one entry per widget ever
|
||
drawn and was never emptied. It is now inserted around
|
||
`Widget::draw` and removed when it returns, with a `debug_assert!`
|
||
at the top of `update` that it is empty between frames. Her build
|
||
has both.
|
||
|
||
- [x] **Stale primitives, the phone's half** — **root-caused and fixed
|
||
2026-09-06, commit `76b1f99`.** It was neither `Span::draw` nor
|
||
`List`: `UiRenderState::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 (the order a
|
||
`HashSet` makes arbitrary, which is why it was intermittent), wrote
|
||
a second full set of primitives and then had `active.insert`
|
||
overwrite the only handles that could ever have freed the first
|
||
set. Those instances stay in the layer's buffer for the life of the
|
||
process, with a leaked move slot and leaked mask refs, redrawn 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, which is the copy below the composer.
|
||
`Painter::draw_twice` (`List::place`'s measurement pass) reaches
|
||
`draw_inner` twice for one id in one frame and so hits the same
|
||
fault with no ancestor involved.
|
||
**Fix**: consume the mark at the top of `draw_inner` — this call *is*
|
||
the redraw it asked for — and free the old primitives on the dirty
|
||
path too.
|
||
**Why the earlier passes could not see it**: `replacing_the_last_row_
|
||
many_times_does_not_leak_primitives` counts *widgets*, and the
|
||
orphan's owner is very much alive; it is an earlier set of that same
|
||
widget's primitives that is stranded.
|
||
**Guard**: `UiRenderState::orphaned_primitives()` names every live
|
||
instance no `ActiveData` owns, and `update` `debug_assert!`s it empty
|
||
every frame in debug builds. The per-frame form is a *count*
|
||
comparison (`primitive_counts_agree`, 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 (260s timeout, no report).
|
||
**Test**: `an_ancestor_redrawing_a_dirty_row_leaves_no_stale_copy`
|
||
(`iris/src/widget/list.rs`), which fails on the pre-fix code with
|
||
`1 primitive(s) survived their own widget's redraw`.
|
||
**Emulator evidence, 2026-09-06** (this checkout's `ai-app-2` AVD,
|
||
`build-apk.sh debug --abi x86_64 --features "transcript-screen bench
|
||
force-gles"`, a **debug** build so the guard is live): a complete
|
||
`run-bench.sh` run — 3,142 frames over 147s across the fling, the
|
||
400-event stream (which is 400 `apply` calls including the fixture's
|
||
compaction event), the typing and the keyboard phases — with the
|
||
assert firing zero times and `logcat` showing no abort. That is the
|
||
whole of the phone's reported scenario exercised with the invariant
|
||
checked on every frame.
|
||
- [~] **Composer touch-drag scroll** for overflowed text — **the
|
||
mechanism is done, the composer is not.** `Scroll::drag`
|
||
(`iris/src/widget/position/scroll.rs`) takes its pan from the same
|
||
`sense::DragGesture` `List` is driven by, and
|
||
`WidgetLike::scrollable()` registers it beside the wheel handler, so
|
||
every `.scrollable()` in the codebase pans on a finger with nothing
|
||
added at the call site. No fling (`Scroll` has no per-frame tick and
|
||
the areas it wraps are at most a screenful) — see IRIS.md and
|
||
DECISIONS.md. A vertical drag inside a *focused* field no longer
|
||
extends a selection either (`attr.rs`'s `on_press` now applies the
|
||
same `DRAG_SLOP` rule its unfocused branch already did), which is
|
||
Android `EditText`'s own behaviour and what lets the scroll area
|
||
around a field win the gesture.
|
||
**Tests**: four in `scroll.rs` (pan past the slop, a tap inside it,
|
||
a horizontal drag, the end clamp) plus
|
||
`a_finger_drag_over_a_scroll_area_pans_it` in `sense_tests.rs`, which
|
||
drives the whole path — `scrollable()`'s registration, `run_sensors`'
|
||
dispatch, `Scroll::drag`, arbitration and pointer capture — and fails
|
||
with `got 0` if the registration is removed.
|
||
**What is left, with the measurement**: wrapping the composer's field
|
||
in `.scrollable().masked()` was tried and reverted the same day.
|
||
`Scroll` resolves `content_len`/`container_len` against
|
||
`Painter::output_size` — the whole window — so inside the `MaxSize`
|
||
that caps the composer at six lines the two are in different spaces
|
||
and the field pans itself entirely out of the bar: measured on the
|
||
emulator with 474 characters in it (`iris text render: ...
|
||
size=(1016.7, 623.7)` against a 441px cap) the bar collapsed to its
|
||
padding with no text in it. Making `Scroll` measure against its own
|
||
offered box is the next step, and it touches a widget the transcript
|
||
and the bench shell both use.
|
||
One real bug **was** found and fixed on the way (`ActiveData::mask`
|
||
stored the mask a widget *set* rather than the one it was drawn
|
||
*under*, and `redraw` feeds that field straight 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; that is a
|
||
real abort on the emulator, `assertion failed: self.mask ==
|
||
MaskIdx::NONE`, reproduced as
|
||
`redrawing_a_masked_widget_does_not_nest_its_own_mask` in
|
||
`layout_tests.rs`).
|
||
- [ ] **Streaming re-layout** (IRIS_TODO.md's last section) — after the
|
||
above, since they make the stream phase unrepresentative today.
|
||
- [x] **client-core prerequisites for P1, in parallel** (pure Rust,
|
||
disjoint from `iris/`), closed 2026-09-06: `TranscriptSource`'s
|
||
cache-vs-server stitching (new `client-core/src/transcript_source.rs`)
|
||
and `joinPages`/`healSplitMessage`/`adoptRun` page-boundary healing
|
||
(new functions in `transcript_fold.rs`), per `CLIENT_CORE.md`. Ported
|
||
against the Kotlin source and AGENTS.md's paging incidents as the
|
||
spec (`TranscriptSource.kt`/`TranscriptItems.kt` had no JVM unit
|
||
tests of their own to port test-for-test). `client-core` goes from
|
||
85 to 109 tests; `cargo test`/`clippy --all-targets`/`fmt` all clean.
|
||
Both AGENTS.md regressions have a dedicated test: `loadOlderPage`'s
|
||
`before == 0` guard moved into `TranscriptSource::page` itself
|
||
(`paging_before_the_first_event_makes_no_request_at_all` asserts
|
||
zero transport calls, not just an empty result), and
|
||
`a_clean_boundary_between_two_finished_runs_is_still_healed_into_one_run`
|
||
pins `adopt_run` running on *every* join rather than only the
|
||
split-call path. One incidental fix needed to port `TranscriptSource`
|
||
faithfully: `api.rs` gained `fetch_transcript_lines` (additive, the
|
||
existing `fetch_transcript_page` untouched since `iris/` depends on
|
||
its signature), which pairs each event with the exact server bytes
|
||
it came from via `serde_json::value::RawValue` rather than
|
||
re-serializing a parsed `Value` -- needed so the cache and a live SSE
|
||
frame agree byte-for-byte, the same class of bug as the
|
||
`float_roundtrip` fix. Deliberately not ported: `EventStream.kt`'s
|
||
reconnect/backoff and cross-thread stream cancellation, which are
|
||
runtime policy for whichever framework embeds this crate, not pure
|
||
logic -- see `CLIENT_CORE.md`'s new section for the full account.
|
||
- **Then**: redeliver the APK for Iris. **Delivery is a push to the
|
||
`~/repos/ai-app-bench` repo** (`iris/build/outputs/apk/release/
|
||
iris-bench-arm64.apk` plus a dated README section), which Dev Updater
|
||
on the host pulls -- **not** `~/host/bench/`, which nothing reads; two
|
||
builds on 2026-09-06 went there and never reached her phone. Record any
|
||
choice she should see in `DECISIONS.md`.
|
||
|
||
## Where things stand (2026-09-05)
|
||
|
||
- **Streaming no longer costs a full rebuild** (P0's box, "Streaming no
|
||
longer costs a full rebuild" subsection): `iris::widget::List::
|
||
replace_back`/`clear` plus `transcript_ui::TranscriptScreen::apply`
|
||
replace the "refold + rebuild the whole ~3,200-row tree per event" path
|
||
in all three clients. Worst/p99 frame time in the streaming phase
|
||
dropped roughly 3x on this checkout's emulator (see the box for the
|
||
exact numbers and their caveats). Two new scripts,
|
||
`iris/android-app/build-apk.sh` and `iris/android-app/run-bench.sh`, now
|
||
do the build/install/tap/read-report cycle that used to be typed out by
|
||
hand each time.
|
||
- **The three items the dropout-fix pass left open are all closed,
|
||
2026-09-05** (the `ai-server` build break -- `event_model::
|
||
Event::LimitReached` -- was already fixed on `rustify` by the time this
|
||
pass started). Three clean `-gpu host` cold-boot `iris-scroll.sh` runs
|
||
all scrolled 24/24 swipes (checked directly via clustered `render():`
|
||
timestamps, not inferred from frame count), and the host-GPU table's
|
||
iris row is now a best-of-three. `EMU_GPU=software` + `force-gles`
|
||
still cannot produce a GLES number on this hardware, now for a third,
|
||
structural reason found this pass: SwiftShader's ES 3.0 GL path reports
|
||
zero storage-buffer capacity, and `shader.wgsl` reads four
|
||
`var<storage>` buffers unconditionally -- reaching that path needs a
|
||
shader rewrite, not a limits fix, so the SwiftShader-Vulkan-vs-GLES
|
||
question is closed as unanswerable on this hardware rather than
|
||
answered with a number. A fresh cold-boot `run-bench.sh` reading for
|
||
P0's bench build (`frames=690 janky%=62.03 p50=19.6ms worst=62.5ms`) is
|
||
in line with or better than the P0 box's three warm-AVD readings, so
|
||
that box's "needs a clean cold boot" caveat is resolved too. Full
|
||
account in I5's box, "The three remaining I5 verifications, closed
|
||
2026-09-05."
|
||
- **The intermittent touch-scroll dropout is root-caused and fixed,
|
||
2026-09-05.** Not the previously-suspected coalesced first
|
||
`ACTION_MOVE` (ruled out) -- a gesture's `ACTION_DOWN` can land on a
|
||
row's own padding/gap or its header, which `CursorSense` has no sensor
|
||
over, so the widget that ends up handling the gesture only ever sees
|
||
`Pressing` frames and `DragArbiter` never gets `press_start`, leaving it
|
||
stuck in `Idle` (answers `Undecided` forever) for the rest of that
|
||
gesture. Fixed in `Selection::drag` (`iris/transcript-ui/src/
|
||
selection.rs`) via a new `DragArbiter::is_idle()` the caller checks to
|
||
recover a missed press on the next `Pressing` frame. Four new unit
|
||
tests (three in `iris/src/sense.rs`'s `drag_arbiter_tests`, one in
|
||
`transcript-ui`'s `selection::tests`, the latter failing on the
|
||
pre-fix code). See this box's own "Touch-scroll dropout root-caused,
|
||
2026-09-05" subsection for the trace. **The aggregate verification an
|
||
earlier pass could not complete (peer-emulator interference) is now
|
||
done, 2026-09-05**: three separate cold-`-gpu host`-boot
|
||
`iris-scroll.sh` runs each scrolled all 24/24 swipes, confirmed by
|
||
clustered `render():` timestamps rather than frame count alone -- see
|
||
this same subsection's "Update, 2026-09-05" paragraph.
|
||
- **iris no longer requests compute-shader limits it never uses,
|
||
2026-09-05.** `adapter.request_device`'s `Limits::default()` asks for
|
||
desktop-tier compute limits unconditionally even though nothing in
|
||
`iris`/`iris-core` uses a `ComputePipeline` -- confirmed by grep, not
|
||
assumed -- which is what crashed `request_device` outright under
|
||
`EMU_GPU=software`'s `force-gles` path (SwiftShader's GL reports OpenGL
|
||
ES 3.0, no compute at all). New shared `iris_core::device_limits()`
|
||
zeros exactly the six compute fields; `rigs/gpu-probe`'s own mirrored
|
||
limits were updated and confirm `IRIS DEVICE: ok` on this VM's own
|
||
Vulkan and GL adapters. **Verified on-device 2026-09-05**: a cold
|
||
`EMU_GPU=software` boot no longer aborts on the compute-limit request
|
||
this fix targeted -- adapter selection now succeeds -- but device
|
||
creation still aborts, on a different, unfixed limit
|
||
(`max_storage_buffer_binding_size`, SwiftShader ES 3.0 has no SSBOs
|
||
either); see this box's "The three remaining I5 verifications, closed
|
||
2026-09-05" subsection, item 2. See this box's "Fixed, 2026-09-05,
|
||
later the same day" subsection (under the software-mode crash it fixes)
|
||
and `DECISIONS.md`.
|
||
- **Decided 2026-09-05: iris over Masonry**, by Iris, from the host-GPU
|
||
numbers in I5's box and E1/E2's findings. See the Recommendation's item
|
||
3 and `DECISIONS.md`. Next: the remaining screens and the app on iris —
|
||
a new ordered list is the next thing to write into this file.
|
||
- **The port plan exists, 2026-09-05: "## The port, in order (decided
|
||
2026-09-05)"**, seven steps (P1–P7) below "Experiments, in order,"
|
||
ordered by risk to the daily-use path rather than by screen count. **P1
|
||
— session screen parity — is next.** One crate decision made there:
|
||
screens grow out of `iris/transcript-ui` into `iris/app-ui`, with
|
||
`desktop-app`/`android-app` as thin entry points over it.
|
||
- **I5 is now `[x]`: a clean, single-session, like-for-like 24-swipe
|
||
scroll comparison between Compose and iris exists, 2026-09-05.** Same
|
||
sandbox session content for both apps, same emulator, `EMU_GPU=software`
|
||
(a second pair under `-gpu host` not yet taken). Headline: Compose
|
||
(debug build) 1102 in-app-reported frames, 99.0% late, p50 33.8ms/p90
|
||
50.6ms/p99 79.5ms; iris (**release** build -- debug `SIGSEGV`s on this
|
||
emulator, I4's finding) `FrameReport` 299 frames, 94.65% janky, p50
|
||
79.1ms/p90 98.6ms/p99 117.8ms/worst 212.6ms (repeat run: 233 frames,
|
||
94.42%, p50 109.3ms). **Not a clean apples-to-apples number**: different
|
||
build profiles (forced, not chosen), different jank definitions/frame
|
||
populations across the three measurement sources, and both are emulator
|
||
numbers under software rasterisation -- all stated plainly in I5's own
|
||
box, "Clean scroll comparison, 2026-09-05," which also has the
|
||
sampler timeline (load rose during the gesture but did not correlate
|
||
with a failure this pass) and the dropout finding (this pass's own
|
||
script bug -- `cd`ing into `/tmp` changed which emulator `ui-trace`
|
||
targeted -- not a reproduction of the previously-suspected touch-
|
||
delivery starvation). `DECISIONS.md`'s DEFERRED item has this table's
|
||
numbers for Iris to decide from; the iris-vs-Masonry choice itself is
|
||
still hers to make, not decided here.
|
||
I5's own box, "Update, 2026-09-05, later the same day" has the full
|
||
account.
|
||
- **The `-gpu host` pair this box's own DEFERRED item flagged as missing
|
||
is now taken, 2026-09-05, and it changes the picture.** Under real GPU
|
||
rendering (`--features force-gles`, since the default Vulkan backend has
|
||
no adapter under plain host-GPU boot -- confirmed by the exact crash
|
||
message), iris's median frame (15.0ms, `FrameReport`) is *faster* than
|
||
Compose's (20.0ms, in-app report) on the same session content, the
|
||
opposite shape from the software-mode table. The new CPU/GPU split
|
||
(`FrameReport::record_split`, `iris/core/src/render/frame_report.rs`,
|
||
commit `e2a1fad`) shows why: iris's own redraw-to-submit work is a
|
||
median 0.2ms; almost the whole frame is time handing off to the driver.
|
||
Software-mode `force-gles` crashes for a third, distinct reason
|
||
(SwiftShader's GL path reports itself as ES 3.0, which has no compute
|
||
shaders, and iris's device request assumes them unconditionally), so
|
||
this pass could not isolate SwiftShader-Vulkan as the sole cause of the
|
||
software-mode gap. A real intermittent touch-scroll dropout was also
|
||
reproduced and left unexplained (not the same as the earlier pass's
|
||
script-bug dropout). I5's box, "Where iris's frame time goes,
|
||
2026-09-05, the `-gpu host` pass" has the full account, all four
|
||
findings, and what verification did and did not re-run.
|
||
`DECISIONS.md`'s DEFERRED item has the updated table.
|
||
- **I5's Android integration is done and measured, 2026-09-05.** The
|
||
transcript screen runs on-device against a real `ai-server`, with real
|
||
scrolling, real touch-drag panning and tap-by-name accessibility all
|
||
confirmed by screenshot/log evidence on this checkout's emulator. Two
|
||
real, previously-unknown bugs were found and fixed getting here (a
|
||
missing `INTERNET` permission, and a background-thread redraw request
|
||
that crashed the process via a `Looper` requirement neither this box nor
|
||
`Tasks::redraw_handle`'s design had anticipated) -- both in I5's own box,
|
||
both in `IRIS.md`.
|
||
- **Design choices for the two pieces before this are summarised in
|
||
`DECISIONS.md`** at the repo root, which is the file Iris reads for
|
||
choices made without her.
|
||
- **E4 done, 2026-09-05.** `iris/desktop-app`: a winit window with a
|
||
session list beside `transcript-ui`'s screen (`build_tree`), against a
|
||
real `ai-server` through `client-core`, enrolled from the same
|
||
`aiapp://enroll?...` link a phone scans. Both pass conditions held on
|
||
`app/ui-sandbox.sh` -- see E4's own box for the commands, the
|
||
screenshot, and a real streaming-duplication bug the screenshot found
|
||
and a regression test now covers.
|
||
- **The I5 touch-drag pan-vs-select gap is closed, 2026-09-05**, as a
|
||
`DragArbiter` in `iris/src/sense.rs` wired into `transcript-ui`'s
|
||
selection -- see I5's own box below, "Gap closed, 2026-09-05".
|
||
- **Done**: E0 (toolchain), E1 (Masonry on android-view, which found the
|
||
keyboard gap — now explained, see below), E2 (a transcript in Masonry,
|
||
which found that Masonry has no touch-scroll on Android at all — see
|
||
below), E3 (the Kotlin/Java shell over a JNI bridge into Rust, both
|
||
pass conditions proved on the emulator — see its own box), E5 (the
|
||
Gradle-free packaging xtask, both pass conditions proved — see its own
|
||
box), I0a, I0b (iris builds on a pinned nightly and runs), I1 (parley +
|
||
glyph atlas), I2 (iris on android-view), I3 (`iris::widget::List`), I4
|
||
(host half).
|
||
- **E5 done, 2026-09-05.** `cargo xtask apk` (new `xtask/` crate at the
|
||
repo root, zero dependencies) replaces Gradle for packaging
|
||
`app/shellApp`: `cargo ndk` → `javac`/`d8` → `aapt2` → `zipalign` →
|
||
`apksigner`, signed with the same key `app/build-apk.sh` uses. Both pass
|
||
conditions held on this checkout's emulator: `adb install -r` over the
|
||
Gradle-built `shellApp` succeeded (same key, so the signatures matched),
|
||
and the notification service reached its follow-loop and posted a real
|
||
notification while the app was backgrounded. E3's open `kotlinc`
|
||
question resolved itself as a side effect of the one Gradle call still
|
||
needed for AndroidX dependency resolution — see E5's own box for the
|
||
full account, including the one disclosed place Gradle still runs and
|
||
what was deliberately left undone (a real-device `arm64-v8a` install,
|
||
dex shrinking).
|
||
- **I5 — the transcript screen in iris: partial, 2026-09-05 (ticked `[~]`
|
||
in its own box, not `[x]`).** `iris/transcript-ui/` builds a real
|
||
transcript screen — markdown-folded rows in `iris::widget::List`,
|
||
cross-row selection, a growing composer, tool-row expand-hold — on top
|
||
of a new, genuinely useful iris capability this box added:
|
||
**`SpanStyle`**, per-range text styling (`core/src/primitive/text.rs`),
|
||
which is what lets one wrapped, selectable `TextEdit` carry a heading,
|
||
bold, italic, inline code and a link all inside the same paragraph —
|
||
exactly the inline-rich-text ceiling E2 found Masonry structurally
|
||
unable to cross. Screenshotted via `run-headless.sh` (real inline
|
||
styling visible, not just block-level). 9 new tests, all passing;
|
||
`cargo build/clippy/fmt/test --workspace` and `cargo ndk` (both `iris`
|
||
and `transcript-ui`) all clean. **What did not happen this pass**: any
|
||
Android integration for this specific screen (no cdylib/Gradle shell
|
||
exists for it yet, unlike `tabs-ui`'s `iris-android-app`), and therefore
|
||
the emulator-side pass condition (`transcript-bench.sh` against the
|
||
Compose baseline, `ui-trace` tap-by-name on a row) — `emu list` showed
|
||
the one emulator here held by another session, but the real blocker is
|
||
that the integration work itself is unbuilt, not the emulator being
|
||
busy. Full accounting, every citation, and the dated IRIS_TODO.md items
|
||
are in I5's own box below. **Update, 2026-09-05, same day**: touch-drag
|
||
panning over a row's own rendered text, which was not yet reachable for
|
||
a specific, diagnosed reason (it competed with this box's own
|
||
row-level drag-select for the same gesture, not an absent primitive),
|
||
is now closed — a `DragArbiter` in `iris/src/sense.rs`, wired into
|
||
`transcript-ui`'s selection — see the box's "Gap closed" note. Android
|
||
integration is the one item left before this box can tick `[x]`.
|
||
- **E3 done, 2026-09-05, and unlike E1/E2 it is committed to this repo**
|
||
(`android-shell/` — a JNI-bridge crate on `client-core` — plus a new
|
||
Gradle module `app/shellApp/`, left deliberately separate from
|
||
`app/androidApp` so its ~13,000 lines of working Compose UI are
|
||
untouched). Both pass conditions held: a notification arrived in
|
||
Android's drawer while the app was closed, and a shared text share
|
||
landed as a real `userMessage` in a sandbox session's transcript. Found
|
||
and fixed three real bugs along the way — a generic `JObject` native
|
||
parameter silently exporting the wrong JNI signature
|
||
(`UnsatisfiedLinkError`), a class-by-name lookup failing from this
|
||
crate's own background thread because a Rust-attached thread has no app
|
||
`ClassLoader` (`Error::NoClassDefFound`, invisible without a logger
|
||
installed), and `onStartCommand` opening two `/notifications`
|
||
connections per enrollment — the last one a latent bug in
|
||
`Notifications.kt` itself, found here rather than there. See E3's own
|
||
box for the full account, the exact commands, and what was deliberately
|
||
cut (attachment uploads, a session picker, the on-screen/banner
|
||
suppression — all pending E4's screen).
|
||
- **I4 — accessibility names via AccessKit: host half done and verified
|
||
2026-09-05, ticked in the box below.** `iris_core::ui::access::AccessTree`
|
||
builds one flat AccessKit tree from `Widgets::named()` (a side set only
|
||
`.label()` populates, so an unnamed widget costs this nothing), pushed
|
||
through `accesskit_winit` on the desktop and `accesskit_android` on
|
||
Android, updated only when a name/role/bounds actually changes (a
|
||
counter confirms it: 1 rebuild on first draw, 0 across an unchanged
|
||
frame, 1 more after a real move). E1's detach-abort mitigation is
|
||
carried (`android/access.rs`'s `raise_if_enabled`). Every check that
|
||
doesn't need the emulator is clean — see I4's own box for the exact
|
||
numbers. **What's left**: the emulator itself is held by another session
|
||
this pass, so `ui-trace record --do "tap 'pad'"` against
|
||
`iris-android-app`'s tabs screen (which now has five named buttons) has
|
||
not been run for real yet — exact commands at the bottom of I4's box.
|
||
- **E2 done, 2026-09-05, and its headline finding changes what "decide
|
||
from the measurements" (recommendation item 3) can mean right now.**
|
||
Built a real transcript screen (`~/src/android-view/e2-transcript`,
|
||
local, not committed — see E2's own box), fetching 854 real events from
|
||
an `app/ui-sandbox.sh` session through `client-core`. Six of the seven
|
||
"hard to get back" behaviours are answered with evidence either way;
|
||
the seventh (measurable frames) is **blocked before it can even start**:
|
||
neither of Masonry's scrolling widgets (`VirtualScroll`, `Portal`)
|
||
reacts to a touch drag, only to a wheel-style `PointerEvent::Scroll` —
|
||
confirmed by reading (`virtual_scroll.rs:504-523`, `portal.rs:259-267`)
|
||
and empirically (a real swipe and a synthetic Android scroll event both
|
||
moved nothing on screen). So `transcript-bench.sh`'s own gesture cannot
|
||
be performed against a Masonry transcript on Android today, which means
|
||
the render-number half of E2's pass condition has no comparison to make
|
||
yet — not a bad number, no number obtainable at all. Selection
|
||
spanning rows and per-span rich text (bold/italic/inline
|
||
code/links inside one paragraph) are also confirmed not possible on
|
||
the pinned commit, each for a specific, cited reason. What did work:
|
||
block-level rich text (heading size, monospace fences), real
|
||
virtualisation of 854 rows, `overwrite_anchor`-based hold-top-edge on
|
||
expand (screenshotted), and tap-by-name accessibility. Full writeup,
|
||
every citation, and the exact repro commands are in E2's own box below.
|
||
- **Done, 2026-09-04: the `Widget::draw`/layout redesign (LAYOUT.md).**
|
||
`desired_width`/`desired_height`/`SizeCtx`/`Cache` are gone; every widget
|
||
in `iris/src/widget/` implements one `fn draw(&mut self, &mut Painter) ->
|
||
Size`. A moved widget (`Scroll`, `Offset`) now costs one
|
||
`move_offsets` write resolved by a shared `resolve_move` WGSL function in
|
||
both shader stages, independent of how many primitives are in its
|
||
subtree — measured at 500 in `iris/src/layout_tests.rs`, which also
|
||
covers the unchanged-frame, hit-test-after-move and mask-follows-move
|
||
pass conditions as plain unit tests (no GPU or window needed, since
|
||
`UiRenderState` touches neither). All four examples render
|
||
pixel-identically to before the change. See LAYOUT.md's "Deviations
|
||
found during implementation" for five real bugs the design's first draft
|
||
did not anticipate — worth reading before touching `Aligned`, `Sized`,
|
||
`MaxSize`, `Scroll`, or the move-slot lifecycle again. `GpuTextures::grow_array`
|
||
(a second atlas layer opening) has now been exercised too, on `tabs` with
|
||
`PAGE` temporarily lowered — see TEXTURES.md's "Exercised, 2026-09-04".
|
||
Not done: a pixel-level screenshot check of a `Masked`-wrapped `Scroll`
|
||
(no example builds one yet — the numeric check in `layout_tests.rs`
|
||
stands in).
|
||
- **E1's keyboard gap is Masonry's `as_input_connection` returning `None`
|
||
(a TODO), not android-view or `EditorInfo`.** android-view's own demo
|
||
implements the `InputConnection` trait over a parley editor and gets
|
||
real Gboard suggestions on this emulator — screenshotted 2026-09-04.
|
||
android-view's `accesskit_android` adapter also has a reproducible abort
|
||
(a client detaching, not attaching, is the trigger) — see E1 below for
|
||
both, with the mitigation iris/I4 needs to carry.
|
||
- **Resolved, 2026-09-04: iris's binding array does not survive real
|
||
Android hardware.** iris's texture pipeline used to ask every device,
|
||
unconditionally, for `VK_EXT_descriptor_indexing` ("bindless" binding
|
||
arrays), which a real share of Android hardware lacks. It has been
|
||
rebuilt per TEXTURES.md's "Recommended shape": the glyph atlas is one
|
||
`texture_2d_array` (a layer per page), a standalone image is its own
|
||
ordinary `Texture`/`BindGroup`, and `request_device` now asks for no
|
||
features and no binding-array limits at all. `rigs/gpu-probe`, rewritten
|
||
to match, confirms `request_device` now succeeds on the emulator's
|
||
software Vulkan (`EMU_GPU=software`, SwiftShader) — see TEXTURES.md's
|
||
"Implemented, 2026-09-04" for the exact command and output, and for what
|
||
was verified (rendering, via `run-headless.sh`) versus what was reasoned
|
||
through but not separately stress-tested (a real second-atlas-page
|
||
grow under load). Nothing here has been run on real Android hardware
|
||
yet, only the emulator; the Android Vulkan Profile 2025 sourcing in
|
||
"iris's binding array does not survive real Android hardware" below is
|
||
what stands in for that until I2 gets a device.
|
||
- **I2 — iris on android-view: done 2026-09-05.** The android-view backend
|
||
(`iris/src/android/`), the `iris-android-app` cdylib and Gradle shell,
|
||
insets, the back gesture, and the full `InputConnection` bridge are all in
|
||
and measured working — Gboard's suggestion strip reads real buffer content
|
||
through it, the same bar E1 set. **The render gap (nothing drew but the
|
||
clear colour) is fixed**: `UiRenderNode::new` seeded the GPU's window
|
||
uniform from `WindowUniform::default()` (0, 0) rather than the surface's
|
||
real size, so the vertex shader's `/ window.dim` produced `NaN`/`Inf` clip
|
||
positions on every primitive, on both Vulkan and GLES — winit's backend
|
||
never hit this because winit fires an initial `WindowEvent::Resized` that
|
||
corrects it before the first frame, and android-view has no equivalent
|
||
event. Fixed by seeding the uniform from `config.width`/`height` at
|
||
construction instead of depending on a later resize call. The tabs example
|
||
now renders on the emulator on both backends (screenshotted); the
|
||
GLES-only `D2`/`D2Array` warning was confirmed a red herring — still
|
||
present post-fix, harmless. See I2's own entry below for the full
|
||
writeup. **E2** (a transcript in Masonry) is done — see its own box.
|
||
- **I3 — `iris::widget::List` built and benchmarked 2026-09-05, ticked in
|
||
the box below.** Variable-height rows, virtualised, moved not
|
||
relaid-out on scroll, insert-above-anchor and expand-hold both measured
|
||
flat across N = 100/1,000/10,000. What is left is wiring it into an
|
||
actual transcript screen and comparing against `transcript-bench.sh`'s
|
||
Compose baseline on the GPU emulator, which needs a session/scroll model
|
||
around it (closer to I5's scope) — see I3's own box for the exact
|
||
command once that screen exists. Read `list.rs`'s module doc and
|
||
`IRIS.md`'s 2026-09-05 entry before touching it: a widget that fills
|
||
whatever region it's offered (a `Rect` background) cannot be measured at
|
||
a throwaway region and merely repositioned, a lesson that generalises
|
||
beyond this one widget.
|
||
- **`client-core` built (2026-09-04)**, item 1 of the recommendation:
|
||
`event-model/` (the event types, now shared with `server/`) and
|
||
`client-core/` (REST and SSE clients, transcript fold, cache, highlighter,
|
||
ANSI parser, 85 ported tests). `CLIENT_CORE.md` maps Kotlin file to Rust
|
||
module and lists what is not yet covered. `./run-tests.sh` runs all three
|
||
crates.
|
||
- **The app itself is untouched.** Everything so far is in `iris/`, in
|
||
`rigs/gpu-probe` (a headless wgpu/Vulkan feature probe, pushable to a
|
||
device with no APK — see the binding-array section), and in the other
|
||
rigs; nothing under `app/` or `server/` has changed.
|
||
- **Changed outside this repo**, both in `emulator-tools` and both pushed:
|
||
`avd_serial` now validates its cache by asking the device its AVD name
|
||
rather than by checking the serial is still attached (a recycled port
|
||
silently pointed this checkout at another session's emulator), and
|
||
`EMU_GPU=software` was added as an opt-in that keeps a run off the host
|
||
GPU and gives the guest a software Vulkan device. The default is
|
||
unchanged, because `-gpu host` was measured and the Compose benchmarks
|
||
depend on it.
|
||
|
||
## What has to be reproduced
|
||
|
||
The app is ~19,000 lines of Kotlin. It splits three ways, and the split is
|
||
what decides how much of a port is mechanical.
|
||
|
||
**Pure logic with no Compose or Android in it, ~4,500 lines.** `Api.kt`
|
||
(1,142), `Events.kt`, `EventStream.kt`, `Sse.kt`, `TranscriptCache.kt`
|
||
(589, touches `java.io.File` only), `TranscriptSource.kt`,
|
||
`MarkdownSyntax.kt`, `Languages.kt`, `Highlighter.kt`, `Ansi.kt`,
|
||
`ResetCountdown.kt`, `Durations.kt`, `Sizes.kt`, `ModelName.kt`,
|
||
`LoadState.kt`, `ImportableStream.kt`. `TranscriptUnits.kt` and
|
||
`TranscriptItems.kt` (the event fold into rows, ~940 lines) are logic with
|
||
a handful of Compose annotations. This is also exactly the code that has
|
||
JVM unit tests today. All of it ports directly, and most of it already has a
|
||
Rust twin in `server/`: `Events.kt` is a hand-kept mirror of
|
||
`session/driver.rs`'s enum, the highlighter and the syntax scanner exist on
|
||
the server for the explorer, and the cache compares the server's own JSON
|
||
lines. **Sharing these types between server and app is the single largest
|
||
"keep things in sync" win available, and it does not depend on which UI
|
||
framework wins.**
|
||
|
||
**Compose UI, ~13,000 lines.** Screens, dialogs, the transcript list, the
|
||
markdown renderer's customisations, tool cards, the file explorer viewer and
|
||
editor. This is the part a UI framework choice is about.
|
||
|
||
**Android platform code, ~1,500 lines**, spread over 20 files. Every one of
|
||
these is a Java-side object that no Rust framework can replace, because
|
||
Android only offers them as Java classes:
|
||
|
||
- `NotificationService` — a **foreground service** holding the
|
||
`/notifications` SSE stream while the app is closed, with its ongoing
|
||
notification, `specialUse` type and the `POST_NOTIFICATIONS` request.
|
||
- `MainActivity` — edge-to-edge, the `ACCESS_LOCAL_NETWORK` runtime
|
||
permission (Android 17), `singleTop` intent routing for `aiapp://enroll`,
|
||
notification taps, and the **share sheet** (`ACTION_SEND`, any MIME type).
|
||
- `ServerConfig` — the bearer token sealed under an **Android Keystore**
|
||
AES-GCM key, shared with Dev Updater through `wg-app-link`'s `:link`.
|
||
- `EnrollmentScanActivity` — the in-app **QR scanner** (zxing, camera).
|
||
- `Attachments` — `ContentResolver` reads of shared URIs, `BitmapFactory`
|
||
decode and downscale, **EXIF** orientation.
|
||
- `SessionImage` — bitmap decode for produced images.
|
||
- `ScrollAnchor`, `Drafts` — `SharedPreferences`; `CrashLog` — `filesDir`.
|
||
- `TranscriptCache` — `cacheDir`.
|
||
- `DebugStats`/`FrameStats` — `Choreographer` frame timing and the render
|
||
report; `runtime-tracing` names composables in a system trace.
|
||
|
||
So **"pure Rust" on Android means Rust owns every line of logic and
|
||
drawing, behind a thin shell of Java stubs**, and a packaging step that
|
||
produces a signed APK. How thin, and whether Gradle is inevitable, are
|
||
answered below.
|
||
|
||
### How much Java is unavoidable, and why
|
||
|
||
Rust can *call* any Android API through JNI (`jni` crate, with
|
||
`ndk-context` handing over the `JavaVM` and the Activity): posting a
|
||
notification, `startForegroundService`, the Keystore, `ContentResolver`
|
||
reads, permission requests, `WindowInsets`, the clipboard. None of that
|
||
needs a line of Kotlin. What JNI cannot do is *define* a class that the
|
||
system instantiates **by name from the manifest** — an `Activity`, a
|
||
`Service`, an `Application`, a `BroadcastReceiver`. Those must exist as dex
|
||
bytecode inside the APK before any Rust runs, because the framework
|
||
constructs them and only then calls into native code. `NativeActivity` is
|
||
the platform's own stub for the Activity case; there is no
|
||
`NativeService`, and android-view ships its own `View` subclass for the
|
||
same reason.
|
||
|
||
So the floor is roughly **two Java classes of ten lines each**: an
|
||
`Activity` and a `Service` whose lifecycle methods are declared `native`
|
||
and registered from `JNI_OnLoad`, plus whatever android-view already
|
||
provides. Everything they would have done in Kotlin — insets, intent
|
||
routing, the SSE follow loop, the notification builder — is Rust reached
|
||
through those stubs. Writing the stubs in Java rather than Kotlin drops
|
||
`kotlinc` from the toolchain; `javac` comes with the JDK Gradle already
|
||
needs. Generating the dex from Rust is not worth it: there is no mature
|
||
Rust dex writer, and the stubs never change.
|
||
|
||
### Can the APK be built without Gradle?
|
||
|
||
Yes. An APK is a zip containing a binary-XML `AndroidManifest.xml`,
|
||
`resources.arsc`, `classes.dex`, `lib/<abi>/*.so` and assets, aligned and
|
||
signed with the v2 scheme. The tools are `aapt2` (manifest and resources),
|
||
`d8` (Java bytecode to dex), `zipalign` and `apksigner`, all in the SDK's
|
||
`build-tools`, none of them Gradle. Three ways to drive them:
|
||
|
||
- **A `cargo xtask`** (or `build.rs`-adjacent script) that runs `cargo ndk`
|
||
for each ABI, `javac` + `d8` for the stubs, `aapt2 link`, `zipalign`,
|
||
`apksigner`. About 150 lines, every step visible, no AGP, no Gradle
|
||
daemon holding 2.8 GB between builds. The pinned-CA constant becomes a
|
||
`build.rs` reading the same `certs/ca.pem` path.
|
||
- **[cargo-apk2](https://github.com/mzdk100/cargo-apk2)**: the maintained
|
||
successor to cargo-apk, and unlike it compiles `java_sources` /
|
||
`kotlin_sources` into the dex and declares multiple activities **and
|
||
services** with intent filters from `[package.metadata.android]`, with
|
||
per-profile keystores and optional `aapt2`. Exactly the shape needed;
|
||
the question is whether a third-party tool with one maintainer beats
|
||
150 lines we own.
|
||
- **cargo-apk / xbuild**: unmaintained and `NativeActivity`-only. No.
|
||
|
||
What Gradle would take with it: Android Lint (which found two real bugs
|
||
here, but in Kotlin that would no longer exist — with forty lines of Java
|
||
stubs there is little left for it to find), manifest merging, R8, and the
|
||
generated-source plumbing. What it gives back: one toolchain, `cargo`
|
||
end to end, and Dev Updater keeps calling `build-apk.sh` exactly as now.
|
||
**Recommendation: the xtask**, with cargo-apk2 read for the details it
|
||
already got right (v2 signing, `uses-feature`, ABI splits).
|
||
|
||
### The behaviours that are hard to get back
|
||
|
||
Reading the Compose code for what a replacement must be able to express,
|
||
rather than what it happens to look like:
|
||
|
||
1. **The transcript is one selectable body of text.** One
|
||
`SelectionContainer` around the whole lazy list, so a selection runs from
|
||
a reply into the tool output beneath it. The framework needs selectable
|
||
read-only rich text across many rows, with the platform's selection
|
||
handles and clipboard on the phone.
|
||
2. **Rich inline text**: markdown with links (one tap detector per text,
|
||
not a node per link), inline code chips drawn behind the text, tables
|
||
with wrapping cells and a sideways scroll, syntax-highlighted fences,
|
||
ANSI colour in tool output, Nerd Font icon glyphs. Needs a text layout
|
||
engine with spans, not just styled labels.
|
||
3. **A bottom-anchored virtualised list of variable-height rows**, paged in
|
||
both directions (800-event pages, `HISTORY_SCREENS` measured in
|
||
viewports), with a saved scroll anchor per session, "hold the edge
|
||
nearest the tap" when a row expands (`holdTopEdge`, done in the layout
|
||
pass so the wrong frame is never drawn), and rows keyed so that a run of
|
||
tool calls stays one row while it grows.
|
||
4. **The soft keyboard**: the composer resizes with the IME, the guard
|
||
against a stuck inset animation, drafts per session, autocorrect and
|
||
suggestions from the phone's own keyboard. This is where most Rust
|
||
frameworks fail on Android today; see below.
|
||
5. **Platform integration through the app model**: foreground service,
|
||
notifications, share sheet, deep link, Keystore, camera, back gesture,
|
||
edge-to-edge insets, local-network permission.
|
||
6. **Accessibility names on icon buttons**, which the bench scripts depend
|
||
on (`ui-trace` taps by label). A framework with no accessibility tree
|
||
also breaks the measuring rig.
|
||
7. **Measurable frames**: the debug render report, and a way to attribute
|
||
a frame's cost to a widget on the real phone.
|
||
|
||
## The two constraints that decide it
|
||
|
||
**1. Android text input.** Every framework built on `winit` inherits
|
||
winit's Android backend, and that backend cannot drive the soft keyboard
|
||
properly: the IME tracking issues
|
||
([#1823](https://github.com/rust-windowing/winit/issues/1823),
|
||
[#2766](https://github.com/rust-windowing/winit/issues/2766)) are open,
|
||
`ReceivedCharacter` is unimplemented on Android
|
||
([#2305](https://github.com/rust-windowing/winit/issues/2305)), and the
|
||
`android-activity` groundwork for editor actions only merged in February
|
||
2026 ([PR #214](https://github.com/rust-mobile/android-activity/pull/214))
|
||
with the winit half still to come. Composition, autocorrect and suggestions
|
||
need an `InputConnection` implemented on the Java side, which winit's
|
||
`NativeActivity`/`GameActivity` model does not offer. The frameworks that
|
||
type on Android today each wrote their own Java glue (Slint, Makepad), and
|
||
the one designed to do it the way Android intends is
|
||
[`android-view`](https://github.com/rust-mobile/android-view): a Rust
|
||
implementation of an Android `View`, with text input through
|
||
`InputConnection`, accessibility, touch, callbacks on the UI thread, usable
|
||
either as a whole app or embedded beside ordinary Android components. It is
|
||
marked WIP. Both Linebender (its Masonry demo lives in that repo) and
|
||
Robius/Makepad ([Robrix's release notes](https://github.com/project-robius/robrix/releases)
|
||
say Android lacks a "full" keyboard and they are integrating android-view
|
||
for it) are converging on it. **That makes android-view the phone-side
|
||
foundation whichever widget set sits on top**, and the first thing to
|
||
build and measure here.
|
||
|
||
**2. Rich, selectable text and a virtualised list.** Frameworks group by
|
||
their text stack:
|
||
|
||
- **Parley + Fontique + Vello** (Linebender): rich spans, selection and
|
||
editing utilities, IME support driven through `ui-events`, AccessKit text
|
||
properties ([Linebender 2026 Q1](https://linebender.org/blog/tmil-25/),
|
||
[parley](https://github.com/linebender/parley)). Used by Masonry/Xilem,
|
||
and by Blitz. Vello proper needs compute shaders; `vello_hybrid` (CPU
|
||
path processing, GPU compositing) is "roughly beta" and runs on GLES too,
|
||
and Vello CPU exists as a no-GPU fallback.
|
||
- **cosmic-text** (iced, egui optionally): good layout, but the widgets on
|
||
top decide selection. iced's `markdown` widget is not selectable
|
||
([discourse](https://discourse.iced.rs/t/markdown-widgets-text-should-be-selectable/1107)).
|
||
- **Slint's own**: `TextInput` with `read-only` is the selectable-text
|
||
trick; there is **no inline rich text at all** (issue
|
||
[#1325](https://github.com/slint-ui/slint/issues/1325), markdown request
|
||
[#6684](https://github.com/slint-ui/slint/issues/6684) both open). A
|
||
markdown transcript with links and code chips cannot be drawn.
|
||
- **Makepad's own**: GPU/SDF text, a `Markdown` widget and a virtualised
|
||
`PortalList` in `makepad-widgets`.
|
||
|
||
## Options
|
||
|
||
### A. Keep Compose, move the logic into a Rust core (uniffi)
|
||
|
||
A `client-core` crate (events shared with the server, API client, SSE,
|
||
transcript fold, cache, markdown model, highlighter, ANSI) exposed to
|
||
Kotlin through [uniffi](https://github.com/mozilla/uniffi-rs). Compose keeps
|
||
drawing. Desktop would be a second UI (iced or Compose Desktop) over the
|
||
same core.
|
||
|
||
- **For**: the logic and the wire types stop drifting from the server
|
||
today, with tests in one language. Incremental and always shippable.
|
||
- **Against**: it is not what was asked for. The 13,000 lines of UI stay
|
||
Kotlin, the desktop app shares no UI code, and the `:link` Kotlin module
|
||
stays. uniffi's Kotlin Multiplatform bindings are a
|
||
[community fork](https://github.com/UbiqueInnovation/uniffi-kotlin-multiplatform-bindings);
|
||
the Android-only bindings are Mozilla's and solid.
|
||
- **Verdict**: not the destination, but **step one of every other option**
|
||
is building this crate, so it costs nothing to keep it as the fallback.
|
||
|
||
### B. Slint
|
||
|
||
Rust on Android is officially supported (minSdk 26, `android-activity`
|
||
backend, own Java IME glue, safe areas and keyboard insets since 1.15,
|
||
Skia renderer needs `clang`). Royalty-free licence requires disclosing
|
||
Slint use; GPLv3 otherwise. UI is a separate `.slint` DSL, not Rust.
|
||
|
||
- **Against**: no rich inline text (see above), so the transcript cannot be
|
||
drawn as it is today; the UI language is not Rust, which forfeits the
|
||
"compiler catches it" motivation for the half of the code that is UI.
|
||
- **Verdict**: rejected on rich text alone.
|
||
|
||
### C. iced
|
||
|
||
Elm-style, Rust-only widgets, desktop-first, `winit` + `wgpu`. Has a
|
||
`markdown` widget and `rich_text` with links. The maintainer states mobile
|
||
is a non-goal ([iced](https://github.com/iced-rs/iced)); a community
|
||
Android example exists and its author could not get the soft keyboard
|
||
working, patched widgets for touch, and notes no accessibility
|
||
([HN thread](https://news.ycombinator.com/item?id=46350641)). Markdown is
|
||
not selectable; `scrollable` is not virtualised.
|
||
|
||
- **Verdict**: a fine desktop toolkit and the one Iris named, but every
|
||
phone-side gap (IME, touch, accessibility, selection, virtualisation)
|
||
would be ours to build and maintain against a project that does not want
|
||
them. Not the shared framework.
|
||
|
||
### D. egui
|
||
|
||
Immediate mode, `winit`-based on Android, AccessKit integration,
|
||
selectable labels across a `Ui`. Repaints only on input by default, so
|
||
battery is not the immediate-mode worry. Android IME is blocked on winit
|
||
([discussion](https://github.com/emilk/egui/discussions/2053)); the
|
||
workaround is an in-app virtual keyboard, which is exactly the
|
||
non-native keyboard to avoid. Variable-height virtualised lists are manual
|
||
(`show_rows` assumes uniform heights). Looks like egui, not Material.
|
||
|
||
- **Verdict**: workable on desktop, wrong on the phone for the same reason
|
||
as iced, plus a look that would need a full custom style.
|
||
|
||
### E. Makepad
|
||
|
||
GPU-rendered, hybrid retained/immediate, `live_design!` DSL with hot
|
||
reload, MIT, 1.0 in 2025 ([makepad](https://github.com/makepad/makepad)).
|
||
Ships Android apps today with its own Java glue; Robrix (a Matrix chat
|
||
client, the closest analogue to this app) is its reference application on
|
||
Android, iOS and desktop. Has `Markdown`, `PortalList` (virtualised),
|
||
`TextInput`. Robrix reports the Android keyboard is not "full" and is moving
|
||
to android-view for it; the README says non-standard targets "may require
|
||
minor fixes".
|
||
|
||
- **For**: the only option that already ships a chat-shaped app on Android
|
||
and desktop from one codebase, with the widgets this app needs.
|
||
- **Against**: the DSL is its own language with its own shader-based
|
||
styling, so a large part of the UI would not be checked by rustc; the
|
||
rendering model (SDF everything) is a different world from Compose's,
|
||
and selection across a `Markdown` widget is unverified.
|
||
- **Verdict**: **rejected 2026-09-04** — Iris does not want a DSL. Kept
|
||
here so its Android keyboard status stays a data point about
|
||
android-view, not as an option.
|
||
|
||
### F. Masonry / Xilem on android-view (Linebender)
|
||
|
||
Retained widget tree (Masonry) with a reactive view layer (Xilem) that
|
||
reads like Compose; Rust all the way down; Vello, Parley, Fontique,
|
||
AccessKit, `ui-events`. Widgets include `Prose` (selectable read-only rich
|
||
text), `TextArea`, `VirtualScroll`, and this year `Svg`, `Split`,
|
||
`CollapsePanel`, a new layout system, and IME through `ui-events`
|
||
independent of winit. `masonry_android_view` exists in the android-view
|
||
repo and is "not yet generally usable"; Xilem calls itself experimental.
|
||
Desktop runs on winit. Vello needs a compute-capable GPU or falls back to
|
||
`vello_hybrid`/CPU.
|
||
|
||
- **For**: the only stack where every hard behaviour above maps onto a
|
||
component designed for it: selection and rich text (Parley/Prose),
|
||
virtualised variable heights (`VirtualScroll`), native IME
|
||
(android-view's `InputConnection`), accessibility (AccessKit, now with an
|
||
Android crate), one Rust widget language on both platforms. The team is
|
||
the one writing the Android integration everyone else is adopting.
|
||
- **Against**: pre-1.0 with API churn each release; a small team; no
|
||
Material widget set, so every control's look is ours; some of the pieces
|
||
(`masonry_android_view`, `vello_hybrid`) are explicitly unfinished. Being
|
||
early means fixing things upstream ourselves, which Iris said is
|
||
acceptable.
|
||
- **Verdict**: **the option to try first**, because it is the only one
|
||
whose gaps are "not finished yet" rather than "not designed for this".
|
||
|
||
### G. iris — the in-house library, and what "from scratch" means here
|
||
|
||
[cat16/iris](https://github.com/cat16/iris), read 2026-09-04 from the one
|
||
public commit (2026-01-31, "portfolio copy"; ~8,700 lines in `core`,
|
||
`macro` and the crate itself). Retained-mode widgets stored outside the
|
||
render tree, `wgpu` 28 directly, `winit` 0.30, `cosmic-text` 0.16 (parley
|
||
since I1), a
|
||
relative-anchor-plus-offset layout with `rest()` and `rel()` lengths, a
|
||
postfix builder API (`rect(..).radius(30).on(CursorSense::click(), ..)
|
||
.sized(..).align(..)`), events handled where the widget is declared, and
|
||
a single-threaded context passed explicitly — all of which reads like this
|
||
codebase's own rules. There is text editing (`widget/text/edit.rs`),
|
||
images, masks, spans and stacks; the TODO names text resizing as
|
||
per-frame slow and scaling as unsolved. It requires **nightly** (fourteen
|
||
`#![feature]` gates as vendored, among them `const_trait_impl`,
|
||
`unboxed_closures`, `portable_simd`, `associated_type_defaults`; eleven
|
||
after I0b and I1 — see those steps for the current list). Desktop only; no
|
||
Android surface, no IME, no accessibility tree, no virtualised list, no
|
||
rich-text selection.
|
||
|
||
**That list is the work, and some of it is done.** As of 2026-09-04 it
|
||
builds on a pinned nightly, runs on this machine's GPU, has parley and a
|
||
glyph atlas, and `iris-core` cross-compiles to Android. What it still
|
||
lacks from the list above is the Android surface, the IME bridge, the
|
||
accessibility tree and the virtualised list — I2, I3 and I4.
|
||
|
||
**iris is not a candidate to be tested as it stands. It is the in-house
|
||
library** (Iris, 2026-09-04): "essentially a good start to a rewrite from
|
||
scratch", to be maintained and extended by the sessions working here.
|
||
So the list above of what it lacks is a **work list, not a score**. When
|
||
the app needs something iris does not have, the answer is to build it
|
||
into iris. The layer iris has is the widget and layout layer; the layers
|
||
it needs are the same ones Masonry gets from android-view, Parley and
|
||
AccessKit, and there is no reason iris cannot sit on those same
|
||
foundations rather than reinvent them — the surface, the keyboard bridge
|
||
and the accessibility tree are platform plumbing, not a framework's
|
||
identity. The text stack was the first real design decision in that work,
|
||
and it is settled: **Parley, with a glyph atlas** (I1, 2026-09-04).
|
||
|
||
Two things to carry into that work honestly. Nightly is the opposite of
|
||
"holds up long term": a build that breaks on a toolchain update, on the
|
||
machine Dev Updater builds on, unattended. **Done in I0b** —
|
||
`iris/rust-toolchain.toml` pins `nightly-2026-09-03` — and the gate list
|
||
lives with I0b and I1, to be retired as they stabilise or are designed
|
||
around; it is down from fourteen to eleven. And a one-person framework
|
||
carries every gap itself, which is what Iris said she is willing to do.
|
||
|
||
"From scratch" therefore means iris, not a fourth thing. Masonry stays in
|
||
the plan as the **yardstick and the fallback**: building its demo and its
|
||
version of the transcript screen first says what a finished stack costs
|
||
on this hardware, proves android-view before iris depends on it, and
|
||
gives a comparison that is measured rather than remembered.
|
||
|
||
Not considered further: **GPUI** (Zed) mobile is a community fork that
|
||
depends on unpublished crates; **Dioxus/Blitz** is excluded by Iris (its
|
||
native renderer is Parley/Vello under HTML semantics, and the earlier
|
||
`tdep-survey/app-dioxus` spike parked it on a `vello_hybrid` stroke bug and
|
||
shipped the WebView); **Compose Multiplatform Desktop** would give a desktop
|
||
app for nothing but in Kotlin, which is the opposite direction.
|
||
|
||
### Weight and debug builds
|
||
|
||
Iris remembers the Linebender stack being slow in debug. What is behind
|
||
that is the dependency graph — Vello, wgpu, Parley, Fontique, Skrifa —
|
||
running unoptimised on the CPU side (path encoding, shaping), not the
|
||
widget layer. Xilem's own advice is only `split-debuginfo = "unpacked"` to
|
||
keep `target/` small; the fix everyone with this shape of dependency tree
|
||
uses is to optimise dependencies while leaving the app crate at `opt-level
|
||
= 0`:
|
||
|
||
[profile.dev.package."*"]
|
||
opt-level = 2
|
||
|
||
**Measured 2026-09-04, and it is not the widget layer.** iris's own
|
||
graph (wgpu + winit + cosmic-text at the time) built cold in **43s** with
|
||
a 2.1 GB `target/`, and **1m46s** with a 1.5 GB `target/` under the
|
||
profile above — so the knob costs build time and saves disk here, and
|
||
plain debug was never the problem. Masonry's graph is the one with Vello,
|
||
Parley, Fontique and Skrifa in it, and E1 gives the number that matters
|
||
for it: **`libmain.so` is 181 MB in debug and 11 MB in release.** That
|
||
size is also a correctness issue rather than only a weight one — a debug
|
||
build labels its Vulkan objects, and the emulator's driver segfaults in
|
||
`SetDebugUtilsObjectNameEXT` when it does. Runtime cost of the profile
|
||
knob is still unmeasured; resident memory and the frame cost of an
|
||
800-event page want E2. Vello proper needs compute shaders and carries a
|
||
large shader set; `vello_hybrid` is lighter and Masonry can now render
|
||
through either (or Vello CPU) via its `imaging` abstraction, so "keep it
|
||
light" has a knob inside the same stack.
|
||
|
||
## Recommendation
|
||
|
||
1. **Build `client-core` now, whatever the framework** (done 2026-09-04, see `CLIENT_CORE.md`). A Rust crate holding
|
||
the event model (shared with `server/` as one crate, ending the
|
||
`Events.kt` mirror), the API and SSE clients, the transcript fold, the
|
||
cache, the markdown block model, the highlighter and the ANSI parser,
|
||
with the existing JVM tests ported. It is the part of the app that is
|
||
already tested, already logic, and already duplicated on the server.
|
||
2. **One foundation, two widget layers.** The platform plumbing is shared
|
||
whichever way the decision goes: android-view for the Android surface,
|
||
keyboard and accessibility bridge; `wgpu` for the GPU; AccessKit for
|
||
names; winit on the desktop. On top of it, **Masonry as the yardstick**
|
||
(E1, E2) and **iris as the thing being built** (I0–I5), both aimed at
|
||
the same transcript screen with the same pass conditions.
|
||
3. **Decided, 2026-09-05: iris.** Iris made the call from the host-GPU
|
||
comparison in I5's box (iris p50 15.0 ms, Compose 20.0 ms, same
|
||
content, same emulator) and from what E1/E2 found Masonry cannot do on
|
||
Android today (touch scroll, per-span rich text, cross-row selection,
|
||
the keyboard bridge). `DECISIONS.md` has the entry. The paragraph
|
||
below is what the decision was to be made from, kept for the record.
|
||
**Decide when the transcript screen exists in both**, from the
|
||
measurements, and record the decision here with the numbers. If iris
|
||
carries the screen within the Compose baseline, it is the app's
|
||
framework and Masonry was the calibration. If it does not, the
|
||
measurement says which parts of Masonry to adopt underneath it.
|
||
|
||
**Still not decidable by a render-time number, 2026-09-05 (updated) —
|
||
what's missing, named rather than guessed at, and now for a different
|
||
reason than before.** E2 found Masonry's own scroll gesture path
|
||
absent on Android entirely (its box, "measurable frames") — that has
|
||
not changed. I5's Android integration is now built and confirmed
|
||
working (real server, real scrolling, real touch-drag pan, tap-by-name
|
||
— I5's own box, "Measurements taken"), so the earlier blocker ("no
|
||
cdylib/Gradle shell exists for this screen") is gone. What replaced
|
||
it: **`dumpsys gfxinfo`, the tool `transcript-bench.sh` and this
|
||
recommendation both assumed would give the comparison, cannot see a
|
||
`SurfaceView`'s own GPU-drawn frames at all** — it instruments
|
||
Android's ordinary View/Skia drawing pipeline, which a `wgpu`-rendered
|
||
`SurfaceView` (iris's whole approach) bypasses entirely. Confirmed
|
||
0 frames reported across a 24-swipe gesture loop that visibly scrolled
|
||
the screen (screenshots differ), and a `dumpsys SurfaceFlinger
|
||
--latency` fallback returned no per-frame history either (just the
|
||
display's refresh period) on this Android version's BLAST compositor.
|
||
The Compose side of the same loop *did* produce a real number under
|
||
identical conditions (`EMU_GPU=software`, same emulator, same session):
|
||
**8.96% janky frames, 99th percentile 150ms.** So this is now a
|
||
one-sided number, not a missing one — the number needed to close item 3
|
||
is a render-time report from **iris itself** (the equivalent of the
|
||
Compose app's in-app copy-button report `transcript-bench.sh` already
|
||
reads), which does not exist yet and is real, scoped follow-on work
|
||
(frame timing inside `iris_core::render`, exposed the way `AccessTree`
|
||
or `UiRenderState::take_counters` already are) rather than a rerun of
|
||
anything above. Until it exists, the decision still rests on the
|
||
structural findings both sides *did* produce, now joined by a
|
||
functional one: Masonry cannot do cross-row selection or per-span
|
||
inline rich text at all today (E2's `grep -rln`, zero hits, cited in
|
||
its own box); iris does both (I5's `SpanStyle` and `selection.rs`) and
|
||
its touch-scroll now works end-to-end on a real device, not just
|
||
programmatically (I3's benchmark plus I5's on-device screenshot
|
||
evidence) — three structural points and one functional one in iris's
|
||
favour, still with no opposing *or* supporting render-time measurement
|
||
on either side.
|
||
|
||
**Update, 2026-09-05, later the same day: iris now has a render-time
|
||
report of its own, and a real number from it, but not yet the clean
|
||
comparison item 3 needs.** `iris_core::FrameReport` (new,
|
||
`iris/core/src/render/frame_report.rs`) is exactly the follow-on work
|
||
named above — a per-frame wall-time ring exposed as two named on-screen
|
||
controls, unit tested (6 tests over the ring/percentile math). Driven
|
||
for real against a real touch-drag on this checkout's emulator, it
|
||
read `frames=34 janky%=61.76 p50=26.5ms p90=48.0ms p99=98.1ms
|
||
worst=98.1ms` — a genuine measurement through iris's own render path,
|
||
not inferred. **It is not yet the comparable number**, for a newly
|
||
found and separately named reason (I5's own box, "Update, 2026-09-05,
|
||
later the same day"): gestures against this checkout's
|
||
`EMU_GPU=software` emulator intermittently delivered zero touch input
|
||
during this pass — reproducible, but not yet root-caused past one
|
||
candidate (the emulator's own software rasterisation measured at ~78%
|
||
of a CPU core continuously, a plausible source of input backlog, not
|
||
yet confirmed with a sampler running during a failing gesture). So
|
||
item 3 still cannot be closed by a clean number, now for a narrower and
|
||
more tractable reason than before: the instrumentation exists and
|
||
works, and what remains is making the emulator rig deliver touch input
|
||
reliably enough to run the comparable loop. **What Iris needs to
|
||
weigh, updated**: whether "iris works, Masonry's Android scroll path is
|
||
absent entirely, and iris's own frame-timing report is real and
|
||
working" is enough to decide without the final clean number, or
|
||
whether to wait for the touch-delivery investigation above — still a
|
||
product/tradeoff call, left to her (`DECISIONS.md`'s DEFERRED item,
|
||
updated with this session's numbers).
|
||
4. Then the shell (E3), the desktop window (E4) and the packaging (E5),
|
||
which do not depend on the choice.
|
||
|
||
## Experiments, in order
|
||
|
||
Each has a pass condition that is a measurement in this clone. The rig
|
||
matters: this emulator runs `-gpu host` with **host Vulkan switched off**
|
||
(`GPU_HOST_FEATURES` in `emulator-tools`, a gfxstream/Venus gap), so inside
|
||
the guest a `wgpu` app gets GLES, not Vulkan; the earlier Dioxus spike also
|
||
needed `WGPU_GLES_MINOR_VERSION=1` for compute shaders and found `wgpu`'s
|
||
Android backend wants API 26 (a libc symbol). The real phone has Vulkan.
|
||
Per the standing rule, a rig limit is something to fix before it is
|
||
accepted.
|
||
|
||
- [x] **E0 — toolchain (done 2026-09-04).** Installed under the
|
||
user-owned SDK: **NDK r29 (`29.0.14206865`)**, 2.4 GB at
|
||
`~/Android/Sdk/ndk/29.0.14206865`, the newest stable — r30 is still
|
||
at rc.3. **cargo-ndk 4.1.2**. Verified by cross-compiling a scratch
|
||
`cdylib` to both ABIs: `file` reports "for Android 26, built by NDK
|
||
r29 (14206865)" for `aarch64-linux-android` and
|
||
`x86_64-linux-android`. Two things to know at the call site.
|
||
**cargo-ndk 4's API-level flag is `-P`, not `-p`** — `-p` is now
|
||
passed through to cargo as `--package`, so the old
|
||
`cargo ndk -t arm64-v8a -p 26` panics with `unknown package: 26`
|
||
*and dumps the whole environment to stdout* as a bug report, which is
|
||
worth not doing in a log somebody might paste. And the Android
|
||
targets were installed for **stable** only; the pinned nightly needs
|
||
its own, which `iris/rust-toolchain.toml` now declares.
|
||
- [x] **E1 — android-view's Masonry demo on this emulator (2026-09-04).**
|
||
It builds, renders on the GPU through Vulkan, exposes its
|
||
accessibility tree, and **the phone's own keyboard types into its
|
||
editor** — but with **no autocorrect and no suggestions**. Ticked
|
||
because everything it was meant to establish is established,
|
||
including the one gap; that gap is now E2's problem and I2's.
|
||
|
||
*Build.* `~/src/android-view` at `bec6c62`, x86_64 rather than the
|
||
README's arm64 because that is what this emulator is:
|
||
`cargo ndk -t x86_64 -P 26 -o masonry-app/src/main/jniLibs/ build -p
|
||
android-view-masonry-demo --release`, then
|
||
`./gradlew :masonry-app:assembleDebug`. **`libmain.so` is 181 MB in
|
||
debug and 11 MB in release** — the loudest single number about
|
||
Vello's dependency graph, and the reason the release build matters
|
||
for more than speed.
|
||
|
||
*Renderer.* wgpu takes **Vulkan**, and the emulator log confirms it
|
||
from the other side: `Created VkDevice ... for application:'wgpu'`.
|
||
Two things were needed. The emulator must be given Vulkan at all —
|
||
`-feature Vulkan` with `VK_DRIVER_FILES` pointing at the SDK's
|
||
`vk_swiftshader_icd.json`, **plus `-no-snapshot-load`**, which is the
|
||
piece this file had flagged as untested: without a cold boot the
|
||
guest keeps the snapshot's old GPU config and `cmd gpu vkjson`
|
||
reports zero devices however the host is configured. And the native
|
||
library must be **release**: a debug build calls
|
||
`SetDebugUtilsObjectNameEXT` to label its image views, and the
|
||
emulator's own guest driver (`vulkan.ranchu.so`) segfaults inside it.
|
||
On GLES, with no Vulkan available, it instead fails
|
||
`Surface::configure` with "Invalid surface" — untriaged, since the
|
||
Vulkan path works and Vello wants compute shaders anyway.
|
||
|
||
*Accessibility works*, which E2's condition 6 and every bench script
|
||
depend on. `ui-trace` reads Masonry's AccessKit tree: "Add task"
|
||
arrives as a named `Button`, the editor as an `EditText` node. So
|
||
tap-by-name works against a Masonry screen for any control carrying
|
||
a name; the demo's editor carries none, which is the demo's omission
|
||
rather than the framework's.
|
||
|
||
*The keyboard: real input yes, suggestions no.* Tapping the editor
|
||
opens the actual soft keyboard (`mInputShown=true`, Gboard), and
|
||
tapping its keys types into Masonry — "teh" typed key by key, with a
|
||
caret. What does **not** appear is Gboard's suggestion strip. The
|
||
control is what makes that a finding rather than an impression: the
|
||
**same three key taps in the Settings app's search field, on the same
|
||
device in the same session, produce "teh | the | yeh"**. So the strip
|
||
works here and android-view's editor is not asking for it — most
|
||
likely the `EditorInfo` its `InputConnection` reports. That matches
|
||
Robrix's report that the Android keyboard is not yet "full", and it
|
||
is the single most important thing to fix or fund upstream, because
|
||
composition, autocorrect and suggestions are exactly what the
|
||
composer in this app needs and exactly what `winit` cannot do at all.
|
||
|
||
**Cause found 2026-09-04, and it is Masonry's, not android-view's.**
|
||
`~/src/android-view/masonry/src/lib.rs:531` is
|
||
|
||
fn as_input_connection(&mut self) -> Option<&mut dyn InputConnection> {
|
||
// TODO
|
||
None
|
||
}
|
||
|
||
so the Masonry demo has **no `InputConnection` at all**; `RustView`
|
||
returns null from `onCreateInputConnection` and the IME falls back to
|
||
dispatching raw key events, which is exactly the behaviour observed —
|
||
keys arrive, composition does not exist, so there is nothing for
|
||
Gboard to suggest against. It is not a wrong `EditorInfo`, and the
|
||
guess above that it was is withdrawn.
|
||
|
||
android-view's **own** demo (`demo/src/lib.rs`, packaged by `app/`)
|
||
implements the whole trait against a parley editor and asks for
|
||
`INPUT_TYPE_CLASS_TEXT | CAP_SENTENCES | AUTO_CORRECT | MULTI_LINE`
|
||
with `IME_FLAG_NO_FULLSCREEN | NO_EXTRACT_UI | NO_ENTER_ACTION`
|
||
(`demo/src/lib.rs:588`). So the capability is present in the layer
|
||
iris would sit on, and the 30-odd method `InputConnection` trait in
|
||
`src/ime.rs` — `set_composing_text`, `set_composing_region`,
|
||
`finish_composing_text`, `text_before_cursor`, `cursor_caps_mode`,
|
||
`request_cursor_updates`, and `InputMethodManager::update_selection`
|
||
to push the selection back — is the full surface an IME needs.
|
||
**This changes what I2 costs**: the IME bridge is a trait to
|
||
implement over iris's parley editor, not a gap to fund upstream. It
|
||
also means E2 inherits Masonry's TODO, so a Masonry transcript will
|
||
have the same dead composer until somebody fills that in.
|
||
|
||
**Measured on the emulator, same session, same device.** Built
|
||
android-view's own demo — `cargo ndk -t x86_64 -P 26 -o
|
||
app/src/main/jniLibs/ build -p android-view-demo --release`, then
|
||
`./gradlew :app:assembleDebug`, installed with `ANDROID_SERIAL=$(emu
|
||
serial)` — and tapped into its editor. `dumpsys input_method` reports
|
||
`mInputShown=true` with `mServedView=…viewdemo.DemoView`, and the
|
||
screenshot shows **Gboard's suggestion strip populated with "dolor |
|
||
Dolores | door"**: the caret had landed inside the word *dolor* in
|
||
the demo's lorem ipsum, and Gboard read that word out of the Rust
|
||
editor through `text_before_cursor`. So on this emulator, through
|
||
android-view, a parley editor gets a real IME with real suggestions
|
||
drawn from its own buffer. That is the bar E1 could not reach and the
|
||
bar I2 is written against, and it is now known to be reachable.
|
||
|
||
*One crash seen once — reproduced and diagnosed 2026-09-04.* With an
|
||
accessibility client attached the app aborted, stack:
|
||
`android_view::view::do_frame` → `CallbackCtx::finish` →
|
||
`accesskit_android::event::QueuedEvents::raise` →
|
||
`send_completed_event` → `unwrap()` on `Err(JavaException)`.
|
||
android-view builds `panic = "abort"`, so a JNI call that throws
|
||
takes the process. Two later `ui-trace record` runs left the app
|
||
alive, so the trigger looked narrower than "a client is attached".
|
||
|
||
**It is the opposite of "a client is attached": it is a client
|
||
having *detached*.** `accesskit_android`'s `State` enum
|
||
(`adapter.rs:161` in 0.4.0, `:192` in 0.8.0) is
|
||
`Inactive | Placeholder | Active`, and **nothing ever moves it back
|
||
to `Inactive`**. A client — `ui-trace`, which is uiautomator — calls
|
||
into the node provider once, `get_or_init_tree` promotes the adapter
|
||
to `Active`, and it stays there for the life of the process. Every
|
||
later change then returns `Some(QueuedEvents)`, `raise` calls
|
||
`ViewParent.requestSendAccessibilityEvent`, and that reaches
|
||
`AccessibilityManager.sendAccessibilityEvent`, which on the main
|
||
looper **throws `IllegalStateException("Accessibility off. Did you
|
||
forget to check that?")` when accessibility is disabled**. jni-rs
|
||
returns `Err(JavaException)`, `send_completed_event` unwraps it, and
|
||
`panic = "abort"` ends the process.
|
||
|
||
*The controlled run*, one process (pid 4085), `settings get secure
|
||
accessibility_enabled` = 0 throughout:
|
||
|
||
- tapped the editor and typed three keys with `adb shell input tap`,
|
||
no client ever attached — **alive**;
|
||
- one `ui-trace record -d 800` with no gesture at all, then two
|
||
seconds' wait — **still alive** (the queue was raised while the
|
||
client was still there);
|
||
- the very next three keystrokes, same process — **aborted**, same
|
||
stack.
|
||
|
||
So the failure is not the recording; it is the **first thing that
|
||
changes the accessibility tree after a recording ends**. That makes
|
||
it a standing hazard for this project rather than an oddity:
|
||
`transcript-bench.sh`, `stream-bench.sh` and `bench-lib.sh`'s
|
||
tap-by-name all attach and detach uiautomator, so on a Rust app the
|
||
typing or scrolling *after* a bench run is what dies, several
|
||
seconds away from anything that looks like a cause.
|
||
|
||
**Still present at head**: 0.8.0 is the newest `accesskit_android`
|
||
(the demo resolves 0.4.0) and both the unconditional `unwrap` in
|
||
`send_completed_event` and the one-way `State` are unchanged there,
|
||
so upgrading is not the fix. **Our mitigation for I2/I4 is a gate we
|
||
own**: ask `AccessibilityManager.isEnabled()` before calling
|
||
`raise`, and drop the events when it says no. Worth reporting
|
||
upstream as well — the honest fix is for `raise` to clear a pending
|
||
exception rather than unwrap it, since a view can be detached or
|
||
accessibility switched off between queueing and raising no matter
|
||
who is calling.
|
||
|
||
*A rig trap that cost a wrong conclusion.* Several bounded runs were
|
||
given `sleep N; emu down` watchdogs, and one armed for an earlier
|
||
experiment fired in the middle of a later one — the app vanished, adb
|
||
hung, and it read exactly like the Vulkan path crashing. It was not.
|
||
A watchdog must be scoped to the process it guards (`kill $pid`, with
|
||
the pid captured at launch) rather than to whatever AVD is running
|
||
when it wakes, and only one should be armed at a time.
|
||
|
||
- [x] **E2 — a transcript in Masonry (2026-09-05).** Built and run on this
|
||
emulator. It found the thing it was measuring for: a framework-wide
|
||
gap that blocks the bench comparison itself, plus a full accounting
|
||
of the seven behaviours. Ticked on E1's own precedent -- "everything
|
||
it was meant to establish is established, including the one gap."
|
||
|
||
*Where it lives.* `~/src/android-view/e2-transcript` (new workspace
|
||
member, `crate-type = ["cdylib"]`, `lib.name = "main"`), packaged by
|
||
a new Gradle module `~/src/android-view/e2-app` copied from
|
||
`masonry-app` (`E2View`/`E2Activity`, package
|
||
`org.linebender.android.e2transcript`). Neither is committed to
|
||
`ai-app-2` or pushed anywhere -- same as E1, this is a local
|
||
experiment against the `xilem` commit
|
||
`e14ba3a5f9461b403cb30d95826187fba7f6924b` and the `android-view`
|
||
commit `bec6c62a96cef8239b0fd7fedeef9b184d02e3a1`, reproducible from
|
||
the commands below rather than from a remote.
|
||
|
||
*Build.* Depends on `client-core`/`event-model` from this checkout by
|
||
path (`../../../repos/ai-app-2/client-core`) -- real code, not a
|
||
reimplementation: `ApiClient`/`UreqTransport` for the HTTP fetch,
|
||
`fold_event`/`group_tool_runs` for the transcript fold, exactly what
|
||
the app itself would use. The sandbox CA and a session's URL/token
|
||
are baked in at build time via `env!()`/`include_bytes!()`, the same
|
||
pattern the real APK uses to pin its CA (AGENTS.md), since this is a
|
||
throwaway screen with no enrollment flow:
|
||
|
||
cd app && ./ui-sandbox.sh start # prints the port and token
|
||
sid=$(./ui-sandbox.sh spawn e2test)
|
||
./ui-sandbox.sh send "$sid" @/tmp/big.md # markdown content
|
||
./ui-sandbox.sh send "$sid" "/tools 3" # a grouped tool run
|
||
cd ~/src/android-view
|
||
E2_SANDBOX_URL=https://10.0.2.2:<port> \
|
||
E2_SANDBOX_TOKEN=<token> \
|
||
E2_SANDBOX_SESSION=<sid> \
|
||
E2_CA_PEM_PATH=$HOME/.config/ai-app/certs/ca.pem \
|
||
cargo ndk -t x86_64 -P 26 -o e2-app/src/main/jniLibs/ \
|
||
build -p e2-transcript --release
|
||
ANDROID_HOME=~/Android/Sdk ./gradlew :e2-app:assembleDebug
|
||
|
||
**`libmain.so` is 13.5 MB release** (E1's masonry-demo was 11 MB;
|
||
the difference is `client-core`'s `ureq`/`rustls` stack, which E1's
|
||
demo does not link). Release native lib, debug Gradle variant --
|
||
the combination E1 found necessary (a debug build's
|
||
`SetDebugUtilsObjectNameEXT` segfaults this emulator's Vulkan
|
||
driver).
|
||
|
||
*Emulator.* This checkout's own AVD (`ai-app-2`, not `ai-app`, which
|
||
another session already had up), booted with Vulkan the way E1
|
||
established: `GPU_HOST_FEATURES="-feature Vulkan"
|
||
VK_DRIVER_FILES=$HOME/Android/Sdk/emulator/lib64/vulkan/vk_swiftshader_icd.json
|
||
emu up`. `adb shell cmd gpu vkjson` confirmed a device before
|
||
anything was installed. Torn down with `emu down` at the end of this
|
||
session (see "Where things stand" below for the exact state left).
|
||
|
||
*What it does.* `fetch_rows()` (`e2-transcript/src/lib.rs`) makes one
|
||
blocking `fetch_transcript_page(session, None, 800, false)` call
|
||
before the widget tree exists, folds every line through
|
||
`client-core`, and groups tool runs -- 854 real events from a mixed
|
||
sandbox session (markdown paragraphs/headings/fences plus a
|
||
three-call tool run from the echo driver's `/tools 3`). Each
|
||
`TranscriptRow` becomes one `VirtualScroll<dyn Widget>` child,
|
||
built lazily from `VirtualScrollAction` the way
|
||
`masonry_winit/examples/virtual_fizzbuzz.rs` does it. **This is a
|
||
deliberate scope cut from "page 800 events" as live paging**: all
|
||
854 rows' content is fetched once, and what `VirtualScroll` pages is
|
||
*widget construction*, not a second round of network calls per
|
||
scroll -- wiring a background-thread fetch woken across the JNI
|
||
boundary (the way I2's `ssh.rs` attach-and-call works) is real work
|
||
this experiment did not need to answer its question. `markdown.rs`
|
||
is a `pulldown-cmark` event-stream walk into a small `Block` enum
|
||
(`Text`/`Heading`/`Code`), with its own module doc explaining the
|
||
one real ceiling it hit (below).
|
||
|
||
*Verification.* `cargo fmt -p e2-transcript -- --check` clean.
|
||
`cargo ndk -t x86_64 -P 26 clippy -p e2-transcript --all-targets`:
|
||
**zero warnings in this crate** (the only clippy output at all is
|
||
from `android-view` itself, a vendored dependency this experiment
|
||
does not own). `cargo ndk -t x86_64 -P 26 test -p e2-transcript
|
||
--lib` (run against the emulator, since the crate is
|
||
`cfg`-unconditionally Android): 1 test, `markdown::parse`'s block
|
||
split, passing. No larger test surface exists to port -- this is a
|
||
throwaway screen, not a library, matching AGENTS.md's "match the
|
||
codebase's testing posture."
|
||
|
||
*Screenshots* (all `/tmp`, not committed -- see the standing rule
|
||
against transcripts leaving this repo, which applies equally to a
|
||
screenshot of one): `e2-screenshot2.png` first real content;
|
||
`e2-expand.png` a tool row expanded with its top edge held;
|
||
`e2-markdown.png` a heading/bold/italic/inline-code/link/fenced-code
|
||
message (the "You said: ## A Heading" line is the sandbox's echo
|
||
driver prefixing the literal input text before the `##`, which
|
||
keeps `pulldown-cmark` from recognising it as a heading -- a fixture
|
||
artifact, not a finding about Masonry).
|
||
|
||
**The seven behaviours, each shown or given a sourced reason:**
|
||
|
||
1. **One selectable body of text spanning rows -- not possible, and
|
||
it is a real ceiling, not an oversight.** `Prose` wraps exactly
|
||
one `TextArea<false>`, which wraps exactly one
|
||
`parley::PlainEditor` (`masonry/src/widgets/prose.rs`: "Note that
|
||
copying is not yet implemented"). Selection lives entirely inside
|
||
that one editor: `TextArea::on_pointer_event`
|
||
(`masonry/src/widgets/text_area.rs:414-459` in the pinned `xilem`
|
||
commit) captures the pointer on `Down`
|
||
(`ctx.capture_pointer()`) and drives `self.editor`'s own
|
||
`extend_selection_to_point` on `Move` -- there is no code path,
|
||
in `masonry_core` or `masonry`, that extends a selection into a
|
||
second widget's editor. A drag that starts in one row's `Prose`
|
||
and continues into the next is still that first row's own
|
||
`PlainEditor` being asked for a point outside its bounds; it
|
||
cannot reach the second row's text. Confirmed by reading, not
|
||
guessed at: there is no `SelectionContainer`-shaped type
|
||
anywhere in `masonry`, `masonry_core` or `xilem` (checked with
|
||
`grep -rln "SelectionContainer\|cross.widget.*selection"`, zero
|
||
hits).
|
||
2. **Rich inline text -- block-level yes, inline no, and both for
|
||
the same reason.** `TextArea::edit_styles()` returns one
|
||
`&mut StyleSet<T>` for the whole editor
|
||
(`masonry_core/src/core/text.rs:29-32` defines `StyleSet` as
|
||
`parley::StyleSet<BrushIndex>`, applied editor-wide); the type's
|
||
own comments say why nothing finer exists yet:
|
||
`// TODO: RichTextInput 👀` and
|
||
`// TODO: Support for links - https://github.com/linebender/xilem/issues/360`
|
||
at `masonry/src/widgets/text_area.rs:43-44`. So bold, italic,
|
||
inline code and a link *inside one paragraph* cannot each carry
|
||
their own style without leaving `TextArea` for a hand-rolled
|
||
`parley::Layout` (which loses selection, the caret and copy,
|
||
since those live inside `PlainEditor` specifically). What **is**
|
||
real: each markdown block is its own `Prose`, so a heading is a
|
||
bigger font and a fenced code block is monospace, screenshotted
|
||
in `e2-markdown.png` -- block-level style works because it is
|
||
block-level *widgets*, not a rich-text API. Tables and per-token
|
||
syntax colour inside a fence hit the identical ceiling (both are
|
||
per-range styling) and were not attempted for the same reason.
|
||
`markdown.rs`'s degraded rendering (backticks kept literally,
|
||
`[text](url)` shown as `text (url)`) is the honest fallback,
|
||
documented at the point it is produced.
|
||
3. **Bottom-anchored virtualised list, paged, hold-top-edge on
|
||
expand -- mostly shown, with one real gap in the anchor API.**
|
||
`VirtualScroll<dyn Widget>` holds all 854 folded rows;
|
||
`overwrite_anchor` before swapping a tool row's widget for its
|
||
expanded/collapsed version is exactly the primitive
|
||
`holdTopEdge` needs, and it worked: `e2-expand.png` shows the
|
||
row growing downward from the same top edge it had collapsed,
|
||
no jump. Virtualisation is real (`ui-trace elements` only ever
|
||
lists the rows currently on screen, never all 854). **What did
|
||
not come free: hugging the bottom of the screen.**
|
||
`VirtualScroll::new`'s doc says "the item at `initial_anchor`
|
||
will have its top aligned with the top of the scroll area" --
|
||
so anchoring on the last row puts that row's top at the
|
||
viewport's *top*, with empty space below it, not at the
|
||
viewport's bottom the way a chat transcript wants (visible in
|
||
`e2-screenshot2.png`). The complete public `WidgetMut` surface of
|
||
`VirtualScroll` is `new`, `with_valid_range`,
|
||
`will_handle_action`, `add_child`, `remove_child`, `child_mut`,
|
||
`set_valid_range`, `overwrite_anchor`
|
||
(`masonry/src/widgets/virtual_scroll.rs:257-428`) -- no
|
||
scroll-offset setter and no reverse/bottom-up layout mode exist
|
||
to ask for the other behaviour. Backward paging beyond the
|
||
initial 800 was not exercised, per the scope cut above.
|
||
4. **The soft keyboard -- inherited gap, not re-investigated.** E2's
|
||
screen has no `TextInput`, only read-only `Prose`/`Button`, so it
|
||
does not hit `masonry/src/lib.rs:531`'s `as_input_connection`
|
||
returning `None` directly -- but it would the moment a composer
|
||
is added, per E1's finding. Nothing new to add here.
|
||
5. **Platform integration -- out of scope by design.** Foreground
|
||
service, notifications, share sheet, deep link, Keystore,
|
||
camera, back gesture, edge-to-edge, local-network permission are
|
||
E3's list in RUST.md's own experiment order, not E2's.
|
||
6. **Accessibility names -- shown, and the bench-script dependency
|
||
actually exercised.** `ui-trace record --do "tap '> 3 tool
|
||
calls'"` found the button by its label and pressed it (that tap
|
||
is what produced `e2-expand.png`); `Prose` rows surface their
|
||
text as their accessible name too (`ui-trace elements` lists
|
||
"You said: One more short reply..." etc. as named nodes). Tap by
|
||
name, the rule this whole project's bench scripts depend on,
|
||
works against this screen.
|
||
7. **Measurable frames -- blocked, and this is the finding E2 was
|
||
really testing for.** Two separate problems, one of them fatal
|
||
to the render-numbers half of this box's own pass condition.
|
||
First, Masonry has no render-report/per-widget-cost
|
||
instrumentation the way Compose's `DebugStats` gives this
|
||
project -- building one was out of scope here. Second, and this
|
||
is the one that matters: **neither of Masonry's two scrolling
|
||
widgets responds to a touch drag at all.**
|
||
`VirtualScroll::on_pointer_event`
|
||
(`masonry/src/widgets/virtual_scroll.rs:504-523`) and
|
||
`Portal::on_pointer_event`
|
||
(`masonry/src/widgets/portal.rs:259-267`) both match only
|
||
`PointerEvent::Scroll` (wheel/trackpad deltas) and do nothing
|
||
with `PointerEvent::Down`/`Move`/`Up` -- there is no drag-to-scroll
|
||
gesture logic anywhere in the widget set. `android-view`'s own
|
||
Java bridge keeps the two paths separate at the source:
|
||
`RustView.java`'s `onTouchEvent` forwards raw touch straight to
|
||
Rust, and only `onGenericMotionEvent` (mouse/trackpad, not
|
||
touch) reaches the `ACTION_SCROLL` branch that becomes
|
||
`PointerEvent::Scroll`
|
||
(`android-view/src/events.rs:530`). Confirmed empirically, not
|
||
just by reading: a real swipe (`ui-trace`'s `swipe 540 1600 540
|
||
400 300`, twice) moved nothing (`e2-scroll.png` is pixel-identical
|
||
to the screen before it), and a synthetic Android wheel event
|
||
(`adb shell input scroll 540 1200 --axis VSCROLL,-5`) also moved
|
||
nothing. **This means `transcript-bench.sh`'s own gesture --
|
||
a finger swipe -- cannot scroll a Masonry transcript on Android
|
||
today, at all, on this framework commit.** So the "render
|
||
numbers land within the Compose baseline" half of this box's
|
||
pass condition cannot be attempted, let alone met: there is no
|
||
way to perform the scroll the comparison asks for. This is not
|
||
a performance shortfall to close by writing faster code: it is
|
||
an absent input path upstream. The fix is a drag-to-scroll
|
||
gesture in `on_pointer_event` (the same place `TextArea`'s own
|
||
caret-drag logic already lives, so the pattern -- capture on
|
||
`Down`, accumulate delta on `Move`, release on `Up` -- exists
|
||
in this codebase already, just not wired into either scrolling
|
||
widget), and it belongs upstream in `xilem` rather than in this
|
||
project.
|
||
|
||
**Net for RUST.md's recommendation.** Item 3 ("decide when the
|
||
transcript screen exists in both, from the measurements") cannot be
|
||
decided by a render-number comparison yet, because the comparison's
|
||
own gesture does not work on Masonry on Android. What *can* be
|
||
compared today is structural: iris already has a working scroll
|
||
gesture and a working touch model (I2, 2026-09-05) that Masonry's
|
||
upstream commit does not yet have for this exact case. That is a
|
||
point in iris's favour that a frame-time number would not have
|
||
shown any more clearly.
|
||
- [x] **E3 — the shell (2026-09-05).** Both pass-condition proofs held on
|
||
the emulator: a notification arrived while the app was closed, and a
|
||
shared text share landed as a real message in a session's transcript.
|
||
Committed to this repo (unlike E1/E2's external, uncommitted trees),
|
||
since this is lightweight glue rather than a multi-gigabyte native
|
||
build.
|
||
|
||
*Where it lives.* `android-shell/` (new crate, `client-core` as its
|
||
only real dependency) is the JNI bridge; `app/shellApp/` is a **new
|
||
Gradle module**, not a rewrite of `app/androidApp` in place --
|
||
that module is ~13,000 lines of working Compose UI this experiment
|
||
does not touch or risk, and the two install side by side on one
|
||
development device. `app/shellApp`'s manifest, channel names,
|
||
notification wording and share intent-filter are copied from
|
||
`androidApp`'s (`Notifications.kt`, `Share.kt`, the manifest) per
|
||
AGENTS.md's "reuse rather than re-derive" -- see each file's own doc
|
||
comment for exactly what was carried over. Two deliberate
|
||
differences, both practical rather than behavioural: application id
|
||
`com.example.aiapp.shell` and deep-link scheme `aiappshell` (not
|
||
`aiapp`), so this experiment's install cannot collide with the real
|
||
app's enrollment or Keystore alias on the same phone -- see
|
||
`android-shell/src/settings.rs`'s `SCHEME` doc.
|
||
|
||
*The Java floor, and one line more than planned.* Two classes,
|
||
matching "How much Java is unavoidable" almost exactly:
|
||
`MainActivity.java` (`onCreate`/`onNewIntent` forward to
|
||
`nativeHandleIntent`) and `NotificationService.java`
|
||
(`onStartCommand`/`onDestroy`/a `sync()` companion, three natives).
|
||
Both ~30 lines including the license-free boilerplate Java itself
|
||
demands (imports, `System.loadLibrary`). **One addition the analysis
|
||
did not anticipate**: `MainActivity.toast(Context, String)`, a
|
||
plain (non-native) static method Rust *calls* rather than
|
||
implements, because posting a `Toast` from `share.rs`'s background
|
||
thread needs a hop back to the main looper
|
||
(`new Handler(Looper.getMainLooper()).post(...)`), and JNI can call
|
||
an existing Java method on any thread but cannot construct a Java
|
||
`Runnable` to hand to `Handler.post`/`runOnUiThread` without a
|
||
reflection proxy uglier than three lines of Java. Recorded here
|
||
because "the floor is two classes of ten lines" undersold this by
|
||
exactly one small, call-only method -- the pattern (Rust calls
|
||
Java, never Rust implements a Java interface) is worth keeping the
|
||
next time this floor is estimated.
|
||
|
||
*What client-core gained.* `notifications.rs`: `SessionNotification`,
|
||
`NotificationKind` (mirroring `server/src/session/mod.rs`'s wire
|
||
shape field-for-field) and `follow_notifications`, the SSE parse
|
||
over `/notifications` built on the same `sse::SseReader` and
|
||
`Transport` trait `event_stream.rs` already uses. `attention_line`
|
||
is ported verbatim from `Notifications.kt`. 3 new tests (88 total in
|
||
the crate); `android-shell` itself has none, since every function in
|
||
it needs a live `Env` and there is no pure logic left to test in
|
||
isolation once client-core owns the parsing -- matches E2's
|
||
precedent ("a throwaway screen, not a library").
|
||
|
||
*Scope cuts, each recorded at its own point in the code rather than
|
||
only here:*
|
||
- **Text-only share.** `Intent.EXTRA_TEXT` becomes a session message;
|
||
a shared file/photo URI is not uploaded, because `client-core`'s
|
||
`ApiClient` has no `/sessions/{id}/attachments` route yet either
|
||
(`CLIENT_CORE.md`'s own "not covered" list) -- porting
|
||
`Attachments.kt`'s `ContentResolver` reads and bitmap downscaling
|
||
is real work belonging to whichever caller needs it next, not a
|
||
detour inside this box.
|
||
- **No session picker.** With no screen drawn yet (E4's job), a
|
||
share attaches to whichever session has the latest
|
||
`last_activity` -- documented as a placeholder in `share.rs`,
|
||
not a designed behaviour.
|
||
- **No banner/on-screen suppression.** `notify::show` skips
|
||
`Notifications.kt`'s "nothing if this session is on screen" /
|
||
"hand to the app as a banner" branches entirely: both read
|
||
process-wide state that only means something once a screen
|
||
exists to register against it, so every notification here takes
|
||
the platform-drawer branch -- which is also exactly what the pass
|
||
condition asks for. Revisit once E4 draws something.
|
||
- **Keystore is not reimplemented in Rust.** `settings.rs` calls
|
||
`wg-app-link`'s existing `ServerStore`/`ServerSettings` Kotlin
|
||
classes over JNI rather than re-deriving the AES-GCM sealing:
|
||
that code is shared with Dev Updater, already tested, and tied to
|
||
a Keystore alias an existing enrolled phone depends on. This does
|
||
mean `kotlinc` stays in the toolchain regardless of what E5 does
|
||
with `javac`/`d8` for this module's own two classes -- a
|
||
correction to "Can the APK be built without Gradle?"'s assumption
|
||
that dropping Kotlin drops `kotlinc` outright; it drops it for
|
||
*this app's own code*, not for a shared submodule pulled in as a
|
||
dependency.
|
||
|
||
*`jni` 0.22, not the older API most examples assume.* This is a
|
||
real API split (`Env` for real work, `EnvUnowned` as the FFI-safe
|
||
type a native fn receives, joined by `EnvUnowned::with_env`), and
|
||
the `native_method!` macro (used for all four natives here, via
|
||
`const _: NativeMethod = native_method! { ... }`) generates both the
|
||
mangled `Java_...` export and the panic/error-handling wrapper from
|
||
one Rust function signature -- chosen over hand-written
|
||
`#[unsafe(no_mangle)] extern "system" fn Java_com_..._method` because
|
||
a hand-typed export name and a hand-typed JNI signature string
|
||
routinely drift from the Java they claim to match, silently (see
|
||
the next two findings, both of which were exactly that drift).
|
||
`error_policy = LogErrorAndDefault` reports a failure to logcat
|
||
rather than throwing it back into Java as an exception that would
|
||
crash the app over something recoverable -- matching
|
||
`Notifications.kt`'s own "log, don't crash" posture, but it is a
|
||
no-op without a logger backend (`android_logger`, Android-only
|
||
dependency, `lib.rs`'s `ensure_logger`) installed; the class of bug
|
||
this exists to report was found once with no logger and read as
|
||
nothing having gone wrong at all.
|
||
|
||
**Three real findings, each cost a failed run before being
|
||
diagnosed, each written where the fix lives so a reader who touches
|
||
that file again does not lose an afternoon to it:**
|
||
|
||
1. **A generic `JObject` parameter type silently exports the wrong
|
||
JNI signature.** `native_method!`'s shorthand
|
||
`fn native_sync(context: JObject) -> ()` encodes the export as
|
||
`(Ljava/lang/Object;)V`, because it has no way to know the
|
||
intended Java type is `android.content.Context` from a bare
|
||
`JObject`. The real Java method is declared
|
||
`(Landroid/content/Context;)V`; the two mangled names never
|
||
resolve to each other, and the failure is
|
||
`UnsatisfiedLinkError: No implementation found`, thrown the
|
||
moment Java calls it -- not a build error on either side. Fixed
|
||
by spelling each parameter as its actual Java type in the macro
|
||
invocation (`context: android.content.Context`, `activity:
|
||
android.app.Activity`, ...), which the macro accepts directly
|
||
per its "Java Object Types" syntax, while the Rust implementation
|
||
function keeps the parameter as plain `JObject` (the "Built-in
|
||
Types" fallback for a Java class with no dedicated Rust
|
||
wrapper). `lib.rs`'s comment beside the first `native_method!`
|
||
call is the citation.
|
||
2. **A class looked up by name from this crate's own background
|
||
thread fails, and only for app classes.** `android-shell`'s
|
||
follow-loop and share threads are Rust-spawned and attached via
|
||
`JavaVM::attach_current_thread`, which the platform never handed
|
||
an app `ClassLoader` -- so `FindClass`'s default fallback (used
|
||
internally by `find_class`/`new_object`/`call_static_method`/
|
||
`get_static_field`, anything that resolves a class *by name*
|
||
rather than from an object it already holds) only reaches the
|
||
bootstrap loader's framework classes. `androidx.core.app.
|
||
NotificationManagerCompat`, packaged inside this app's own APK,
|
||
is invisible from there: `Error::NoClassDefFound`, logged by
|
||
`notify::show`'s `LogErrorAndDefault` as "failed to resolve Java
|
||
class ... (class not found or linkage error)" -- which on a real
|
||
device is indistinguishable from "the notification silently
|
||
never arrives," since the *ongoing* foreground notification
|
||
(built on the main thread, before this thread exists) posts
|
||
fine regardless, so nothing else looks wrong. Fixed in
|
||
`jcall.rs`: `remember_class_loader` caches the app's own
|
||
`ClassLoader` (`context.getClass().getClassLoader()`) the first
|
||
time any entry point with a `Context` runs, and every
|
||
class-by-name lookup goes through `LoaderContext::Loader`
|
||
explicitly rather than the thread-dependent default -- correct
|
||
on the main thread and this crate's background threads alike.
|
||
`jcall.rs`'s module doc has the full account.
|
||
3. **`onStartCommand` spawning a thread unconditionally opens a
|
||
second connection, and `Notifications.kt` has the same bug.**
|
||
Enrolling calls `sync()` twice in one launch (once
|
||
unconditionally in `MainActivity.onCreate`, again inside
|
||
`handle_enrollment` after saving the token), each of which starts
|
||
the service, and Android runs `onStartCommand` once per start
|
||
request -- so the follow-loop thread was spawned twice, caught on
|
||
`adb logcat` as two `jni::vm::java_vm: Attached thread
|
||
ai-app-notifications` lines for one enrollment. Kotlin's
|
||
`onStartCommand` has the identical shape (`thread(isDaemon =
|
||
true) { follow(settings) }`, no guard), so this is a latent bug
|
||
in the reference implementation this port found by testing
|
||
rather than something E3 introduced -- worth carrying the same
|
||
guard back to `Notifications.kt` separately, not done here.
|
||
Fixed in `notify.rs` with a `RUNNING` `AtomicBool`, `swap`ped
|
||
true before spawning and reset in `on_destroy`; see its doc
|
||
comment for the accepted race this shares with the pre-existing
|
||
`STOPPING` gap below.
|
||
|
||
**Known gap, not fixed, written where it will be found.**
|
||
`notify.rs`'s `STOPPING` flag (checked between reconnects) cannot
|
||
interrupt a `ureq` read already blocked inside one connection --
|
||
unlike `HttpURLConnection.disconnect()`, `client_core::Transport`
|
||
exposes no cancellation handle. `/notifications` is idle between
|
||
events (a keep-alive), so in practice a stop is a bounded wait
|
||
rather than a hang; closing this for real means adding a
|
||
cancellation point to the `Transport` trait itself, a decision
|
||
affecting every caller, not an `android-shell`-only fix.
|
||
|
||
*Verification, exact commands.* `cargo fmt -- --check`,
|
||
`cargo clippy --all-targets` (zero warnings) and `cargo build`
|
||
clean for both `client-core` and `android-shell` on the host
|
||
target; `cargo ndk -t x86_64 -P 26 clippy --all-targets` clean for
|
||
`android-shell` on the Android target too (the `android_logger`
|
||
dependency is Android-only, so this is the only way to compile-check
|
||
it). `./run-tests.sh` from the repo root: 127 `server` tests, 88
|
||
`client-core` tests (85 + the 3 new to `notifications.rs`), all
|
||
passing -- the port added no regression to what already worked.
|
||
`./gradlew :shellApp:lintDebug`: `No issues found` (the report at
|
||
`app/shellApp/build/reports/lint-results-debug.txt`).
|
||
|
||
*The two pass-condition proofs*, both on this checkout's own AVD
|
||
(`ai-app-2`, GPU host per the default, torn down with `emu down`
|
||
when this session finished) against `app/ui-sandbox.sh`:
|
||
|
||
- **Notification with the app closed.** Enrolled via
|
||
`adb shell "am start -a android.intent.action.VIEW -d
|
||
'aiappshell://enroll?host=10.0.2.2&port=<sandbox port>&token=<token>'"`
|
||
(per the sandbox's own banner, substituting the scheme), granted
|
||
`POST_NOTIFICATIONS`, pressed home, then
|
||
`./ui-sandbox.sh spawn e3notif2` and
|
||
`./ui-sandbox.sh send <sid> "/question Should I proceed with the deploy?"`.
|
||
`adb shell dumpsys notification --noredact` shows a
|
||
`channel=sessions` record, `android.title=e3notif2`,
|
||
`android.text=Waiting for you` (matching `attention_line` and the
|
||
session's own title, exactly what `Notifications.kt` would have
|
||
shown) -- posted while the app held no visible activity. Tapping
|
||
it (`ui-trace record --do "tap 'e3notif2'"`, found in the
|
||
expanded shade after `adb shell cmd statusbar
|
||
expand-notifications`) launched
|
||
`com.example.aiapp.shell/.MainActivity` with
|
||
`dat=aiappshell://session/...`, confirmed in `adb logcat`'s
|
||
`ActivityTaskManager: START` line -- the `PendingIntent` names
|
||
the right session.
|
||
- **A share lands in a session.** With the app enrolled and a
|
||
session already active,
|
||
`adb shell "am start -a android.intent.action.SEND -t text/plain
|
||
--es android.intent.extra.TEXT 'Please check the deploy logs for
|
||
errors.' -n com.example.aiapp.shell/.MainActivity"` (the classic
|
||
`adb shell` quoting trap from `this-machine-android` applies here
|
||
too: the whole `am start` invocation has to be one single-quoted
|
||
string handed to the *remote* shell, or the extra's spaces get
|
||
re-split away). `./ui-sandbox.sh api
|
||
'/sessions/<sid>/transcript?limit=20'` shows
|
||
`{"type":"userMessage","text":"Please check the deploy logs for
|
||
errors."}` followed by the echo driver's reply -- the share
|
||
reached the most-recently-active session as a real message, not
|
||
a mock.
|
||
- [x] **E4 — the same screen on the desktop (2026-09-05).** A new
|
||
`iris/desktop-app` crate (added to the `iris` workspace's members, not
|
||
excluded the way `android-app` is -- nothing here needs the NDK):
|
||
a real winit window showing a session list (`iris::widget::Span`,
|
||
rebuilt on selection) beside `transcript-ui`'s screen
|
||
(`transcript_ui::build_tree`, new this box -- see IRIS.md's
|
||
2026-09-05 entry), talking to a real `ai-server` through
|
||
`client-core`'s `ApiClient`/`UreqTransport`/`follow_session_events`.
|
||
Enrolment is `client_core::config::EnrolledServer::parse_link`
|
||
against the same `aiapp://enroll?host=H&port=P&token=T` link a phone
|
||
scans, pasted via `--link` and persisted at
|
||
`$XDG_CONFIG_HOME/ai-app-desktop/enrollment.json` (0600 --
|
||
`iris/desktop-app/src/config.rs`); the pinned CA is a `--ca PATH`
|
||
argument, never baked in (DECISIONS.md, 2026-09-05).
|
||
|
||
*Both pass-condition proofs held, against `app/ui-sandbox.sh`'s real
|
||
server.* (1) The list showed the sandbox's spawned session
|
||
("Demo session", its live status); selecting it loaded the real
|
||
transcript and the composer's `Submit` posted a message whose reply
|
||
streamed in live over SSE, both proved by two `run-headless.sh`
|
||
screenshots taken seconds apart around a real `./ui-sandbox.sh send`
|
||
-- the second showed the new turn appended under the first with
|
||
nothing duplicated or lost. (2) Screenshotted headless:
|
||
`/tmp/iris_e4_desktop.png` (1920x1200, 15.9 KB, the real first-run
|
||
state -- list populated, "Select a session." on the right, nothing
|
||
selected yet). `run-headless.sh` gained a `--bin` flag for this
|
||
(`cargo build --bin NAME` + `target/debug/NAME` instead of the
|
||
`--example` path, since `desktop-app` is a real binary a person
|
||
runs, not a demo) and `$RUN_HEADLESS_ARGS`, word-split into the
|
||
launched binary's own argv (a real CLI's flags, which no example
|
||
needed a way to pass before). Exact commands, from `iris/`:
|
||
|
||
TOKEN=$(cat "${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/sandbox-token")
|
||
LINK="aiapp://enroll?host=127.0.0.1&port=<PORT>&token=$(python3 -c \
|
||
'import sys,urllib.parse;print(urllib.parse.quote(sys.argv[1],safe=""))' "$TOKEN")"
|
||
CA="${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/certs/ca.pem"
|
||
RUN_HEADLESS_ARGS="--ca $CA --link $LINK" \
|
||
./run-headless.sh desktop-app --bin --shot /tmp/iris_e4_desktop.png -- -p desktop-app
|
||
|
||
**A real bug this screenshot found, not a synthetic one**: the first
|
||
attempt resumed the live SSE stream from
|
||
`items.iter().map(TranscriptItem::seq).max()` -- the *folded* item's
|
||
seq, which for a still-open `AssistantMsg` is the seq of its
|
||
*first* delta by design (`fold_event`'s own doc comment: "a row
|
||
whose identity changed with every delta would be a new row every
|
||
frame"). Resuming from there re-delivered every delta already
|
||
folded into that message, and the screenshot showed the assistant's
|
||
reply with its own tail duplicated ("You said: ... testsaid: ...
|
||
test"). Fixed by computing the resume cursor from the raw wire
|
||
`seq` of the last fetched line (`app.rs`'s `raw_seq`) instead of
|
||
from any folded item -- regression test
|
||
`the_resume_cursor_is_the_last_wire_seq_not_the_last_items_seq` in
|
||
`iris/desktop-app/src/app.rs`. Exactly the class of bug CODE_RULES
|
||
warns about under "a fix tried only on what it was meant to fix":
|
||
the bare REST fetch (no live stream yet) looked perfect on its own,
|
||
and only *resuming* a stream after it exposed the seam.
|
||
|
||
**Deliberately left simple at the time, later fixed** (`app.rs`'s
|
||
module doc has the full account): every incoming SSE event used to
|
||
refold the session's whole item list and rebuild the entire
|
||
right-hand widget tree from scratch on every event, rather than
|
||
reaching for `TranscriptScreen::push_row`'s incremental append --
|
||
`push_row` could only add a new row, and a streaming reply is
|
||
exactly a row whose text keeps changing after it first appears.
|
||
Fine at the size a desktop session's conversation is; wrong for a
|
||
long, fast-streaming one -- fixed below (this same box's "Streaming
|
||
no longer costs a full rebuild" entry) by giving `transcript-ui` a
|
||
`TranscriptScreen::apply` that updates a row already on screen
|
||
instead of rebuilding every row around it. `rebuild_transcript`
|
||
still runs the whole tree once, for a freshly loaded/selected
|
||
session and for `apply`'s own rare full-rebuild fallback. No history
|
||
paging (I3's job, reused as-is if this becomes permanent) and no
|
||
scroll-position preservation across a rebuild -- both still named
|
||
rather than silently missing, and neither depends on the fix below.
|
||
Background network I/O runs on plain `std::thread`s reporting back
|
||
through winit's `EventLoopProxy<AppEvent>` rather than iris's own
|
||
`Tasks`/`task_on`, because `Tasks` only requests a redraw once after
|
||
its whole async closure finishes, which fits "one request, one
|
||
update" and not a live stream that needs a redraw after *each*
|
||
event it relays.
|
||
|
||
Verification: `cargo fmt --all`, `cargo clippy --workspace
|
||
--all-targets` (zero warnings), `cargo test --workspace` from
|
||
`iris/` (7 new tests in `desktop-app` -- 4 for
|
||
`config.rs`'s save/load/permissions/corruption, 3 for `app.rs`'s
|
||
transcript folding and the resume-cursor regression above -- plus
|
||
the existing 37 unchanged), and `./run-tests.sh` at the repo root
|
||
(127 passing, `client-core` alone 93 -- the `EnrolledServer` parsing
|
||
tests already existed before this box). Android is untouched by
|
||
this step, as asked.
|
||
- [x] **E5 — the packaging xtask (2026-09-05).** Both pass-condition
|
||
proofs held on this checkout's own emulator: `adb install -r` of the
|
||
xtask-built APK over the Gradle-built one succeeded, and the
|
||
notification service reached its follow-loop and posted a real
|
||
notification while the app was backgrounded. `cargo xtask apk` at
|
||
the repo root (`.cargo/config.toml`'s alias for `cargo run
|
||
--manifest-path xtask/Cargo.toml --`) runs `cargo ndk` →
|
||
`javac`/`d8` → `aapt2` → `zipalign` → `apksigner` with no Gradle
|
||
driving the packaging itself -- one disclosed exception, below.
|
||
|
||
*Where it lives.* `xtask/` (new, independent crate at the repo
|
||
root -- **no Cargo workspace**, matching every other crate here;
|
||
`run-tests.sh` already `cd`s into each rather than assuming one).
|
||
**Zero dependencies**: every step is "run this SDK/JDK tool with
|
||
these arguments and check its exit status," which needs nothing a
|
||
crate would add (AGENTS.md's "new dependencies need a reason").
|
||
`src/sdk.rs` finds the SDK root/build-tools/`android.jar` the same
|
||
way `app/android-env.sh` does ($ANDROID_HOME, then
|
||
$ANDROID_SDK_ROOT, then `~/Android/Sdk`); `src/keystore.rs`
|
||
finds-or-generates the release key with the exact recipe
|
||
`app/build-apk.sh` uses (same env vars, same path, same `keytool`
|
||
invocation) so the two tools sign with the *same* key, plus a
|
||
`--debug` path using the conventional `~/.android/debug.keystore`;
|
||
`src/apk.rs` is the pipeline itself, `src/main.rs` the ~40-line CLI.
|
||
About 420 lines total against RUST.md's earlier "about 150" guess --
|
||
the difference is almost entirely dependency handling (below), which
|
||
the earlier estimate didn't anticipate.
|
||
|
||
*`:link`'s `kotlinc` question, resolved.* Checked first, since E3
|
||
left it open: no standalone `kotlinc` exists on this machine (not on
|
||
PATH, not under any SDK -- only `kotlin-compiler-embeddable` jars
|
||
inside Gradle's own distributions). So the choice was never
|
||
"invoke kotlinc" versus "port `ServerStore`/`ServerSettings` to
|
||
Java" as originally framed -- a third route fell out of solving the
|
||
*other* open dependency problem (androidx, next paragraph): the one
|
||
Gradle call already needed for that also compiles `:link`'s Kotlin
|
||
as a side effect, via Gradle's own embedded compiler, and hands back
|
||
the resulting `classes.jar` in the same resolved-jars list. That is
|
||
RUST.md's own "prebuild it once into a jar/aar E5 consumes as a
|
||
binary input" option, arrived at for free rather than built
|
||
specially -- no Java port of `ServerStore` was written, and
|
||
`android-shell/src/settings.rs`'s JNI class-by-name lookup
|
||
(`com/example/wgapplink/ServerStore`) needed no change.
|
||
|
||
*One disclosed exception to "no Gradle in the loop": dependency
|
||
resolution.* `app/shellApp` depends on `:link` (Kotlin, above) and
|
||
on `androidx.core:core-ktx` -- not a compile-time dependency of the
|
||
two Java stub classes (`MainActivity`/`NotificationService` import
|
||
only `android.*`), but a **runtime** one: `android-shell/src/notify.rs`
|
||
reaches `NotificationCompat`/`NotificationChannelCompat`/
|
||
`NotificationManagerCompat`/`ServiceCompat`/`ContextCompat` by class
|
||
name over JNI, so their bytecode has to be in the final dex even
|
||
though nothing in this pipeline's own Java source mentions them.
|
||
Reimplementing a Maven/AAR dependency resolver to avoid one Gradle
|
||
call was not a good trade against "smallest honest route" (the
|
||
standard this file already applied to `kotlinc`) -- so
|
||
`app/shellApp/build.gradle.kts` gained one task,
|
||
`printRuntimeClasspathJars`, which asks the `releaseRuntimeClasspath`
|
||
configuration for its artifacts through an `ArtifactView` requesting
|
||
the `android-classes-jar` attribute (the same post-AAR-transform
|
||
view AGP's own dexing task consumes, so an AAR is already unpacked
|
||
to a plain `.jar` by the time the xtask sees it) and writes their
|
||
absolute paths, one per line, to
|
||
`app/shellApp/build/xtask/runtime-classpath.txt`. `cargo xtask apk`
|
||
runs `./gradlew :shellApp:printRuntimeClasspathJars` once (a few
|
||
seconds, mostly UP-TO-DATE on a warm Gradle daemon), reads that file,
|
||
and hands every jar in it to `d8` as an ordinary program input --
|
||
`:link`'s `classes.jar` among them, per the paragraph above. Nothing
|
||
past that one call touches Gradle. **What this trades away**: the
|
||
pipeline is not Gradle-free end to end, only Gradle-free for the
|
||
part that was actually expensive (assembling and dexing the app's
|
||
own code, which the earlier options -- kotlinc, or a hand-rolled
|
||
resolver -- were the two ways to avoid entirely). Recorded here
|
||
rather than left implicit, matching how the `kotlinc` compromise
|
||
above is recorded.
|
||
|
||
*The rest of the pipeline, in order (`apk.rs`):* `cargo ndk -t
|
||
arm64-v8a -t x86_64 -P 26 -o app/shellApp/src/main/jniLibs/ build
|
||
--release -p android-shell` (both ABIs by default -- real phone and
|
||
this machine's emulator -- `--abi` overrides; always `--release`
|
||
for the native library regardless of the APK's signing variant, for
|
||
the reason E1 already established: a debug build's Vulkan
|
||
object-labelling segfaults this emulator's driver, and there is no
|
||
reason for a signing choice to make this crate's `.so` bigger).
|
||
`javac -cp android.jar` compiles `MainActivity.java`,
|
||
`NotificationService.java` and a freshly generated `PinnedCa.java`
|
||
(same template as the Gradle `generatePinnedCa` task, same
|
||
opening-quotes-adjacent-to-`"""` rule from AGENTS.md's "Things that
|
||
have bitten") into one `classes.jar` (`jar cf` -- `d8` rejects a
|
||
bare directory of `.class` files outright, "Unsupported source file
|
||
type", discovered by trying it). `d8 --release --min-api 24 --lib
|
||
android.jar` dexes that jar plus every classpath jar from the
|
||
paragraph above into one `classes.dex` (no multidex needed at this
|
||
size). `aapt2 link` compiles `app/shellApp/src/main/AndroidManifest.xml`
|
||
into the base APK's `resources.arsc` -- the checked-in manifest has
|
||
no `package` attribute (Gradle injects one from `android.namespace`
|
||
during a manifest merge this pipeline doesn't run), so `apk.rs`
|
||
writes a copy with `package="com.example.aiapp.shell"` spliced in
|
||
rather than editing the source manifest, and refuses to run at all
|
||
if the source ever gains one of its own (a version-drift guard
|
||
cheaper than a real merge). `--min-sdk-version`/`--target-sdk-version`/
|
||
`--version-code`/`--version-name` are passed on the command line for
|
||
the same reason -- the raw manifest carries none of them, Gradle's
|
||
`defaultConfig` normally does. `jar uf` (not a hand-rolled zip
|
||
writer -- `jar` ships with the JDK this pipeline already needs)
|
||
merges `classes.dex` and a staged `lib/<abi>/libandroid_shell.so`
|
||
tree into the base APK (cargo-ndk's `-o` writes
|
||
`jniLibs/<abi>/*.so`, matching the Gradle source-set layout it was
|
||
pointed at; Android's own zip convention wants `lib/<abi>/*.so` at
|
||
the archive root, hence the staging copy rather than an in-place
|
||
rename). `zipalign -f -p 4` then `apksigner sign` finish it, signed
|
||
with `~/.config/ai-app/release.jks` by default or
|
||
`~/.android/debug.keystore` under `--debug`. The signed APK is
|
||
copied to `xtask/build/outputs/apk/<mode>/ai-app-shell-<mode>.apk`
|
||
as a final step -- a Gradle-shaped path (`*/build/outputs/apk/*/*.apk`)
|
||
chosen so Dev Updater's fixed-pattern APK discovery
|
||
(`~/repos/dev-updater/server/src/discover.rs`'s `APK_PATTERNS`,
|
||
which has no per-component path override) finds it without any
|
||
change on that side; the working files above it stay under
|
||
`target/xtask/apk/`, an ordinary build-cache location (gitignored,
|
||
along with `xtask/target/`).
|
||
|
||
*Wired into `.dev-updater.ron`*: a second `Apk` component, `shell`,
|
||
`build: "cargo xtask apk"`, `modes: ["release", "debug"]`, no `cwd`
|
||
(defaults to the checkout root, which both the `cargo xtask` alias
|
||
and the publish path above need -- `.cargo/config.toml`'s alias
|
||
resolves its `--manifest-path` relative to the *invoking* working
|
||
directory, not to where the config file lives, which is what ruled
|
||
out giving this component its own `cwd`). Dev Updater's `ByMode`
|
||
appends the chosen mode word as the command's last argument
|
||
(`build-apk.sh`'s own interface, per that component's comment), so
|
||
`main.rs` accepts bare `release`/`debug` as well as `--release`/
|
||
`--debug` for typing by hand. The existing `app` component
|
||
(`build-apk.sh`, Gradle) is untouched.
|
||
|
||
*Verification.* `cargo fmt -- --check` and `cargo clippy
|
||
--all-targets` clean, zero warnings, for `xtask` (host target --
|
||
nothing in it is Android-specific; it *runs* `cargo ndk`, it isn't
|
||
cross-compiled itself). `./run-tests.sh`: 127 `server` + 88
|
||
`client-core` tests, unaffected, still passing. `apksigner verify
|
||
--print-certs` on the xtask's release output confirms a V3 signer
|
||
with `CN=ai-app` -- the same key `build-apk.sh` generates.
|
||
|
||
*The two pass-condition proofs*, both on this checkout's own AVD
|
||
(`ai-app-2`, GPU host, brought up and torn down within this
|
||
session):
|
||
- **Installs over the Gradle-built one.** Built the Gradle release
|
||
variant first (`AI_APP_KEYSTORE=~/.config/ai-app/release.jks
|
||
AI_APP_KEYSTORE_PASSWORD=$(cat
|
||
~/.config/ai-app/release.jks.password) ./gradlew
|
||
:shellApp:assembleRelease` -- needed its own signing block added
|
||
to `app/shellApp/build.gradle.kts`, copied from `androidApp`'s,
|
||
since `shellApp` had none before this), installed it fresh
|
||
(`adb uninstall com.example.aiapp.shell` first -- an older debug
|
||
install from E3 testing was signed with a different key and
|
||
`install -r` over it fails loudly with
|
||
`INSTALL_FAILED_UPDATE_INCOMPATIBLE`, which is the correct,
|
||
expected failure for a mismatched key rather than a bug), then
|
||
`adb install -r xtask/build/outputs/apk/release/ai-app-shell-release.apk`:
|
||
**`Success`**.
|
||
- **The notification service starts.** Enrolled via
|
||
`adb shell "am start -a android.intent.action.VIEW -d
|
||
'aiappshell://enroll?host=10.0.2.2&port=<sandbox port>&token=<token>'"`,
|
||
force-stopped the app, then re-launched it once (enrollment calls
|
||
`sync()` from `MainActivity.onCreate`). `adb logcat` shows
|
||
`ActivityManager: Background started FGS: Allowed ... intent:
|
||
... cmp=com.example.aiapp.shell/.NotificationService`, immediately
|
||
followed by `android-shell: jni::vm::java_vm: Attached thread
|
||
ai-app-notifications`, a real TLS handshake to the sandbox's
|
||
`10.0.2.2:<port>`, and `Response { status: 200 ... }` on
|
||
`/notifications`. Pressed home, spawned a sandbox session and sent
|
||
it `/question Should E5 proceed?`; `adb shell dumpsys notification
|
||
--noredact` then shows a live `NotificationRecord` for
|
||
`com.example.aiapp.shell`, `channel=sessions`, `tag=<session id>`
|
||
-- posted while the app held no visible activity, the same bar
|
||
E3's own proof cleared.
|
||
|
||
*Left undone, honestly.* No attempt to shrink the dex (R8/minify is
|
||
off, matching `shellApp`'s existing `isMinifyEnabled = false`, so
|
||
the APK carries the full unshrunk `androidx`/Kotlin-stdlib/coroutines
|
||
graph -- about 5.2 MB signed with both ABIs, most of it native
|
||
libraries and that dependency graph rather than this project's own
|
||
code). No `--abi arm64-v8a`-only real-device install was attempted
|
||
this session (no physical phone reachable from here); the emulator
|
||
proof above is `x86_64` plus a cross-compiled but unexercised
|
||
`arm64-v8a` `.so` in the same APK. Multidex is unneeded at today's
|
||
size but nothing in `dex()` checks for the 64k-method ceiling should
|
||
the dependency graph grow.
|
||
|
||
### The iris track
|
||
|
||
These build iris up to carry the app. Each is a feature added to iris
|
||
with a pass condition, in dependency order. Work in `iris/` in this
|
||
repository on the `rustify` branch, and record in this file what each
|
||
step measured.
|
||
|
||
- [x] **I0a — where iris lives (decided 2026-09-04).** For now it is
|
||
**vendored at `iris/` in this repository**, history not carried,
|
||
and consumed by path. Iris's decision: keep it close while it is
|
||
being reshaped for this app, and give it back its own repository —
|
||
`iris/iris` on the gitea remote, which already holds the full
|
||
244-commit history, on a branch of its own — once it has proved
|
||
itself. The vendored tree is that repository's `main` at
|
||
`7b54aaf` ("readme", 2026-01-29), byte-identical to the public
|
||
GitHub copy, so a later reconciliation has a known base. A crate
|
||
that uses it says `iris = { path = "../iris" }`.
|
||
- [x] **I0b — make it build here (done 2026-09-04).** iris now builds,
|
||
clippy-clean and rustfmt-clean at the defaults, on a pinned dated
|
||
nightly, and the `tabs` example draws on this VM's GPU.
|
||
|
||
**The pin** is `nightly-2026-09-03` (rustc 1.100.0-nightly,
|
||
`2e2b193f8`), declared in `iris/rust-toolchain.toml` along with the
|
||
`clippy`/`rustfmt` components and the two Android targets, so a
|
||
fresh clone provisions itself. It is dated rather than `nightly`
|
||
because the whole failure below was a rolling channel moving under
|
||
an unattended build. Installed with `--profile minimal`: 912 MB.
|
||
|
||
**The 36 errors were one syntax change, and the earlier diagnosis in
|
||
this file was wrong.** It is not that a trait must now be declared
|
||
`const trait` — the vendored tree already declares them that way,
|
||
which is how it was written in January. What changed is the *impl*
|
||
keyword order: `impl const Trait for T` is now
|
||
`const impl Trait for T`, and generics go on the `impl`
|
||
(`const impl<T: [const] Foo> Bar for T`). Bounds are unaffected;
|
||
`T: const Foo`, `T: [const] Foo` and `impl const Foo` in argument
|
||
position all still compile. Everything else — the unresolved
|
||
`UiVec2`/`Vec2`/`impl_op` imports, and a `Color<u8>` that resolved
|
||
to `wgpu_types::Color` — cascaded from the seven files that failed
|
||
to parse. The rewrite was mechanical across 20 sites and took the
|
||
workspace from 36 errors to 0.
|
||
|
||
**`#![feature]` gates, 12 after this step** (two were declared and
|
||
unused, and were removed: `map_try_insert`, `const_cmp`).
|
||
Load-bearing and worth watching: `const_trait_impl`, `const_ops`,
|
||
`const_convert`, `const_destruct` are the const-traits family and
|
||
the one that has already broken once — they move together, so
|
||
advancing the pin means re-reading this section. `unboxed_closures`
|
||
+ `fn_traits` (postfix builder API) and `unsize` +
|
||
`coerce_unsized` (widget handles) are pairs. The rest are
|
||
individually small: `macro_metavar_expr_concat`, `portable_simd`,
|
||
`associated_type_defaults`, `option_into_flat_iter`, and `gen_blocks`
|
||
in the top crate.
|
||
|
||
**Running it headless.** `iris/run-headless.sh EXAMPLE [--shot PNG]`
|
||
with `iris/headless.conf`, the same trick `emu` uses: a headless
|
||
sway, and `grim` for the picture. It deliberately starts its *own*
|
||
compositor rather than joining `emu`'s — sway tiles, so adding a
|
||
window to the one an emulator sits in resizes that emulator.
|
||
Unlike `emu`'s it disables Xwayland, since winit speaks Wayland.
|
||
**This VM has a real GPU for this**: Vulkan 1.4 through Venus onto
|
||
the host's RX 7900 XT, and GL 4.6 through virgl — so desktop wgpu
|
||
work here is not software-rasterised, unlike inside the emulator.
|
||
|
||
**iris has no tests at all** (`cargo test --workspace`: 0 passed
|
||
across 6 targets). Nothing to keep passing, and nothing to catch a
|
||
regression — worth knowing before I1 changes the text stack.
|
||
|
||
**`iris-core` no longer depends on winit, and now cross-compiles to
|
||
Android.** It wanted exactly one thing from it — `PhysicalSize<u32>`
|
||
in `UiRenderNode::resize`'s signature, for two numbers it immediately
|
||
turned into floats — and that pulled a whole windowing backend into
|
||
the layer below it, the wrong direction. `resize` takes
|
||
`impl Into<Vec2>` now, like `UiRenderState::resize` beside it already
|
||
did. The consequence is the point: with winit in the graph an Android
|
||
build of the core failed in `android-activity` (which needs a backend
|
||
feature nothing here selects), and without it
|
||
`cargo ndk -t arm64-v8a -P 26 build -p iris-core` finishes in 30s and
|
||
produces an rlib, wgpu's Android backend included. So **iris's
|
||
widget, layout and render core already builds for the phone**, and
|
||
what I2 has to supply is the surface, the input and the IME — not a
|
||
port of the library.
|
||
|
||
**Build weight, cold, on this VM's 8 cores** (`rm -rf target`, then
|
||
`cargo build --example tabs`), since "the Linebender stack is slow in
|
||
debug" was the worry behind this question: plain debug **43s** and a
|
||
2.1 GB `target/`; with the `[profile.dev.package."*"] opt-level = 2`
|
||
knob, **1m46s** and 1.5 GB. So iris's own wgpu + winit + cosmic-text
|
||
graph is not the slow thing — which makes it a calibration for E1
|
||
rather than an answer about Masonry, whose graph adds Vello, Parley,
|
||
Fontique and Skrifa. Runtime cost of the knob was not measured here.
|
||
|
||
**Fixed: iris never called `pre_present_notify`.** The symptom was
|
||
that about one start in five kept the window's 800x600 startup layout
|
||
on a 1920x1200 surface for good. What settled it was tracing iris's
|
||
own decisions into memory and dumping them from another thread —
|
||
`eprintln!` in the draw path makes the defect vanish, which is why
|
||
earlier attempts kept losing it. The traces from a good and a bad run
|
||
are **byte-identical**: both lay out and draw `redraw_all at
|
||
(1920, 1200)` into a 1920x1200 texture with `suboptimal=false`. iris
|
||
was drawing the right frame every time; the compositor was still
|
||
showing the first one, and forcing a full repaint did not shift it.
|
||
What was missing is winit's `Window::pre_present_notify`, called
|
||
immediately before `present`, which on Wayland is what ties the
|
||
commit to the surface's frame callback. Without it a frame drawn with
|
||
nothing following it can sit unpresented with nothing left to flush
|
||
it — which is exactly a window that has just settled after its
|
||
opening resize. Measured: **0 bad in 40** with the fix, against 4 in
|
||
20 before it, and — the stronger evidence — 0 in 20 in the
|
||
instrumented configuration that had been 15 in 20. Runtime resizing
|
||
still round-trips to a byte-identical layout.
|
||
|
||
Two things ruled out on the way, both worth not re-trying: the
|
||
present mode (the fault survived the move from `AutoNoVsync` to
|
||
`AutoVsync` at the same rate) and the size cache (`redraw_all` clears
|
||
it). A `desired_maximum_frame_latency` of 1 moved the rate without
|
||
fixing it, and was reverted. Iris's own note that she had never seen
|
||
the library fail to resize was the useful steer: it pointed away from
|
||
the layout code, where two hours had already gone.
|
||
|
||
One thing was fixed on the way, and it is not that bug: `update`
|
||
redrew everything when `resized` was set, but `needs_redraw` — which
|
||
is what decides whether to *ask* for a frame — did not know about
|
||
`resized` at all. The two now share one `needs_redraw_all`, since a
|
||
condition in one and not the other is a frame nobody requests. It is
|
||
latent on Wayland only because winit asks for a redraw after a resize
|
||
by itself; on Android, where the surface work of I2 will not have
|
||
winit underneath it, nothing else here would have asked.
|
||
- [x] **I1 — parley, and a glyph atlas (done 2026-09-04).** No bake-off:
|
||
Iris decided for parley directly ("I wanted to switch it to parley
|
||
anyways"), and then asked for the atlas as well ("just do the atlas,
|
||
commit to it, we do want it"). Both are in.
|
||
|
||
**What parley bought, beyond shaping.** Its editing model addresses
|
||
text by byte offset into one string, where cosmic-text used
|
||
`(line, index)` — so `select_content`, `delete_between`,
|
||
`insert_inner` and `newline` collapse into ordinary string
|
||
operations. Bigger: `Selection::geometry` and `Cursor::geometry`
|
||
replace `iter_layout_lines`, `index_x` and `cursor_pos`, which walked
|
||
runs by hand to place the caret and the selection boxes and were not
|
||
bidi- or wrap-correct. `edit.rs` lost about 130 lines and gained
|
||
Home/End. Its cursor motions map onto parley's `next_visual`,
|
||
`previous_visual_word`, `next_line` and so on, in one function.
|
||
|
||
**The atlas is what "text resizing (per frame) is really slow"
|
||
was.** Every string used to be rasterised into its own `RgbaImage`
|
||
and uploaded as a whole texture whenever anything about it changed,
|
||
so a window resize re-rasterised and re-uploaded every visible
|
||
string. Now a glyph is rasterised once per font, size and subpixel
|
||
phase, shared by every string that contains it, and a resize
|
||
re-emits quads without touching the GPU's copy. **The tabs example
|
||
reports it: `views`, the number of texture views bound, went from 6
|
||
to 1** — six per-string textures became one shared page. Supporting
|
||
pieces: a `GLYPH` primitive that samples a sub-rectangle and tints
|
||
it (the existing texture primitive samples a whole texture), a
|
||
`Patch` texture update so a new glyph costs its own bytes rather
|
||
than a 4 MB page, and `GpuTextures` keeping its `Texture`s, since a
|
||
view cannot be written through.
|
||
|
||
**Not yet measured**, and the honest gap in this step: the TODO's
|
||
"really slow" was never given a number, so neither is the
|
||
improvement. What is evidence rather than argument is the view count
|
||
and the shape of the work — a resize no longer rasterises. A
|
||
before/after timing wants the transcript screen of I5 to be worth
|
||
taking.
|
||
|
||
**Two bugs found on the way**, both pre-existing: `primitives!`'s
|
||
`@count` rule recursed comma-separated while matching
|
||
space-separated, so it terminated only for exactly two primitives
|
||
and adding a third hit the recursion limit; and `Color` had no
|
||
`Default`, which parley's `Brush` requires.
|
||
|
||
**Fourteen tests**, iris's first. The editor is the one part that is
|
||
pure logic rather than something needing a GPU and a window, and it
|
||
was rewritten wholesale with no way to exercise it — synthetic input
|
||
does not reach a client under the headless compositor, which has no
|
||
seat devices. Two of the tests are aimed at what the rewrite could
|
||
plausibly have broken: the IME preedit path, and editing multi-byte
|
||
text now that offsets are bytes.
|
||
|
||
Dropping cosmic-text and unicode-segmentation also retired two
|
||
nightly gates — `portable_simd` (the old glyph compositing) and
|
||
`gen_blocks` (the deleted line iterator). **Eleven left.**
|
||
|
||
### iris's binding array does not survive real Android hardware (found 2026-09-04, resolved 2026-09-04)
|
||
|
||
**Resolved the same day**: see "Where things stand" above and
|
||
TEXTURES.md's "Implemented, 2026-09-04". The measurement and sourcing
|
||
below are unchanged and are why the fix looks the way it does; nothing
|
||
here needs re-checking on its own account.
|
||
|
||
Iris asked, of the "unknown number of images" case — a transcript with an
|
||
unbounded number of attached screenshots — whether iris's approach even
|
||
works on a phone, since her recollection was that mobile does not support
|
||
it. Checked rather than assumed, and the recollection is right, with
|
||
sources rather than a guess.
|
||
|
||
**What iris does today.** Every texture — every `Image` widget
|
||
(`src/widget/image.rs`) and every glyph atlas page — gets its own
|
||
permanent slot in one array via `Textures::add`
|
||
(`core/src/primitive/texture.rs:65`), and both the `TEXTURE` and `GLYPH`
|
||
primitives sample it by `view_idx` into `binding_array<texture_2d<f32>>`
|
||
at `core/src/render/shader.wgsl:56`, sized by `UiLimits::default` — 100,000
|
||
textures, 1,000 samplers (`core/src/render/mod.rs:347`). That needs three
|
||
wgpu features: `TEXTURE_BINDING_ARRAY`,
|
||
`SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING`,
|
||
`PARTIALLY_BOUND_BINDING_ARRAY` — Vulkan's `VK_EXT_descriptor_indexing`
|
||
("bindless"), promoted to core in 1.2. So a transcript with an unbounded
|
||
number of images is exactly the case that grows this array without bound,
|
||
one permanent slot per image.
|
||
|
||
**Measured first on the emulator, and it fails outright.** A rig
|
||
(`rigs/gpu-probe`, a plain executable with no window, pushed with `adb
|
||
push` and run from `/data/local/tmp` — no APK needed to ask a device what
|
||
it supports) asks `wgpu::Adapter::request_device` for exactly iris's
|
||
features and limits. Against the emulator's guest Vulkan — both
|
||
SwiftShader (`vk_swiftshader_icd.json`) and lavapipe (`lvp_icd.json`,
|
||
cold-booted) — `request_device` **fails**: `Unsupported features were
|
||
requested: TEXTURE_BINDING_ARRAY |
|
||
SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING |
|
||
PARTIALLY_BOUND_BINDING_ARRAY`. A second, raw query through `ash`
|
||
(`rigs/gpu-probe/src/vk.rs`, bypassing wgpu) shows lavapipe's
|
||
`vkGetPhysicalDeviceFeatures2` actually reporting all seven descriptor-
|
||
indexing sub-features as `true` at device api version 1.3 — so on this
|
||
software renderer wgpu-hal's own feature detection is being more
|
||
conservative than the driver, for a reason not chased further (a likely
|
||
instance-version negotiation gap, since `VK_EXT_descriptor_indexing` was
|
||
only promoted to core at 1.2 and wgpu-hal's own `Instance::init` may be
|
||
requesting less). That part is an emulator/wgpu-hal question and not the
|
||
finding that matters.
|
||
|
||
**The finding that matters is about real phones, not the emulator, and it
|
||
is sourced rather than recalled.** The **Android Vulkan Profile 2025** —
|
||
Google and Khronos's current baseline, covering **80.1% of active
|
||
Vulkan-capable Android devices** as of October 2025
|
||
([developer.android.com/ndk/guides/graphics/android-vulkan-profile](https://developer.android.com/ndk/guides/graphics/android-vulkan-profile))
|
||
— does **not** require `VK_EXT_descriptor_indexing` or any descriptor-
|
||
indexing feature. It requires `shaderSampledImageArrayDynamicIndexing`
|
||
(indexing an array of samplers by a value uniform across the invocation —
|
||
Vulkan 1.0 baseline, unrelated to bindless) and stops there; the same is
|
||
true of the 2021 and 2022 profiles. On the hardware side, Arm's own
|
||
developer documentation states **"`VK_EXT_descriptor_indexing` is
|
||
supported on all Valhall and 5th Gen GPUs"**
|
||
([developer.arm.com/mobile-graphics-and-gaming/vulkan-api-best-practices-on-arm-gpus](https://developer.arm.com/mobile-graphics-and-gaming/vulkan-api-best-practices-on-arm-gpus)) —
|
||
Mali generations from roughly 2019 (Mali-G77) onward, named affirmatively
|
||
with no claim made for Bifrost, Midgard or Utgard, which are still common
|
||
in budget and older Android phones still in use. So this is not a
|
||
software-renderer artifact: a real, currently-shipping share of the
|
||
Android fleet lacks the feature iris's texture pipeline asks for
|
||
unconditionally, and the newest official baseline does not promise it
|
||
either. (A crates.io/search-engine claim of "1% support on Android" for
|
||
this extension was checked against its cited source, an Arm blog post,
|
||
and was not actually there — that number does not appear anywhere primary
|
||
and should not be repeated; the 80.1%-baseline-excludes-it finding above
|
||
is the one with an attributable source.)
|
||
|
||
**Recommendation, not yet implemented.** iris already solved the
|
||
identical problem for text in I1: the glyph atlas
|
||
(`core/src/render/atlas.rs`) packs many small rasters into a handful of
|
||
shared 1024×1024 pages and samples them by UV offset, so **text needs
|
||
none of the three features above** — only ordinary single-texture
|
||
sampling. The same technique generalizes to images: route an `Image`
|
||
widget through a shared atlas when it is small enough to pack (thumbnails,
|
||
downscaled attachment previews, avatars, icons), and fall back to one
|
||
ordinary, non-array texture bind group — selected per batched draw call
|
||
the way every immediate-mode 2D renderer already does — for anything too
|
||
large to atlas well (a photo opened at full resolution). Either path is
|
||
plain Vulkan 1.0 / GLES texture sampling, so it removes the descriptor-
|
||
indexing requirement from iris's device request entirely, which is also
|
||
what would make the emulator work regardless of the wgpu-hal question
|
||
above: a device that never asks for the feature cannot be refused for
|
||
lacking it. This is a change to iris's rendering core — the shader's
|
||
binding group layout, `Textures`, the texture and glyph primitives, and
|
||
`ui/painter.rs` — so it is written here as a recommendation rather than
|
||
started, per the project's rule to confirm a load-bearing design change
|
||
before making it. **It should be resolved before I2 is called done**,
|
||
since I2's pass condition is the phone, not just the emulator, and this
|
||
is exactly the kind of thing that passes on a desktop GPU and fails
|
||
silently on real hardware.
|
||
|
||
- [x] **I2 — iris on android-view (2026-09-05).** The android-view backend,
|
||
the `iris-android-app` cdylib and Gradle shell, insets, the back
|
||
gesture and the full `InputConnection` bridge are in and measured
|
||
working; the tabs example now renders on the emulator (Vulkan/
|
||
SwiftShader and GLES/virgl both), and the composer's keyboard shows
|
||
real Gboard suggestions through the IME bridge. See below for the
|
||
render-gap root cause and fix.
|
||
|
||
**Layout.** `iris/src/android/` mirrors `default/`'s module split
|
||
(`view.rs` is `app.rs`+`state.rs` combined, since android-view has one
|
||
harness type where winit splits `ApplicationHandler` from per-window
|
||
state; `render.rs`, `input.rs`, `attr.rs` correspond directly;
|
||
`ime.rs` and `insets.rs` have no winit counterpart). What used to live
|
||
only in `default/` and had no winit dependency — `WidgetState`,
|
||
`CursorState`/the sense machinery, `Tasks`, `Selector`/`Selectable`'s
|
||
focus handling — moved to crate-root modules (`state.rs`, `sense.rs`,
|
||
`task.rs`, `attr.rs`) so both backends use one copy; `Tasks`' redraw
|
||
nudge is now behind a `RequestRedraw` trait (`Window` for winit, a
|
||
`JavaVM`+`GlobalRef` attach-and-call for android-view) rather than a
|
||
concrete `winit::window::Window`. `winit`/`arboard` and
|
||
`android-view`/`send_wrapper` are now `[target.'cfg(...)']`
|
||
dependencies, and `default`/`android` are target-gated modules,
|
||
because winit's own Android support needs `android-activity` with a
|
||
backend feature selected — exactly what `iris-core` was kept free of.
|
||
Confirmed by trying it before the split (`cargo ndk -t x86_64 -P 26
|
||
build -p iris` failed inside `android-activity` itself) and after
|
||
(clean). `iris/tabs-ui` is the tabs example's widget tree factored out
|
||
of `examples/tabs/main.rs` into a crate generic over `Rsc: HasEvents`
|
||
+ `Rsc::State: FocusHost`, so the winit example and
|
||
`iris/android-app` (the new cdylib, excluded from the `iris` workspace
|
||
because android-view needs the NDK sysroot to link — see that
|
||
`Cargo.toml`'s comment) call the same `build()`.
|
||
|
||
android-view pinned to `bec6c62a96cef8239b0fd7fedeef9b184d02e3a1`, the
|
||
commit E1 measured against. `RustView.java`/`RustInputConnection.java`
|
||
are vendored (no published AAR to depend on) into
|
||
`iris/android-app/app/src/.../org/linebender/android/rustview/`, with
|
||
one deliberate diff from upstream noted in a comment: `mViewPeer` is
|
||
`protected` rather than package-private, so `IrisView` (a different
|
||
package) can pass it to the window-insets native call android-view
|
||
has no hook for.
|
||
|
||
**Insets and the back gesture**, both without touching android-view.
|
||
The back gesture takes no new plumbing at all: with no
|
||
`OnBackPressedCallback` registered, Android still delivers it as an
|
||
ordinary `KEYCODE_BACK` `KeyEvent` through the existing key path (the
|
||
legacy behaviour every view-based app gets by default), handled in
|
||
`view.rs`'s `on_key_down`. Insets have no such stand-in, so
|
||
`android/insets.rs` registers one more native method
|
||
(`applyWindowInsetsNative`) directly on `IrisView`, writing into an
|
||
`Rc<RefCell<Shared>>` a second copy of which lives in
|
||
`AndroidUiState` — the peer id android-view hands back from
|
||
`register_view_peer` is opaque outside that crate, so this is a
|
||
side table keyed on the same id rather than a way to reach the peer
|
||
itself. `MainActivity` wires `setOnApplyWindowInsetsListener`,
|
||
including the API 30+ `ime()` inset specifically (falls back to 0
|
||
below that). Not yet consumed by any widget's layout — `insets()` is
|
||
exposed on `AndroidUiState` but nothing reads it yet, since the tabs
|
||
example has no chrome that needs to avoid the keyboard.
|
||
|
||
**The IME bridge is implemented and its pass condition holds.**
|
||
`android/ime.rs` implements the full `InputConnection` trait
|
||
(`text_before_cursor`/`after_cursor`/`selected_text`,
|
||
`cursor_caps_mode`, `delete_surrounding_text[_in_code_points]`,
|
||
`set_composing_text`/`_region`, `finish_composing_text`,
|
||
`set_selection`, `begin`/`end_batch_edit`, `send_key_event`,
|
||
`request_cursor_updates`) directly against `TextEdit` — the same
|
||
preedit-replace bookkeeping `default`'s `Ime::Preedit` handling uses
|
||
(`compose_len`, in chars), with new byte<->UTF-16 conversion helpers
|
||
since parley (since I1) is byte-indexed and Java strings are not.
|
||
Two approximations, both commented in place rather than silently
|
||
dropped: `set_composing_region` declines (no separate composing range
|
||
exists to move) and `set_selection`/`delete_surrounding_text_in_code_points`
|
||
collapse to an approximation rather than a real span/code-point
|
||
count. `TextEdit` gained `text()`/`selection_range()`/`caret()`
|
||
getters and `TextEditCtx::delete_byte_range`/`set_cursor_byte`, all
|
||
unconditional (no winit dependency added); `apply_event`/
|
||
`TextInputResult`, which do take a `winit::event::KeyEvent`, are now
|
||
`#[cfg(not(target_os = "android"))]` instead of being ported, since
|
||
android's own `input.rs` calls `TextEdit`'s primitives
|
||
(`backspace`/`delete`/`motion`/`insert`) directly from
|
||
`ndk::event::Keycode` and never needed a winit `KeyEvent` shape.
|
||
|
||
**Measured on the emulator, 2026-09-05, x86_64 API 26,
|
||
`-feature Vulkan` + SwiftShader per the Vulkan section below.**
|
||
`adb shell dumpsys input_method` after tapping the composer field:
|
||
`mInputShown=true`, `mServedInputConnection` is
|
||
`org.linebender.android.rustview.RustInputConnection` attached to
|
||
`IrisView`. `adb shell input text "hi"` followed by a screenshot
|
||
shows **Gboard's suggestion strip populated with "hi | Hi | HI"** —
|
||
capitalization variants read back out of the real buffer through
|
||
`text_before_cursor`, the same kind of evidence E1 recorded (there:
|
||
"dolor | Dolores | door"). That is the bar this box asks for, met.
|
||
|
||
**Resolved 2026-09-05: the render gap was the window uniform, never
|
||
the atlas.** `UiRenderNode::new` (`core/src/render/mod.rs`) seeded the
|
||
GPU's `window_buffer` from `WindowUniform::default()` — width=0,
|
||
height=0 — and the only thing that ever corrected it was a later call
|
||
to `UiRenderNode::resize`, renamed `AndroidRenderer::resize` on the
|
||
android side. winit's backend gets away with the same default because
|
||
winit fires an initial `WindowEvent::Resized` before the first frame,
|
||
which `default/mod.rs`'s event loop turns straight into that resize
|
||
call — a real event this project never had to add on purpose, so
|
||
nothing here noticed the node depended on it. android-view has no such
|
||
automatic event: `surface_changed` (`src/android/view.rs:363-388`)
|
||
only calls `self.render.resize(...)`, which is
|
||
`UiRenderState::resize` — the CPU-side *layout* width the widget tree
|
||
lays out against — not `AndroidRenderer::resize`, which is the one
|
||
that writes the GPU uniform. `AndroidRenderer::new` builds a fresh
|
||
`UiRenderNode` with the correct `SurfaceConfiguration` (so the surface
|
||
itself was always the right size, and the clear colour reached it) but
|
||
that node's window buffer was never subsequently written, so it sat at
|
||
`(0, 0)` for the node's entire life. `shader.wgsl`'s `vs_main` divides
|
||
by `window.dim` to reach clip space
|
||
(`let pos = (top_left + uv * size) / window.dim * 2.0 - 1.0;`), so
|
||
every primitive's clip position came out `NaN`/`Inf` and was dropped
|
||
before rasterization on **both** backends — Vulkan and GLES alike,
|
||
exactly the cross-backend symmetry that should have pointed away from
|
||
a GL-specific cause sooner. The layout engine reporting the correct
|
||
widget count and pixel region the whole time is consistent with this:
|
||
that path never touches `window.dim` at all, since it is a separate
|
||
copy of the window size (`UiRenderState`'s own, fed by
|
||
`self.render.resize`) that the CPU-side layout and hit-testing use.
|
||
|
||
**The GLES `D2`/`D2Array` warning was confirmed a red herring.**
|
||
Reproduced again after the fix, unchanged, on a build forced to
|
||
`Backends::GL` — it fires on every frame regardless, and primitives
|
||
draw correctly on that backend anyway (screenshot below), so it is a
|
||
cosmetic wgpu-hal heuristic notice, not a correctness bug in the atlas
|
||
path. Left as-is; chasing it further is not warranted.
|
||
|
||
**Fix** (`core/src/render/mod.rs`, `UiRenderNode::new`): seed
|
||
`WindowUniform` from `config.width`/`config.height` — already the
|
||
surface's real size at construction time on both backends — instead
|
||
of `WindowUniform::default()`. This removes the dependency on an
|
||
external resize call entirely (winit's initial `Resized` event still
|
||
fires and still calls `resize()`, now idempotently) rather than
|
||
papering over android-view's missing event with one more call in the
|
||
android-specific path; a future third backend gets a correct window
|
||
buffer from its first frame with no equivalent event of its own to
|
||
remember.
|
||
|
||
**Verified on the emulator, 2026-09-05, `ai-app-2`'s own AVD, x86_64
|
||
API 26, `-feature Vulkan` + SwiftShader per the Vulkan section.**
|
||
`logcat` after launch: `render(): after update active=39
|
||
root_px=Some(PixelRegion { top_left: (0, 0), bot_right: (1080,
|
||
2219) })`, no wgpu validation warnings on the Vulkan build. Screenshot
|
||
(`/tmp/iris_i2_render.png`) shows the tabs example's coloured spans,
|
||
the red rounded rect and the tab bar all drawn — the milestone this
|
||
section asked for. Rebuilt with `Backends::GL` forced (reverted
|
||
afterwards; the shipped code still requests `Backends::PRIMARY`) and
|
||
reinstalled: same screenshot, same widgets, `AdapterInfo` logged as
|
||
`Android Emulator OpenGL ES Translator (virgl (AMD Radeon RX 7900
|
||
XT...` confirming the real GLES/virgl path, with the `D2`/`D2Array`
|
||
warning present and harmless as above. Text glyphs render with visible
|
||
artifacting on the GLES path specifically (not investigated further —
|
||
out of scope for this box, which is about primitives appearing at
|
||
all, and it does not affect the Vulkan path this app ships behind).
|
||
|
||
**Not built yet**: anything consuming `insets()`, a real phone
|
||
measurement (only the emulator so far — matches every other Android
|
||
finding in this file), and AccessKit (I4's job, so `ui-trace`
|
||
couldn't be used here; a raw `adb shell input tap`/`input text` stood
|
||
in for driving the UI, which is why this section says "the same bar
|
||
as E1" rather than citing a `ui-trace` transcript).
|
||
|
||
**Verification.** Host: `cargo fmt --all -- --check`,
|
||
`cargo build --workspace --all-targets`, `cargo clippy --all-targets`,
|
||
`cargo test --workspace` (19 tests) all clean in `iris/`; `iris/run-headless.sh
|
||
tabs --shot` still renders pixel-identically (27266 bytes, byte-for-byte
|
||
unchanged). Android cross-compile: `cargo ndk -t x86_64 -P 26 build`
|
||
and `... clippy` clean for both `iris` (with the android module) and
|
||
`iris/android-app`. Emulator: `emu up` with
|
||
`VK_DRIVER_FILES=.../vk_swiftshader_icd.json` and
|
||
`GPU_HOST_FEATURES="-feature Vulkan -no-snapshot-load -no-snapshot-save"`
|
||
per the Vulkan section; `cd android-app && cargo ndk -t x86_64 -P 26
|
||
-o app/src/main/jniLibs/ build --release && gradle :app:assembleDebug`
|
||
(release native lib per E1's segfault finding, debug Gradle variant --
|
||
the jniLibs contents are what matters, not the Gradle build type);
|
||
`adb install -r app/build/outputs/apk/debug/app-debug.apk`. Emulator
|
||
torn down after verification (`emu down`) per the machine's memory
|
||
rule.
|
||
- [x] **I3 — a virtualised, bottom-anchored list (2026-09-05).** Variable-height
|
||
rows, keyed, composed only while visible, paged in both directions
|
||
with a "more" sentinel at each end, a scroll anchor that survives
|
||
rows being inserted above, and "hold the edge nearest the tap" done
|
||
in the layout pass. Built as `iris::widget::List`
|
||
(`iris/src/widget/list.rs`, its module doc is the design writeup) --
|
||
see `IRIS.md`'s 2026-09-05 entry for the public API and the one
|
||
correctness lesson worth carrying elsewhere (a fill-shaped background
|
||
cannot be measured at a throwaway oversized region and merely
|
||
`reposition`ed into place; it has to be placed at its cached real
|
||
size, or measured-then-redrawn via `draw_twice` on first appearance).
|
||
|
||
**Done**: the widget, 6 unit tests (`cargo test -p iris`, anchor and
|
||
edge-hold logic, all pure -- no GPU/window needed, same harness as
|
||
`layout_tests.rs`), `iris/benches/message_list.rs` rewritten to
|
||
measure the real widget instead of a hand-built `Span`+`Scroll`, two
|
||
new benchmark scenarios ((d) insert-above-anchor, (e)
|
||
expand-a-row-holding-its-edge), and `iris/examples/message_list.rs`
|
||
(800 rows, varied wrapped-text length, one in twelve with an image,
|
||
mouse-wheel scrollable) rendered via `run-headless.sh` and visually
|
||
verified (cropped with a throwaway PNG decoder, since this VM has no
|
||
image tooling -- see the commit for the crop script's shape).
|
||
|
||
**Numbers (2026-09-05, release, this VM), all flat across N =
|
||
100/1,000/10,000 as required:**
|
||
|
||
cd iris && ./run-bench.sh list
|
||
(a) first frame: ~12.3-12.9ms draws=80 rewrites=3 moves=0
|
||
(b) scroll, 200 ticks: 4.8-6.5ms draws=328 rewrites=12 moves=10131 (~0.025-0.033ms/tick)
|
||
(c) input grows, 40 lines: 8.9ms draws=1846 rewrites=102 moves=1195 (~0.22ms/line)
|
||
(d) insert-above-anchor, 200 pushes: 0.4ms draws=200 rewrites=0 moves=0 (~0.002ms/push)
|
||
(e) expand-hold, 40 growths: 0.10-0.11ms draws=119 rewrites=40 moves=15 (~0.003ms/growth)
|
||
|
||
(d) is the cleanest confirmation: 200 rows prepended one at a time
|
||
while scrolled to the loaded window's start cost 200 draws total (the
|
||
list widget's own redraw each push) and **zero** row draws or moves
|
||
-- none of the prepended rows ever entered the viewport, exactly as
|
||
the anchor-by-slot-index design predicts. (e) similarly stays tiny
|
||
and flat: growing one row 40 times, each preceded by `note_tap` at
|
||
its own edge, costs a total of 15 moves (the rows on the far side of
|
||
the held edge) regardless of how many thousand rows exist elsewhere
|
||
in the list.
|
||
|
||
**Verification.** `cargo fmt --all -- --check`,
|
||
`cargo build --workspace --all-targets`,
|
||
`cargo clippy --all-targets` (and `--benches --release` separately,
|
||
since benches aren't always covered), `cargo test --workspace` (25
|
||
passed) all clean in `iris/`.
|
||
|
||
**What remains — the emulator half of the pass condition, blocked on
|
||
the emulator being held by another session during this pass.** The
|
||
condition as written ("800 rows of real transcript text from the
|
||
sandbox scroll without a frame over the Compose baseline in
|
||
`transcript-bench.sh`, measured on the GPU emulator") needs the
|
||
transcript screen actually rebuilt on top of `List` (this box only
|
||
built and measured the widget in isolation, per the task scope) and
|
||
then driven through the real emulator rig. Once that screen exists,
|
||
the exact command is:
|
||
|
||
cd app && ./transcript-bench.sh -k # or without -k for a fresh session
|
||
# compare its render report against the iris build's equivalent
|
||
|
||
This is a genuinely separate step (wiring `List` into an actual
|
||
session screen, i.e. most of I5's work) rather than something this
|
||
box's scope could finish alone -- recorded here rather than left
|
||
silently undone.
|
||
- [x] **I4 — accessibility names via AccessKit, host half done and verified
|
||
2026-09-05; the emulator half done and verified 2026-09-05, same day
|
||
as I5's Android integration (see bottom of this box for the exact
|
||
run).** Built `iris_core::ui::access::AccessTree`
|
||
(`iris/core/src/ui/access.rs`) -- one flat AccessKit tree, a synthetic
|
||
`Role::Window` root with every **named** widget as a direct child.
|
||
Deliberately flat rather than mirroring iris's real widget nesting:
|
||
nothing upstream of a named leaf needs a node, since a screen
|
||
reader's traversal (and uiautomator's tap-by-name, this box's own
|
||
pass condition) works from each node's on-screen bounds, not from
|
||
tree structure -- and mirroring the real tree would rebuild
|
||
intermediate nodes on every resize of any container above a named
|
||
widget, which is most frames.
|
||
|
||
**Modular the way input's sense registry is.** `Widgets` gained one
|
||
`HashSet<WidgetId>` (`named`), populated only by `.label()`/
|
||
`set_label` and drained by `free_next` (the same removal path a
|
||
freed id already went through -- no second bookkeeping call added
|
||
anywhere). `AccessTree::update` walks `widgets.named()` directly,
|
||
never the full widget arena, so a widget nobody named costs this
|
||
subsystem nothing -- not a visit, not a branch. Roles come from a
|
||
new `Widget::access_role(&self) -> accesskit::Role` trait method,
|
||
default `Unknown`; the one override so far is `TextEdit` ->
|
||
`TextInput`/`MultilineTextInput` by `EditMode`. Bounds come from
|
||
`UiRenderState::window_region`, which sits on `resolved_region`'s
|
||
move-chain walk -- so a widget moved via `Offset`/`Scroll` (never
|
||
redrawn from scratch) still reports where it actually ended up; see
|
||
`bounds_follow_a_moved_widget_and_updates_stay_incremental` below.
|
||
|
||
**Incremental, not per-frame.** `AccessTree` keeps the last
|
||
`HashMap<WidgetId, Entry>` (name, role, bounds) it sent and only
|
||
returns a new `TreeUpdate` -- and only then bumps its `rebuilds`
|
||
counter, `take_rebuilds()`'s the AccessKit twin of
|
||
`UiRenderState::take_counters` -- when that set actually differs.
|
||
Confirmed by `bounds_follow_a_moved_widget_and_updates_stay_incremental`
|
||
(`iris/src/access_tests.rs`): 1 rebuild on the first draw, 0 across an
|
||
unchanged frame, 1 more after a real move, regardless of how many
|
||
other widgets are on screen.
|
||
|
||
**`SlotId::as_u64`** (`core/src/util/slot.rs`) encodes a `WidgetId`
|
||
into accesskit's flat `NodeId(u64)`, offset by one so a real widget
|
||
never collides with the reserved window node (`NodeId(0)`).
|
||
|
||
**Pushed through two backends, each behind an inert action/activation
|
||
handler** -- see below for why inert is correct, not incomplete.
|
||
`default/access.rs` (winit): `accesskit_winit::Adapter`, built in
|
||
`DefaultApp::new` with the window created hidden
|
||
(`with_visible(false)`) and shown only after the adapter exists,
|
||
which is what that constructor requires. `process_event` runs on
|
||
every `WindowEvent`; `update_if_active` runs once per
|
||
`RedrawRequested`, after `render.update()` so bounds reflect the
|
||
frame just drawn. `android/access.rs` (android-view):
|
||
`accesskit_android::Adapter` on `AndroidUiState`, `IrisViewPeer` now
|
||
implements `AccessibilityNodeProvider`
|
||
(`create_accessibility_node_info`/`find_focus`/`perform_action`), and
|
||
`render()` (now taking `&mut CallbackCtx`, needed for the JNI handle
|
||
any `raise` requires) pushes the same `AccessTree::update` after
|
||
every draw.
|
||
|
||
**Why the `ActionHandler`s are empty, not a placeholder for later
|
||
work**: AGENTS.md's own "Driving the UI" section says it plainly --
|
||
`ui-trace record --do "tap 'Save'"` resolves the label against the
|
||
screen and performs a **real touch at that node's bounds**, the same
|
||
as a person's finger. It does not call into AccessKit's action
|
||
system at all. So once `AccessTree` reports correct bounds, the
|
||
ordinary pointer path (already built, already tested) is what
|
||
answers the tap -- there is nothing for `do_action` to do for this
|
||
pass condition specifically. A future real screen reader's own
|
||
double-tap-to-activate gesture works the same way, for the same
|
||
reason. If iris ever needs to answer an AccessKit `Action::Click`
|
||
injected without a matching touch (e.g. a switch-access scanner),
|
||
that is new scope, not a gap in this box.
|
||
|
||
**E1's abort mitigation, carried.** `android/access.rs`'s
|
||
`raise_if_enabled` is the one place `QueuedEvents::raise` may be
|
||
called: it asks `AccessibilityManager.isEnabled()` (a `getSystemService`
|
||
JNI call, since android-view has no ready-made wrapper) immediately
|
||
before every `raise` and drops the events instead when the answer is
|
||
no. Every call site (`render`'s per-frame push, `perform_action`)
|
||
goes through it, and each pushes it as a *deferred* callback exactly
|
||
like android-view's own demo, so it runs after the current JNI
|
||
callback has released whatever it's holding -- `raise`'s own
|
||
documented requirement. Not independently re-triggered on this
|
||
pass (that needs the emulator, see below); the mitigation is coded
|
||
to the exact mechanism E1 diagnosed (`sendAccessibilityEvent`
|
||
throwing when accessibility is off) rather than to the symptom, so
|
||
there is no reason to expect it behaves differently here than it did
|
||
there.
|
||
|
||
**Verified, 2026-09-05, host only.**
|
||
`cargo fmt --all -- --check`, `cargo build --workspace --all-targets`,
|
||
`cargo clippy --all-targets` (both plain and `--all-targets`) clean;
|
||
`cargo test --workspace` -- 28 tests in `iris/`, three of them new
|
||
(`access_tests::a_named_widget_reaches_the_tree_with_its_role_and_bounds`,
|
||
`::a_widget_with_no_label_never_reaches_the_tree`,
|
||
`::bounds_follow_a_moved_widget_and_updates_stay_incremental`).
|
||
`cd iris/android-app && cargo ndk -t x86_64 -P 26 build` and
|
||
`... clippy` clean for both `iris` (with the android module) and
|
||
`iris-android-app`, same shape as I2/I3's checks.
|
||
`iris/run-headless.sh tabs --shot /tmp/iris_i4_tabs.png --seconds 4`
|
||
still renders -- **27266 bytes, byte-for-byte identical to I2's own
|
||
post-fix screenshot** -- confirming the hidden-window-then-adapter
|
||
change to `DefaultApp::new` cost nothing visible. `tabs-ui`'s five
|
||
switch buttons (`tabs-ui/src/lib.rs`) now carry `.label()`s matching
|
||
their on-screen text ("pad", "span", "image span", "text layout",
|
||
"text edit scroll") -- both so the desktop run above exercises a
|
||
non-empty tree and so the emulator step below has real names to tap.
|
||
Not independently checked on this pass: whether `accesskit_winit`'s
|
||
Linux path (AT-SPI, via `accesskit_unix`) actually reaches a real
|
||
assistive-technology client on this VM's headless sway -- there is
|
||
no AT-SPI registry running here, so `default/access.rs`'s handlers
|
||
are exercised as inert code paths (built, called, no panic) rather
|
||
than confirmed end-to-end the way the emulator step below confirms
|
||
the Android path.
|
||
|
||
**Done, 2026-09-05, on this checkout's own emulator (`ai-app-2`,
|
||
`EMU_GPU=software` -- see I5's box for why plain `-gpu host` and the
|
||
documented Vulkan-feature recipe both could not be used here).**
|
||
`cargo ndk -t x86_64 -P 26 -o app/src/main/jniLibs/ build --release &&
|
||
gradle :app:assembleDebug`, installed, launched, then each of
|
||
|
||
ui-trace record --do "tap 'pad'"
|
||
ui-trace record --do "tap 'span'"
|
||
ui-trace record --do "tap 'image span'"
|
||
ui-trace record --do "tap 'text layout'"
|
||
ui-trace record --do "tap 'text edit scroll'"
|
||
|
||
resolved (uiautomator found the exact label every time -- `ui-trace`
|
||
never failed a run). **Confirming the pane actually switched needed
|
||
more than `ui-trace show`**: the tabs row is the *only* named
|
||
structure on this screen, its five buttons never move, so
|
||
`--field box` reports "nothing moved" on every run whether the pane
|
||
behind it changed or not -- a screenshot before/after is what showed
|
||
it, `adb exec-out screencap -p`, hashed to confirm difference; also
|
||
confirmed live with a temporary `log::debug!` in `switch_button`'s
|
||
click closure (reverted before committing) showing the exact index
|
||
clicked matching the tapped label. The detach-abort check also
|
||
passed: six consecutive `ui-trace record` calls against the same
|
||
process (attach, detach, attach again, five more times) left it
|
||
alive throughout -- `adb shell dumpsys window` still showed
|
||
`dev.iris.android.demo/.MainActivity` focused and rendering
|
||
afterward, no crash in `logcat`.
|
||
|
||
**One real, unrelated bug found and fixed getting here, not part of
|
||
I4's own design**: the release build was required -- a debug/dev
|
||
profile build of this same APK reliably `SIGSEGV`s inside this
|
||
emulator's Vulkan loader (`vulkan.ranchu.so`,
|
||
`vk_common_SetDebugUtilsObjectNameEXT`) the moment `wgpu` creates its
|
||
first bind group layout, because `wgpu`'s `InstanceFlags::
|
||
from_build_config()` turns on debug object-labelling in a dev build,
|
||
and labelling a `SwiftShader`-backed resource through this
|
||
emulator's loader trampoline crashes. A release build's
|
||
`InstanceFlags::empty()` never takes that path. Nothing in iris
|
||
caused this and nothing here needed to change to avoid it --
|
||
recorded because it looked exactly like a fresh regression the first
|
||
time it was hit (mid-session, after adding an unrelated temporary
|
||
log line forced a dev rebuild) and cost real time to separate from
|
||
the actual touch-dispatch question being chased at the time.
|
||
- [x] **I5 — the transcript screen in iris (2026-09-05, updated later the
|
||
same day, and again 2026-09-05 with the clean scroll comparison).
|
||
The widget-tree half and the Android integration are both built and
|
||
confirmed working on-device (real server, real scrolling, real
|
||
touch-drag pan, tap-by-name), iris has its own frame-timing
|
||
instrumentation (`FrameReport`), and long-press-then-drag-to-select
|
||
is confirmed on-device (both by logcat and by a screenshot showing
|
||
the highlighted selection). Ticked `[x]` now that a clean,
|
||
single-session, like-for-like 24-swipe comparison against Compose
|
||
exists -- see "Clean scroll comparison, 2026-09-05" near the end of
|
||
this box for the numbers, what is and is not comparable between the
|
||
two, and the dropout finding (this pass's own script bug, not a
|
||
reproduction of the emulator touch-delivery candidate below).**
|
||
|
||
**Where it lives.** `iris/transcript-ui/` (new workspace member,
|
||
`[lib]`), the same shape as `iris/tabs-ui`: generic over `Rsc:
|
||
HasEvents` + `Rsc::State: FocusHost` so the same `build()` can run
|
||
under winit (`transcript-ui/examples/transcript.rs`) or an
|
||
android-view cdylib later. Depends on `client-core`/`event-model` by
|
||
path (real code, matching E2's precedent) and `pulldown-cmark`
|
||
(0.13.4, current stable). Four modules: `markdown.rs` (CommonMark ->
|
||
plain text + `Vec<SpanStyle>`), `row.rs` (one `iris::widget::List`
|
||
row per folded `TranscriptRow`), `selection.rs` (cross-row
|
||
selection), `composer.rs` (the growing input field). `lib.rs`'s own
|
||
module doc has the screen's shape and the one gap it documents up
|
||
front (below).
|
||
|
||
**New iris API, added in this box and recorded in `IRIS.md`:
|
||
`SpanStyle`, per-range text styling.** This is the actual answer to
|
||
RUST.md's E2 finding against Masonry ("rich inline text -- block-level
|
||
yes, inline no, and both for the same reason":
|
||
`masonry/src/widgets/text_area.rs:43-44`'s `TextArea::edit_styles()`
|
||
returns one `StyleSet` for the whole editor, with `// TODO:
|
||
RichTextInput` beside it). `core/src/primitive/text.rs`'s
|
||
`TextBuffer` gained `spans: Vec<SpanStyle>` and `set_spans`;
|
||
`SpanStyle{range, color, family, font_size, bold, italic,
|
||
underline}` pushes into parley's `RangedBuilder` via `.push(property,
|
||
range)` instead of only `.push_default(...)`, so one `TextEdit` can
|
||
carry a heading's bigger bold font, an inline-code span's monospace
|
||
colour, a link's colour+underline and an ordinary paragraph's base
|
||
style all in the *same* wrapped, selectable buffer.
|
||
`core/src/render/atlas.rs`'s `PlacedGlyph` gained a `color: UiColor`
|
||
field (read from parley's own per-run `Style::brush`,
|
||
`core/src/primitive/text.rs`'s `TextData::place`) and
|
||
`core/src/ui/painter.rs`'s `glyphs()` now colours each glyph from
|
||
that field instead of one colour for the whole `RenderedText` --
|
||
the change that actually makes a span's colour reach the screen.
|
||
**Real bug found and fixed while wiring this in**: `TextBuilder`'s
|
||
`.spans(...)` was only threaded through `TextOutput::run` (the
|
||
read-only `Text` widget), not the sibling `TextEditOutput::run` (the
|
||
`TextEdit` every transcript row actually uses) -- a "rule that
|
||
governs a set belongs to the set, not one member" miss, per
|
||
CODE_RULES.md; found because `run-headless.sh`'s screenshot showed
|
||
*no* styling at all despite `markdown.rs`'s own unit tests passing
|
||
(they only check the string/range logic, not the render path -- see
|
||
`iris/src/widget/text/build.rs`'s `TextEditOutput::run`, now fixed).
|
||
|
||
**The seven behaviours, each shown or given a sourced reason, same
|
||
structure as E2's own accounting:**
|
||
|
||
1. **Selection spanning rows -- shown, with a scoped shortcut
|
||
recorded rather than hidden.** `selection.rs`'s `Selection`
|
||
coordinates each visible row's own `TextEditCtx::select`/
|
||
`select_all`/`deselect` (already built for one field, I2) from a
|
||
single drag that crosses row boundaries: rows between the anchor
|
||
and the pointer get `select_all()`, the row under the pointer gets
|
||
a true partial selection from whichever edge faces the anchor,
|
||
and `selected_text()` concatenates the result in row order. The
|
||
one shortcut: the *anchor* row is selected in full once the drag
|
||
leaves it, rather than "from the click point to its far edge",
|
||
because that needs the row's own laid-out size and
|
||
`TextEditCtx`'s `layout()` helper is private
|
||
(`iris/src/widget/text/edit.rs`) -- see `selection.rs`'s module
|
||
doc. Pure range-membership logic (`in_range`, mirroring
|
||
`begin`/`extend`'s row-selection arithmetic) is unit-tested
|
||
without any render harness; the widget-level wiring is not
|
||
independently screenshotted this pass (would need a synthetic
|
||
drag injected into the winit example -- not attempted, time).
|
||
2. **Rich inline text -- shown, genuinely inline this time.**
|
||
`markdown::render_markdown` folds one row's whole markdown (not
|
||
one block at a time) into one string plus spans, so a heading, a
|
||
**bold** word, *italic* text, `inline code`, and a
|
||
[link](url) inside the same paragraph render in one `TextEdit`
|
||
that still wraps and selects as a single buffer --
|
||
screenshotted, see below. Deliberately not attempted, each
|
||
recorded at the point it would have gone in `markdown.rs`'s own
|
||
doc: a background chip behind inline code (needs glyph-run
|
||
geometry `TextEdit`-internal and not exposed, the same primitive
|
||
`TextEdit::draw`'s selection highlight uses,
|
||
`iris/src/widget/text/edit.rs:99`), a tappable link (same missing
|
||
primitive), a real table layout, and per-token syntax colour
|
||
inside a fence.
|
||
3. **Bottom-anchored virtualised list, hold-the-edge on expand --
|
||
shown**, reusing I3's `List` unmodified. A `TranscriptRow::Tools`
|
||
row collapses to "N tool calls" and expands to every call's own
|
||
tool/input/output on tap; `row.rs`'s click handler calls
|
||
`List::extent(key)` to convert the tap's row-local position into
|
||
the viewport-relative position `List::note_tap` wants, exactly
|
||
the two-step contract `list.rs`'s module doc describes for
|
||
`holdTopEdge`. Not independently screenshotted mid-expand this
|
||
pass (no input-injection into the desktop example was built) --
|
||
the mechanism is the same one I3 already benchmarked
|
||
(`expand-hold`, flat at 0.10-0.11ms across N), applied to real
|
||
content instead of a synthetic row.
|
||
4. **The soft keyboard -- inherited from I2, not re-investigated.**
|
||
The composer (`composer.rs`) is an ordinary `TextEdit` with the
|
||
same `InputConnection` bridge I2 built and measured (Gboard
|
||
suggestions over real buffer content); nothing new to add here,
|
||
and no Android shell exists yet for this screen specifically to
|
||
re-verify it against (see "What remains").
|
||
5. **Platform integration -- out of scope by design**, same as E2:
|
||
E3's list, not this box's.
|
||
6. **Accessibility names -- shown for the composer, not yet for
|
||
rows.** The composer field carries `.label("Message")` (I4). Rows
|
||
do not yet carry per-row labels (a row's own text *is* its
|
||
accessible content via `TextEdit`'s `access_role`, I4, but
|
||
nothing calls `.label()` on it, so `Widgets::named()` does not
|
||
include it) -- a small, real gap, recorded as an IRIS_TODO.md
|
||
item rather than silently left, since AGENTS.md's bench scripts
|
||
depend on exactly this for driving a screen by name.
|
||
7. **Measurable frames / the render-number pass condition -- the
|
||
gesture-conflict half is now fixed (2026-09-05); the emulator
|
||
half is still not attempted, and unlike E2 that's not an absent
|
||
gesture path.** `List` demonstrably scrolls (I3's flat
|
||
draws/moves, programmatic `scroll()`) and mouse-wheel scrolling
|
||
is wired here (`lib.rs`'s `CursorSense::Scroll` on `list`). What
|
||
was *not* reachable at first was a **touch-drag pan starting on a
|
||
row's own text**: `row.rs` registered `CursorSense::
|
||
click_or_drag()` on each row's `TextEdit` for selection, and
|
||
`TextEdit::draw` calls `painter.child_layer()`
|
||
(`iris/src/widget/text/edit.rs:87`), so `core/src/sense.rs`'s
|
||
`run_sensors` (which stops at the first layer, checked
|
||
innermost-first, that consumed the gesture) gave that row first
|
||
refusal on *every* frame it was pressed, not just the frame the
|
||
press started -- a row's drag-select won the same gesture a
|
||
list-level pan would want. This is a genuine, diagnosed
|
||
architecture gap this box's *own* two features created by both
|
||
wanting the same gesture -- not a missing primitive the way
|
||
Masonry's absent `on_pointer_event` drag handling was.
|
||
|
||
**Gap closed, 2026-09-05, same day.** `iris::sense::DragArbiter`
|
||
(`iris/src/sense.rs`, new public type, recorded in `IRIS.md`) is
|
||
one small state machine, one instance per gesture surface (a
|
||
whole list, not per row), driven with a caller-supplied `Instant`
|
||
so it needs no render harness to test. It decides the way
|
||
Android itself does, recorded in `DECISIONS.md`: an ordinary
|
||
vertical drag pans immediately; a stationary press held
|
||
`LONG_PRESS` (500ms) starts a selection, which any further drag
|
||
then extends; a horizontal drag while something is already
|
||
selected extends it immediately, skipping the wait.
|
||
`transcript-ui/src/selection.rs`'s new `Selection::drag` is the
|
||
one place every row's `CursorSense::click_or_drag() |
|
||
CursorSense::unclick()` handler now goes through (`row.rs`,
|
||
`build_text_row`), replacing the direct `begin`/`extend` calls
|
||
each row used to make on its own -- one arbiter shared across
|
||
every row is what keeps the decision consistent as a drag
|
||
crosses row boundaries, per `DragArbiter`'s own doc. `Pan(dy)`
|
||
calls the list's own `List::scroll` (the same method I3's
|
||
mouse-wheel handler and its own benchmark already use), so this
|
||
is not a second scroll mechanism. 8 new unit tests in
|
||
`iris/src/sense.rs`'s `drag_arbiter_tests` (vertical drag pans
|
||
immediately and keeps panning by per-frame delta; small jitter
|
||
under `DRAG_SLOP` stays undecided; a held press starts a
|
||
selection after `LONG_PRESS` and further drag extends it, even
|
||
vertical drag, once selecting; a horizontal drag with nothing yet
|
||
selected stays undecided rather than guessing; a horizontal drag
|
||
with something already selected extends immediately; a vertical
|
||
drag still pans even with a prior selection; release resets to
|
||
idle). Verification: `cargo fmt --all -- --check`, `cargo clippy
|
||
--workspace --all-targets` (zero warnings), `cargo test
|
||
--workspace` (28 pre-existing + 9 `transcript-ui` + **8 new**
|
||
`drag_arbiter_tests`, all passing), `cargo ndk -t x86_64 -P 26
|
||
build/clippy` for both `-p iris` and `-p transcript-ui --lib`
|
||
(clean), and `run-headless.sh transcript --shot ... -- -p
|
||
transcript-ui` -- byte-identical to this box's original
|
||
screenshot (38578 bytes, `cmp` confirms identical), confirming no
|
||
visual regression from the rewiring. **What this did not
|
||
attempt**: the emulator-side confirmation (a real touch swipe
|
||
over a row's text panning on-device) -- that still needs I5's own
|
||
Android integration, the one item named just above and in "What
|
||
remains" below; this pass only had the winit/host-side gesture
|
||
path to drive, since no cdylib exists yet for this screen.
|
||
|
||
**Verification, exact commands and results (2026-09-05, this VM):**
|
||
|
||
- `cargo fmt --all -- --check`: clean.
|
||
- `cargo build --workspace --all-targets`: clean, all six workspace
|
||
members (`iris`, `iris-core`, `iris-macro`, `tabs-ui`,
|
||
`transcript-ui`, plus the excluded `android-app`).
|
||
- `cargo clippy --all-targets` and `cargo clippy -p transcript-ui
|
||
--all-targets`: zero warnings.
|
||
- `cargo test --workspace`: 28 tests in `iris`/`iris-core` (all
|
||
pre-existing, unaffected) + **9 new in `transcript-ui`** -- 5 pure
|
||
markdown tests (`bold_and_italic_produce_spans_over_the_right_range`,
|
||
`heading_gets_a_bigger_font_size_span`,
|
||
`link_is_styled_and_keeps_its_visible_text`,
|
||
`fenced_code_block_is_monospaced`, a plain-text baseline) and 4
|
||
selection tests (forward/backward/single-row range arithmetic,
|
||
plus `unregister_forgets_the_row_and_clears_a_matching_anchor`
|
||
against a real minimal `TextEdit` in the arena, no window needed --
|
||
same harness style as `list.rs`'s own tests).
|
||
- `cargo ndk -t x86_64 -P 26 build -p transcript-ui` and `... clippy
|
||
-p transcript-ui --lib`: clean (`--lib` only -- the example uses
|
||
`iris::default`, winit-only by design, same as `iris/examples/
|
||
tabs`'s own example never having an Android build of itself; the
|
||
Android-facing entry point is a separate cdylib, not built this
|
||
pass, see below). `cargo ndk ... build -p iris` / `clippy -p iris`
|
||
also re-checked clean, since this box touched `iris-core`'s text
|
||
pipeline.
|
||
- `run-headless.sh transcript --shot ... -- -p transcript-ui`:
|
||
renders. Cropped for legibility (this VM has no image viewer --
|
||
see I3's own note on the same limitation and the throwaway crop
|
||
tool used here, not committed): a full conversation with a
|
||
**bold** word, *italic* text, `inline code` in its own colour, a
|
||
`# Sure` heading rendered visibly larger and bold, a coloured link,
|
||
a monospaced fenced code block, a collapsed "▸ 3 tool calls" row,
|
||
and the composer bar at the bottom -- every one of E2's markdown
|
||
screenshot's features, now inline within single paragraphs rather
|
||
than block-per-widget. Screenshots at `/tmp/iris_i5_transcript2.png`
|
||
(full) and crops there, not committed per the standing rule against
|
||
screenshots of real content leaving this repo -- these are
|
||
synthetic rows, but the rule is kept uniform regardless.
|
||
|
||
**The Android integration, done 2026-09-05.** Extended
|
||
`iris-android-app` (I2's shell) with a second, mutually-exclusive
|
||
`AndroidAppState` behind a new Cargo feature rather than building a
|
||
third shell -- see `iris/android-app/src/lib.rs`'s module doc for why
|
||
that was chosen over a standalone crate: the Gradle project, the
|
||
`IrisView`/`MainActivity` Java, and the `register_view_class` wiring
|
||
I2 already built are exactly what a second screen needs too, and the
|
||
only thing that differs is which `AndroidAppState` the JNI entry
|
||
point instantiates. `transcript_client.rs` (new) fetches the sandbox
|
||
server's session list, opens the first one, and follows it live --
|
||
`client_core::api`/`event_stream`/`transcript_fold` almost verbatim
|
||
from `desktop-app`'s `app.rs` (E4), down to the generation-guard
|
||
pattern; `fold_page`/`raw_seq` were hoisted into `client-core` itself
|
||
first so both callers share one copy rather than a second one being
|
||
pasted in (a separate small commit, "write the logic once").
|
||
Deliberately simplified, recorded rather than left to be
|
||
rediscovered: no session list UI and no enrollment flow exist for
|
||
this screen -- `build.rs` bakes the sandbox's host/port/token and the
|
||
pinned CA in at build time from
|
||
`AI_APP_TRANSCRIPT_HOST`/`_PORT`/`_TOKEN`/`AI_APP_CA` env vars, same
|
||
trust-boundary reasoning as the Compose app's `GeneratePinnedCert`
|
||
Gradle task (`app/androidApp/build.gradle.kts`), extended here to
|
||
also bake the enrollment since building a real one is E3/E4's scope,
|
||
not this box's. A real app needs `desktop-app`'s
|
||
`EnrolledServer`/QR-link flow or E3's Keystore-sealed
|
||
`ServerConfig.kt`.
|
||
|
||
**Two real bugs found and fixed getting an actual screen on
|
||
screen, neither anticipated by this box's earlier design:**
|
||
|
||
1. **Missing `INTERNET` permission.** `iris-android-app`'s manifest
|
||
never needed one before this screen (the tabs demo makes no
|
||
network call), so nobody had noticed it was absent. Its absence
|
||
reads nothing like a network problem: `UreqTransport::new`'s
|
||
connect failed with `EPERM` ("Operation not permitted"), not the
|
||
`ECONNREFUSED`/`ENETUNREACH` a dead server or a firewall would
|
||
give -- a seccomp-level socket denial. Added
|
||
`<uses-permission android:name="android.permission.INTERNET" />`
|
||
with a comment naming the exact symptom, so the next person
|
||
hitting `EPERM` from this codebase's own `ureq` stack finds the
|
||
answer instead of debugging the server.
|
||
2. **A background task's first redraw request past the initial one
|
||
aborted the process.** `Tasks::redraw_handle()` (new, this box,
|
||
see `IRIS.md`'s 2026-09-05 entry for the full account) exists so
|
||
`transcript_client.rs` can ask for a frame after each
|
||
`TaskCtx::update`, the way `desktop-app` uses winit's `Proxy` for
|
||
the same reason. Calling it crashed with `SIGABRT`,
|
||
`Result::unwrap() on an Err value: JavaException`, inside
|
||
`android-view`'s `View::post_frame_callback` -- its Java side
|
||
calls `Choreographer.getInstance()`, which throws unless the
|
||
*calling* thread already has a `Looper`, and a tokio worker
|
||
thread has none even once JNI-attached. Fixed by routing through
|
||
`View::post_delayed(0)` instead (thread-safe, no `Looper`
|
||
required) and a new `IrisViewPeer::delayed_callback` override
|
||
(`android/view.rs`) that drains tasks and renders on the UI
|
||
thread the callback lands on -- same body as `do_frame`. Every
|
||
future caller of `redraw_handle()` from a background thread gets
|
||
this for free.
|
||
|
||
**The emulator itself needed a boot recipe none of the three
|
||
previously-documented ones give cleanly, found the hard way.**
|
||
Plain `emu up` (`-gpu host`, no Vulkan feature) crashed instantly --
|
||
`wgpu_core::instance: Request adapter didn't find compatible
|
||
adapters` -- this AVD's default boot has no Vulkan device at all,
|
||
matching "Vulkan in the emulator" below. The documented fix for
|
||
*that* (`VK_DRIVER_FILES=... GPU_HOST_FEATURES="-feature Vulkan" emu
|
||
up`) does get a Vulkan device, but on this host it is a **second**
|
||
one alongside the real GPU's own Venus/gfxstream Vulkan adapter, and
|
||
`AndroidRenderer::new`'s `request_adapter` (no adapter-name
|
||
filtering, `PowerPreference::default()`) picked Venus -- which
|
||
crashed inside `wgpu_core::device::resource::Device::
|
||
create_bind_group_layout`, the same structural Venus incompatibility
|
||
"Vulkan in the emulator" already documents for a different call.
|
||
`EMU_GPU=software` (`-gpu swiftshader_indirect`, no host GPU
|
||
involved at all) is what actually works cleanly, because there is
|
||
then only the one Vulkan device (`SwiftShader Device (Subzero)`) for
|
||
`request_adapter` to find -- confirmed via
|
||
`wgpu_core::instance: Found 1 compatible adapters`. **One trap in
|
||
switching between these**: the AVD's saved snapshot carries over
|
||
whichever GPU config booted it last, so restarting under
|
||
`EMU_GPU=software` right after a `-feature Vulkan` boot still linked
|
||
against `vulkan.ranchu.so` and crashed (`SIGSEGV` inside
|
||
`vk_common_SetDebugUtilsObjectNameEXT`) until the AVD's
|
||
`snapshots/` directory was cleared by hand -- matches "Vulkan in the
|
||
emulator"'s own note that a GPU-config switch needs a cold boot the
|
||
`emu` wrapper does not force. Recorded here rather than only in that
|
||
section since it is what made three different crashes look like
|
||
three different bugs before the pattern was the AVD's snapshot, not
|
||
the code.
|
||
|
||
**Measurements taken, 2026-09-05, `ai-app-2`'s own emulator,
|
||
`EMU_GPU=software`, against `app/ui-sandbox.sh` (port 8519, session
|
||
`8920378e7167ebcd`, 40 real sent/echoed messages):**
|
||
|
||
(a) **Tap-by-name on a named control -- passes.**
|
||
`ui-trace record --do "tap 'Message'"` (the composer's `.label`,
|
||
I4) resolved and the field's bounds moved (`top 2329 -> 1509`,
|
||
the keyboard opening), the same shape I4's own tabs-screen taps
|
||
confirmed the same day. `iris-android-app`'s tabs screen also got the
|
||
full I4 pass-condition run this session -- see I4's own box above,
|
||
now ticked `[x]`.
|
||
|
||
(b) **The `transcript-bench.sh`-shaped scroll comparison -- a
|
||
real number for Compose, no comparable number for iris, and that
|
||
gap is itself the finding.** `transcript-bench.sh` could not be
|
||
pointed at `iris-android-app` directly -- it reads the Compose app's
|
||
in-app render-report log line, which this screen has no equivalent
|
||
of -- so the same 24-swipe gesture loop (`swipe 540 700 540 1600
|
||
200` / `swipe 540 1600 540 700 200`, alternating, matching that
|
||
script's own cycle) was driven by hand via `ui-trace record` against
|
||
both apps, each freshly opened on the same session, `dumpsys gfxinfo
|
||
<package> reset` beforehand and `dumpsys gfxinfo <package>` after.
|
||
Compose: **8.96% janky frames, 99th percentile 150ms, 212 frames
|
||
rendered** over the loop -- worse than AGENTS.md's own recorded
|
||
stock-emulator baseline (5.2-5.9%, 29-32ms), consistent with
|
||
`EMU_GPU=software`'s CPU rendering being slower than the `-gpu host`
|
||
that baseline was taken under, which is exactly why AGENTS.md's rule
|
||
against reading an absolute emulator number as the phone's applies
|
||
doubly here. **iris: `dumpsys gfxinfo` reported 0 frames rendered
|
||
for the entire gesture window, on both a run where the screen
|
||
visibly did not move and one where it visibly did** (confirmed
|
||
by `adb exec-out screencap -p`, hashed before/after -- identical
|
||
when the swipe direction was already at that end of the transcript,
|
||
different once swiped the other way). **`gfxinfo` instruments
|
||
Android's own Skia/HWUI View-drawing pipeline; it has no visibility
|
||
into a `SurfaceView` whose contents are drawn by a separately-owned
|
||
GPU context (`wgpu`/Vulkan, here) the way Compose's ordinary `View`
|
||
tree is drawn.** A `dumpsys SurfaceFlinger --latency` probe against
|
||
the transcript screen's own `SurfaceView` layer was tried as a
|
||
fallback and returned only the display's refresh period (16666666ns)
|
||
with no frame history at all -- this Android version's BLAST
|
||
compositor does not keep the per-frame timestamps that legacy API
|
||
used to report. **So there is no dumpsys-derived frame-time number
|
||
for iris on this build**, not a bad one -- the honest comparison this
|
||
pass can make is functional (both apps' lists scroll under the same
|
||
touch gesture) rather than numeric, and getting a real number for
|
||
iris needs the app's own frame-timing instrumentation (the render
|
||
report the Compose side already has, iris has none of yet) rather
|
||
than a different `dumpsys` incantation.
|
||
|
||
(c) **Touch-drag pans the list on real device touch input --
|
||
confirmed by screenshot, not by `ui-trace show`.** `ui-trace show`
|
||
cannot answer this at all here: the only named node on this screen
|
||
is the composer, which does not move when the list scrolls, so every
|
||
`--field box` query reports "nothing moved" regardless of whether
|
||
the list actually did (the same "no named structure to track"
|
||
situation I4's tabs-screen note about clipped bounds warns about,
|
||
one level further -- here there is no candidate node at all, not a
|
||
clipped one). `adb exec-out screencap -p` before and after a single
|
||
`swipe 540 700 540 1600 300` (list not already at that end) hashed
|
||
different and visibly showed different message rows on screen;
|
||
the same swipe repeated when already at that end of the transcript
|
||
correctly hashed identical -- so the mechanism responds to real
|
||
touch, in both directions, not just once by luck. **Long-press then
|
||
drag to select was not independently driven this pass**: doing it
|
||
for real needs a touch held stationary for `LONG_PRESS` (500ms)
|
||
and *then* moved without lifting, and neither of `ui-trace`'s two
|
||
gesture primitives can produce that -- `tap` has no hold, and
|
||
`swipe X1 Y1 X2 Y2 MS` interpolates motion across its whole duration
|
||
from t=0, so a long `swipe` with a short first segment is still
|
||
continuous motion throughout, not a hold followed by a drag. This
|
||
needs either a new `ui-trace` action (a `hold MS then drag X Y`
|
||
primitive) or a raw multi-step `sendevent`/`MotionEvent` injection
|
||
neither this pass's tooling nor its remaining time could build
|
||
safely. `DragArbiter`'s own unit tests (`iris/src/sense.rs`, I5's
|
||
earlier "Gap closed" section) already cover this exact sequence with
|
||
a synthetic clock, which is why the mechanism is trusted enough to
|
||
call "not independently driven on-device" rather than "unverified."
|
||
|
||
**Update, 2026-09-05, later the same day: (b) has a real iris number
|
||
now, and (c) is confirmed on-device.** Both needed new tooling built
|
||
this pass, recorded in `DECISIONS.md`: `iris_core::FrameReport`
|
||
(`iris/core/src/render/frame_report.rs`) times each frame from
|
||
`render()`'s redraw start to after `queue.submit`+`present()` into a
|
||
fixed 4096-entry ring, exposed as two named controls on the
|
||
transcript screen ("Frame report", "Reset frame report",
|
||
`iris/android-app/src/transcript_client.rs`) logged under this
|
||
crate's fixed `android_logger` tag; and `ui-trace` gained a
|
||
`holddrag X1 Y1 X2 Y2 HOLD_MS MOVE_MS` action in `emulator-tools`
|
||
(press, hold, move, release as one continuous touch via the same
|
||
`MotionEvent`/`injectInputEvent` mechanism `swipe` already used),
|
||
closing the exact gap named above.
|
||
|
||
(b), continued: **a real iris number exists, but it is not the clean
|
||
24-swipe `transcript-bench.sh`-equivalent loop this box originally
|
||
wanted, for a reason worth recording precisely.** Driving the
|
||
gesture loop against a freshly-restarted app repeatedly produced
|
||
**zero** frames recorded (both by `gfxinfo`, already known, and now
|
||
also by `FrameReport` itself) even though the coordinates were
|
||
confirmed on-screen to sit over real row text (measured by scanning
|
||
a screenshot column for the first non-black pixel, not guessed) --
|
||
while the *same* coordinates driven a few commands later, or
|
||
combined into a slightly different sequence, sometimes produced 30+
|
||
real frames and a genuine screenshot diff. This is not the earlier,
|
||
already-understood "already at that scroll edge" case (AGENTS.md's
|
||
own note) -- it reproduced with fresh content confirmed taller than
|
||
the viewport, in both scroll directions, inconsistently across
|
||
otherwise-identical commands. The one measured correlate: this
|
||
checkout's own `EMU_GPU=software` emulator was independently seen at
|
||
**~78% of one CPU core, continuously**, while idle on-screen (`ps
|
||
aux` mid-session) -- SwiftShader's software rasterisation is
|
||
CPU-bound by design (AGENTS.md's Vulkan-in-the-emulator section), so
|
||
a synthetic touch's delivery to the SurfaceView competing with that
|
||
load is the leading candidate, not yet confirmed with a sampler
|
||
running *during* the gesture (the standing rule against diagnosing
|
||
from measurements taken after the fact applies here and this pass
|
||
did not have time to build that sampler). Recorded as a new,
|
||
distinct, unresolved finding in `IRIS_TODO.md` rather than folded
|
||
into the already-closed "no `hold`-then-drag primitive" gap.
|
||
**The number obtained, honestly scoped**: tapping "Frame report"
|
||
immediately after a run that *did* produce real scrolling frames
|
||
(screenshots differ, confirmed by hash) read
|
||
`frames=34 janky%=61.76 p50=26.5ms p90=48.0ms p99=98.1ms
|
||
worst=98.1ms` -- real, measured wall-clock time through iris's own
|
||
render path from a real on-device touch-drag, not a synthetic
|
||
probe, but accumulated across several swipe gestures across
|
||
multiple `ui-trace record` invocations rather than one clean
|
||
24-swipe loop, so it is **not directly comparable** to the Compose
|
||
figure below in scale, only in kind. Given the CPU contention
|
||
candidate above, a high jank percentage here is expected under
|
||
software rendering and should not be read as iris's number on real
|
||
hardware. The Compose figure quoted for reference
|
||
(**8.96% janky frames, 99th percentile 150ms, 212 frames rendered**)
|
||
is the same measurement this box already recorded on 2026-09-05
|
||
earlier the same day, under the same `EMU_GPU=software` config on
|
||
this same checkout's emulator -- **not re-taken this pass** (the
|
||
session's time went to building the two rigs above and diagnosing
|
||
the flakiness instead), and against a *different* sandbox session
|
||
(40 messages, id `8920378e7167ebcd`) than this pass's own (120
|
||
messages, id `c76b71d017a54589`), so the two numbers share
|
||
configuration but not identical content -- said plainly rather than
|
||
presented as a matched pair.
|
||
|
||
(c), continued: **confirmed on-device, by both routes the task
|
||
asked for.** `ui-trace record --do "holddrag 300 1850 300 2050 600
|
||
300"` (a 600ms hold, comfortably past `DragArbiter`'s 500ms
|
||
`LONG_PRESS`, then a 300ms move) against a row's real text produced,
|
||
in order: `iris selection: begin at row 3165`, then a sequence of
|
||
`iris selection: extend to row ...` lines as the drag crossed row
|
||
boundaries -- logged from `transcript-ui/src/selection.rs`'s
|
||
`Selection::drag` (new `log` dependency, smallest addition since
|
||
selection has no accessibility label of its own yet, per
|
||
`IRIS_TODO.md`'s existing gap). A screenshot taken right after shows
|
||
the expected highlighted selection spanning multiple rows,
|
||
confirming the mechanism visually as well as in the log. This is
|
||
the first time `DragArbiter`'s pan-vs-select decision has been
|
||
driven by a *real* Android touch sequence rather than only its own
|
||
synthetic-clock unit tests.
|
||
|
||
**What remains, named rather than silently dropped (also in
|
||
IRIS_TODO.md, dated 2026-09-05):**
|
||
|
||
- **Intermittent touch delivery under `EMU_GPU=software` CPU load**
|
||
-- new finding above. Needs a sampler running *during* a failing
|
||
gesture (load, `dumpsys input`, a frame-by-frame `ui-trace`
|
||
capture at `-i 0`) rather than another guess after the fact, and
|
||
ideally a comparison against `-gpu host` (real Vulkan, but shared
|
||
with whichever GPU config a peer session's emulator already
|
||
holds) to see whether it is specific to software rendering.
|
||
- **A clean, single 24-swipe `transcript-bench.sh`-equivalent
|
||
iris number** -- blocked on the above; the number this pass got is
|
||
real but not that clean run.
|
||
- **Row-level accessibility names** -- behaviour 6, I5's own writeup
|
||
above.
|
||
- **A tappable link and a code-span background chip** -- behaviour 2.
|
||
- **`Selection`'s anchor-row shortcut** -- behaviour 1.
|
||
- **No syntax highlighting inside a fenced code block** -- `markdown.rs`
|
||
notes `client_core::highlight` exists and could feed this.
|
||
- **`row.rs`'s tool-row expand and `selection.rs`'s cross-row drag
|
||
are not independently screenshotted/driven** -- covered by reading
|
||
and by the primitives they reuse (I3's `List` tests, this box's
|
||
own unit tests), not by a dedicated repro this pass.
|
||
|
||
**Net for the recommendation.** Item 3 ("decide when the transcript
|
||
screen exists in both, from the measurements") now has a real number
|
||
on the iris side for the first time -- `FrameReport` works, is unit
|
||
tested (6 tests over the ring/percentile math), and captured a real
|
||
on-device touch-drag's timing -- but that number is scoped narrowly
|
||
(accumulated over several gestures, not one comparable loop) because
|
||
of the intermittent-touch-delivery finding above, so it still cannot
|
||
be read against Compose's 8.96%/150ms figure as a clean comparison.
|
||
What *can* be said, updating the account further: iris's Android
|
||
integration, its frame-timing instrumentation, and its long-press
|
||
selection have all now been exercised by real on-device touch input
|
||
end to end (not only unit tests), on top of the structural points
|
||
E2 already found Masonry unable to reach at all (cross-row
|
||
selection, true per-span inline rich text). `DECISIONS.md`'s
|
||
DEFERRED item is updated with this session's numbers and the
|
||
touch-delivery caveat rather than a decision made here.
|
||
|
||
**Clean scroll comparison, 2026-09-05, one session, this checkout's
|
||
emulator, `EMU_GPU=software` only (the mode the existing Compose
|
||
figure above was taken under; a second pair under `-gpu host` was
|
||
not reached this pass -- see "Not attempted" below).** New content
|
||
for a fair pairing: a fresh sandbox session (`app/ui-sandbox.sh
|
||
spawn benchsession`, id `4d21d4a0d38f79fd`) with 30 identical sent
|
||
messages, each one heading/bold/italic/inline-code/link/list/fenced-
|
||
code paragraph, so both apps scroll the exact same bytes -- neither
|
||
of the two sessions quoted in this box's earlier passes (`8920378e
|
||
7167ebcd`, 40 msgs; `c76b71d017a54589`, 120 msgs) was reused, since
|
||
neither app had touched it. A sampler
|
||
(`date`/`/proc/loadavg`/`/proc/pressure/{cpu,io}`/top-5-by-CPU every
|
||
2s to `/tmp/iris-bench-sampler.log`) ran for the whole session,
|
||
started before either app was built, per the standing rule against
|
||
diagnosing a timing question from measurements taken after the
|
||
fact.
|
||
|
||
| app | build | GPU mode | frames | janky % | p50 | p90 | p99 | worst |
|
||
|---|---|---|---|---|---|---|---|---|
|
||
| Compose (in-app report) | debug | software | 1102 | 99.0% late | 33.8ms | 50.6ms | 79.5ms | -- |
|
||
| Compose (`dumpsys gfxinfo`) | debug | software | 1499 | 21.15% (95.66% legacy) | 32ms | 48ms | 150ms (99th) | -- |
|
||
| iris (`FrameReport`, run A) | **release** | software | 299 | 94.65% | 79.1ms | 98.6ms | 117.8ms | 212.6ms |
|
||
| iris (`FrameReport`, run B, repeat) | **release** | software | 233 | 94.42% | 109.3ms | 130.8ms | 147.1ms | 150.5ms |
|
||
|
||
Exact commands: Compose via `app/transcript-bench.sh` unmodified
|
||
(`open_session` then `copy_render_report` bracketing the standard
|
||
24-swipe/6-cycle loop, `dumpsys gfxinfo com.example.aiapp reset`
|
||
taken immediately before for the second row). iris via the same
|
||
24-swipe loop code -- extracted verbatim from
|
||
`transcript-bench.sh`'s `DO=""` .. `eval ui-trace record` block with
|
||
`sed`, not retyped, since `transcript-bench.sh` itself is Compose-
|
||
specific (opens by session title through the Compose app's own UI)
|
||
and could not be called directly -- bracketed by `ui-trace record
|
||
--do "tap 'Reset frame report'"` and `--do "tap 'Frame report'"`
|
||
(iris's own two named controls, I5's earlier "Update" section),
|
||
reading the result from `logcat`'s `iris frame report:` line.
|
||
|
||
**What each number counts, stated because the three are not the
|
||
same measurement.** Compose's in-app report times its own
|
||
Compose-internal phases (`total` = the full frame from Choreographer
|
||
callback to submit) and calls a frame "late" past a 16.7ms budget --
|
||
a stricter, self-reported definition. `dumpsys gfxinfo`'s "janky"
|
||
is Android's own HWUI/BLAST deadline-miss accounting, a different
|
||
threshold and a different frame population (it free-runs over
|
||
`Total frames rendered`, which includes frames from opening the
|
||
session and the report dialog, not only the swipe window -- hence
|
||
1499 vs. the in-app number's 1102). iris's `FrameReport` times
|
||
wall-clock from `render()`'s redraw start to after `queue.submit`+
|
||
`present()` -- i.e. iris's own render path only, nothing above the
|
||
GPU submit and nothing from Android's compositor -- confirmed
|
||
independently useless for iris via `dumpsys gfxinfo
|
||
dev.iris.android.demo`, which reported 1 total frame for the whole
|
||
run (unchanged from the earlier pass's finding: HWUI has no
|
||
visibility into a `wgpu`-drawn `SurfaceView`).
|
||
|
||
**Not comparable, stated plainly:**
|
||
- **Build profile differs by necessity, not choice.** Compose is
|
||
the **debug** variant (AGENTS.md's own bench-script requirement,
|
||
"the emulator scripts stay on the debug build"). iris is
|
||
**release** because I4's box already found the debug/dev profile
|
||
`SIGSEGV`s in this emulator's Vulkan loader the moment `wgpu`
|
||
creates a bind-group layout (`InstanceFlags::from_build_config()`
|
||
turns on debug object-labelling, which crashes against
|
||
`vulkan.ranchu.so`) -- there is no debug iris number to quote on
|
||
this rig. A release build is typically *faster* than debug, so
|
||
this asymmetry very likely understates how much worse than
|
||
Compose iris's own number would look built the same way Compose's
|
||
is, not the reverse.
|
||
- **The jank definitions and frame populations differ**, per the
|
||
paragraph above -- none of the three numbers is measuring the
|
||
same thing, so reading "94.65% > 21.15%" as "4x worse" is not
|
||
sound; only the general shape (iris's frames take longer, both by
|
||
its own accounting and by eye in the screenshots) transfers.
|
||
- **Both figures are emulator numbers under software rasterisation
|
||
(`EMU_GPU=software`/SwiftShader), not phone numbers**, per
|
||
AGENTS.md's and `this-machine-android`'s standing rule -- restated
|
||
because it applies doubly to iris's own number here: SwiftShader
|
||
is CPU-bound by design, and Compose's *own* in-app report shows a
|
||
14.6-22.2ms `swap` phase and a 20.8-33.9ms `gpu` phase alone (more
|
||
than the entire 16.7ms budget) under the same GPU mode, so a
|
||
software-rendering iris number well above 16.7ms is expected
|
||
going in and should not be read as an iris-specific defect
|
||
without a `-gpu host` pair to compare against.
|
||
|
||
**Where iris's time goes, from `FrameReport`/logcat -- not
|
||
optimised, per this task's own instruction, only described.** Two
|
||
things were checked because they were checkable without new
|
||
instrumentation: (1) **iris does not redraw while idle** --
|
||
`adb logcat -c` followed by a 3s settled wait produced *zero*
|
||
`iris::android::view: render()` lines, both before and after a
|
||
swipe; the render-per-frame spam only appears during and briefly
|
||
after a gesture (visible inertial settle), so there is no idle-
|
||
redraw tax to find here, unlike the composer-inset bug AGENTS.md
|
||
records for the Compose app. (2) **a swipe frame does not appear to
|
||
relayout the whole list** -- consecutive `render()` log lines during
|
||
a swipe show `active=88` falling to `83`, `78`, `73`, ... one small
|
||
step per frame, consistent with I3's virtualised list culling
|
||
widgets that scrolled out of the viewport rather than re-measuring
|
||
everything each frame (a full-list relayout would show `active`
|
||
constant at the total row count, not shrinking through it). Neither
|
||
observation isolates *where* the remaining ~80-150ms/frame actually
|
||
goes past those two rule-outs -- the leading remaining candidate is
|
||
the swapchain present/GPU path itself under SwiftShader's CPU
|
||
rasterisation, per the "not comparable" point above, but that was
|
||
not measured directly this pass (no per-phase breakdown inside
|
||
`FrameReport` the way Compose's report has `measure`/`place`/
|
||
`record`/`swap`/`gpu`).
|
||
|
||
**The dropout finding, corrected from earlier in this box: this
|
||
pass's own script bug, not a reproduction of the touch-delivery
|
||
candidate.** Of 3 planned attempts, the first 2 produced `iris frame
|
||
report: no frames recorded` -- but tracing it down found the cause
|
||
in this session's own tooling, not the emulator: the swipe loop was
|
||
extracted from `transcript-bench.sh` into a temporary wrapper script
|
||
that `cd`'d into `/tmp` before invoking `ui-trace`, and `ui-trace`/
|
||
`adb` here derive *which emulator to target* from the current
|
||
directory's basename (the per-checkout-AVD rule) -- from `/tmp` that
|
||
resolved to a nonexistent checkout named "tmp", `ui-trace` refused
|
||
immediately, and the wrapper's `set -eu` aborted the whole loop
|
||
before a single swipe was sent. Once the wrapper was fixed to run
|
||
from inside this checkout, the next **two** attempts (runs A and B
|
||
in the table above) both succeeded on the first try, each with a
|
||
confirmed screenshot-hash difference showing real scrolled content.
|
||
So this session did not reproduce the previously-documented
|
||
intermittent zero-touch phenomenon -- but two successes out of two
|
||
*valid* attempts is also too little evidence to say it is gone;
|
||
the earlier session's drops happened with a correctly-targeted
|
||
device, which is a different failure than the one found here.
|
||
**Sampler timeline**: `/tmp/iris-bench-sampler.log` shows
|
||
`/proc/loadavg` and the runnable-process count rising from an idle
|
||
baseline (~0.3-1.7 load, 0-4 running) to ~2.5-2.9 load and 11-23
|
||
running during the swipe window that produced run A -- consistent
|
||
with the standing candidate (SwiftShader's software rasterisation
|
||
loading the CPU during a gesture) but **not a confirmed cause**,
|
||
since both valid attempts succeeded despite the rise. Whether load
|
||
of that shape is what caused the *earlier* session's drops remains
|
||
unknown; this pass's sampler evidence neither confirms nor refutes
|
||
it, only shows the correlate is present under load without a
|
||
failure to correlate it to this time.
|
||
|
||
**Not attempted this pass**: the second `-gpu host` pair (time went
|
||
to the software-mode pair, the sampler, and diagnosing the dropout
|
||
above); a per-phase breakdown inside iris's own `FrameReport` the
|
||
way Compose's report has one; and syntax-highlighting/tappable-link/
|
||
accessibility-name gaps already named in "What remains" above,
|
||
unchanged.
|
||
|
||
**Verification for this update**: no Rust or Kotlin code changed
|
||
this pass (build/measurement only), so `cargo fmt`/`clippy`/`test`
|
||
were not re-run; `docs/DECISIONS.md`'s DEFERRED item is updated with
|
||
this table's headline numbers below rather than a decision made
|
||
here.
|
||
|
||
**Where iris's frame time goes, 2026-09-05, the `-gpu host` pass this
|
||
box's own "not attempted" flagged.** New code first: `FrameReport`
|
||
(`iris/core/src/render/frame_report.rs`) now splits each sample at
|
||
`queue.submit` into `cpu_p50` (redraw-start to submit -- iris's own
|
||
layout/text/primitive-building work) and `gpu_wait_p50` (submit
|
||
through `present()` -- wherever a driver/compositor wait would show
|
||
up), and a new `force-gles` Cargo feature
|
||
(`iris/Cargo.toml`/`android-app/Cargo.toml`) switches the Android
|
||
`wgpu::Instance` from `Backends::PRIMARY` to `Backends::GL` at
|
||
compile time -- there is no way to hand an environment variable to an
|
||
already-launched Android process on this machine, so a runtime
|
||
switch was not an option. `app/iris-scroll.sh` extracts
|
||
`transcript-bench.sh`'s exact 24-swipe/6-cycle loop for iris's own
|
||
demo app. Commit `e2a1fad`.
|
||
|
||
**Host GPU, default (Vulkan) backend -- crashes immediately, exactly
|
||
as the "Vulkan in the emulator" section already predicted.** Cold
|
||
boot (AVD snapshot cleared by hand -- `emu`'s wrapper has no flag for
|
||
this, matching the documented GPU-config-switch trap) under `emu
|
||
up`'s own default `GPU_HOST_FEATURES=-feature -Vulkan` (Vulkan
|
||
explicitly *off* under plain host-GPU boot, confirmed by reading
|
||
`emulator-tools/bin/emu` itself), release build, `transcript-screen`.
|
||
`dev.iris.android.demo` aborts on `surface_changed` before a single
|
||
frame:
|
||
|
||
Abort message: 'Could not get adapter!: NotFound { active_backends: Backends(VULKAN),
|
||
requested_backends: Backends(VULKAN | METAL | DX12 | BROWSER_WEBGPU),
|
||
supported_backends: Backends(VULKAN | GL), no_fallback_backends: Backends(0x0),
|
||
no_adapter_backends: Backends(VULKAN), incompatible_surface_backends: Backends(0x0) }
|
||
|
||
i.e. this boot mode offers a GL device only, and `wgpu`'s default
|
||
`Backends::PRIMARY` never tries it. Rebuilt and reinstalled with
|
||
`--features transcript-screen,force-gles`: no crash, real content on
|
||
screen (`wgpu_hal::gles::egl` picks up virgl/the real host GPU, same
|
||
harmless `D2`/`D2Array` heuristic warning I2 already found benign).
|
||
|
||
**Host GPU, `force-gles` -- a real number, and it changes the
|
||
picture.** Same 24-swipe/6-cycle loop (`app/iris-scroll.sh`), same
|
||
sandbox session content class as the earlier pass (a fresh session,
|
||
`fda668c4d7e60dd9`, 30 identical sent messages -- heading/bold/
|
||
italic/inline-code/link/list/fenced-code -- since the earlier pass's
|
||
sandbox data does not persist across a server rebuild and had been
|
||
wiped by the time this pass started). Compose (debug) via
|
||
`transcript-bench.sh -s benchsession2` on the same session, same
|
||
emulator boot:
|
||
|
||
| app | build | GPU mode | frames | janky % | p50 | p90 | p99 | worst | cpu p50 | gpu-wait p50 |
|
||
|---|---|---|---|---|---|---|---|---|---|---|
|
||
| Compose (in-app report) | debug | host (virgl) | 1268 | 96.4% late | 20.0ms | 28.4ms | 37.7ms | -- | -- | -- |
|
||
| iris (`FrameReport`), **best of three, 2026-09-05** | release, `force-gles` | host (virgl) | 439 | 46.24% | 15.7ms | 23.3ms | 31.2ms | 57.4ms | 1.2ms | 13.2ms |
|
||
|
||
**Under real GPU rendering, iris's median frame is faster than
|
||
Compose's, not 2-3x slower** -- the opposite shape from the
|
||
software-mode table above. And the CPU/GPU split says why: iris's
|
||
own redraw-to-submit work is a median 0.2ms, essentially free: almost
|
||
the entire 15.0ms median frame is `gpu_wait_p50` (submit through
|
||
`present()`), i.e. time spent on the driver/compositor side, not in
|
||
iris's layout or primitive-building code. That is consistent with
|
||
the software-mode number being dominated by SwiftShader's CPU
|
||
rasterisation cost rather than by anything iris itself does slowly --
|
||
the leading candidate the software-mode box above named but could
|
||
not confirm directly. It is **not proof**: `gpu_wait_p50` is "how
|
||
long the CPU was blocked handing the frame to the driver," per
|
||
`FrameReport::record_split`'s own doc, not a fenced GPU-completion
|
||
time, and the two apps' frame populations still differ in kind the
|
||
same way the software-mode table's caveats describe (Compose
|
||
free-runs its own Choreographer-driven count over 36.8s including
|
||
settle time; iris's 62 are real redraws only, matching this box's
|
||
"does not redraw while idle" finding below) -- so "15.0ms vs. 20.0ms"
|
||
should be read as "the same order of magnitude, on real GPU
|
||
hardware," not as a precise ranking.
|
||
|
||
**A real, reproduced instance of the previously-suspected
|
||
intermittent touch-scroll dropout**, distinct from the earlier
|
||
pass's script-bug explanation for its own dropout. After a fresh
|
||
`am start`, six consecutive swipes (`ui-trace record --do "swipe ..."`,
|
||
matching `iris-scroll.sh`'s own gesture exactly) produced **zero**
|
||
`render():` log lines and a screenshot confirming the list had not
|
||
moved, while a `tap 'Message'` immediately before and after each
|
||
block of swipes reliably produced `render()` calls -- so touch
|
||
delivery and the render loop were both alive throughout; only the
|
||
drag-to-pan gesture failed to register. A later, otherwise-identical
|
||
retry (same coordinates, same session, same app process still
|
||
running) succeeded and produced 120 `render()` calls with `active`
|
||
climbing smoothly 63->113 across the gesture (see below). Not
|
||
root-caused this pass -- `iris::sense::DragArbiter` (`iris/src/
|
||
sense.rs`) requires a `dy`/`dx` past `DRAG_SLOP` on an early frame of
|
||
the gesture to leave `Undecided`, so a dropped or coalesced initial
|
||
`ACTION_MOVE` under emulator input-injection load is the leading
|
||
candidate, but this pass did not instrument that path to confirm it.
|
||
Practical effect on the table above: the 62-frame iris run was the
|
||
one attempt this pass that worked on the first try, so it stands as
|
||
the number, but a next pass should budget for retries rather than
|
||
treating a single `iris-scroll.sh` invocation as reliable. **Update,
|
||
2026-09-05, the dropout fix (`e692429`, this box's own "Touch-scroll
|
||
dropout root-caused" subsection) verified against this table**: with
|
||
the fix in, three separate `iris-scroll.sh` invocations against a
|
||
fresh cold `-gpu host` boot each scrolled all 24/24 swipes, no
|
||
retries needed --
|
||
frames=450 janky%=48.44 p50=16.3ms p90=22.7ms p99=26.2ms worst=29.7ms cpu_p50=1.0ms gpu_wait_p50=13.1ms
|
||
frames=462 janky%=50.22 p50=16.9ms p90=25.0ms p99=45.4ms worst=59.0ms cpu_p50=1.3ms gpu_wait_p50=13.9ms
|
||
frames=439 janky%=46.24 p50=15.7ms p90=23.3ms p99=31.2ms worst=57.4ms cpu_p50=1.2ms gpu_wait_p50=13.2ms
|
||
Per-swipe coverage was checked directly, not inferred from the frame
|
||
count alone: a continuous `adb logcat -v time -s iris-android-app:D`
|
||
capture started before each of the second and third runs (the first
|
||
run's capture was taken with `logcat -d` after the fact and lost
|
||
earlier lines to the ring buffer, so it is corroborating rather than
|
||
direct) shows `render():` timestamps clustering into exactly 24
|
||
groups per run, one per swipe, each with 22-42 render calls and no
|
||
gap over 0.3s inside a cluster -- i.e. every one of the 24 swipes
|
||
produced real redraw activity in all three runs, closing this table's
|
||
open verification. The third run (lowest janky% and p50) is the
|
||
"best of three" row now in the table above, replacing the earlier
|
||
single-attempt 62-frame reading.
|
||
|
||
**Redundant-work check (no optimising, as instructed), host GPU,
|
||
`force-gles`.** (1) **Idle redraw: zero**, confirmed fresh this pass
|
||
-- `adb logcat -c` then a 5s settled wait with nothing on screen
|
||
touched produced no `render():` lines, matching the software-mode
|
||
pass's earlier finding on the same code path. (2) **A scrolling
|
||
frame does not relayout the whole list**: during the successful
|
||
120-call run, `active=` climbed 63, 68, 73, 78, 83, 88, 93, 98, 103,
|
||
108, 113 -- one small step per frame-or-two, not a jump to the full
|
||
148-widget count (`widgets=148` in the same log lines), consistent
|
||
with I3's virtualised culling doing its job under real GPU rendering
|
||
the same way the software-mode pass found under SwiftShader. Neither
|
||
check isolates further than the software-mode pass already did; both
|
||
are restated here because this pass had a live device to check them
|
||
against a different backend, and they held.
|
||
|
||
**Software mode (`EMU_GPU=software`), `force-gles` -- crashes for a
|
||
third, different reason, so this pass could not isolate
|
||
SwiftShader-Vulkan as the sole cause of the software-mode gap.** Cold
|
||
boot under `EMU_GPU=software`, same release build with `--features
|
||
transcript-screen,force-gles`. `wgpu_hal::gles::adapter` finds a real
|
||
adapter (`Renderer: Android Emulator OpenGL ES Translator (Google
|
||
SwiftShader)`, `Version: OpenGL ES 3.0`), further than the plain
|
||
host-GPU/default-backend attempt got -- but `AndroidRenderer::new`'s
|
||
device request then aborts:
|
||
|
||
Abort message: 'Could not get device!: RequestDeviceError { inner: Core(LimitsExceeded(
|
||
FailedLimit { name: "max_compute_workgroups_per_dimension", requested: 65535, allowed: 0 } )) }'
|
||
|
||
i.e. iris's device descriptor asks for compute-shader limits
|
||
unconditionally, and SwiftShader's software GL path reports itself
|
||
as OpenGL ES 3.0 -- compute shaders are an ES 3.1+ feature, so the
|
||
allowed limit is 0. This is a different failure from both the host-
|
||
GPU/default-backend crash above (no adapter at all) and the earlier
|
||
Venus/gfxstream failure "Vulkan in the emulator" documents (a
|
||
different Vulkan implementation's external-memory gap) -- three
|
||
distinct emulator/backend incompatibilities found across this
|
||
project's Android work, not one recurring bug. **Not fixed this
|
||
pass**: making iris's device request tolerant of a downlevel GL
|
||
adapter (requesting compute limits only when the adapter actually
|
||
reports them) is real scope, not a measurement task. Consequence for
|
||
the software-mode question this step was meant to answer: it remains
|
||
open whether SwiftShader-Vulkan specifically (rather than GLES in
|
||
general) explains the ~80-150ms software-mode numbers, since no GLES
|
||
number under software mode could be taken at all.
|
||
|
||
**Fixed, 2026-09-05, later the same day.** Not "requesting compute
|
||
limits only when the adapter reports them" (a capability check with
|
||
a fallback) -- simpler than that, because iris has no code path that
|
||
needs compute at all: grepped the whole `iris`/`iris-core` tree for
|
||
`ComputePipeline`/`@compute` and found none, so the right fix is to
|
||
stop asking for compute limits, full stop, rather than to build a
|
||
fallback for a capability nothing uses. `iris_core::device_limits()`
|
||
(`iris/core/src/render/mod.rs`) is the one place both platform
|
||
backends now build their `required_limits` from: `Limits::default()`
|
||
with the six `max_compute_*` fields zeroed and `max_buffer_size`
|
||
still raised, as before. `Limits::downlevel_webgl2_defaults()` was
|
||
the first thing tried and rejected -- it also zeros
|
||
`max_storage_buffers_per_shader_stage`, and `shader.wgsl`'s vertex
|
||
stage reads four `var<storage>` buffers, so it would have traded
|
||
this crash for a bind-group-layout one on the same hardware.
|
||
`rigs/gpu-probe`'s own `Limits` (necessarily a hand-mirrored copy --
|
||
that rig is deliberately its own crate, not a workspace member) was
|
||
updated to match and re-run: `IRIS DEVICE: ok` against this VM's own
|
||
Vulkan (Venus) and GL (virgl, reports OpenGL ES 3.2) adapters.
|
||
**Not verified against the actual SwiftShader-ES-3.0 failure this
|
||
pass**: the `EMU_GPU=software` cold boot needed to reproduce it would
|
||
have force-restarted this checkout's shared emulator while another
|
||
session had `com.example.aiapp` focused and running on it (`adb
|
||
shell dumpsys window`), so this pass left that measurement rather
|
||
than disrupting concurrent work -- matching AGENTS.md's "coordinate
|
||
with peer agents" guidance rather than contending for the emulator.
|
||
Everything else: `cargo fmt --all`/`clippy --workspace --all-targets`/
|
||
`test --workspace` clean, `cargo ndk build`/`clippy` for
|
||
`iris-android-app --features transcript-screen,force-gles` clean
|
||
(only the pre-existing unused-`tabs-ui`-dependency warning, unrelated
|
||
to this change). This also means the software-mode question two
|
||
boxes up is still open, for the same original reason plus this new
|
||
one: a GLES number under `EMU_GPU=software` still has not been
|
||
taken, now blocked on emulator availability rather than on the
|
||
crash. A future pass should cold-boot `EMU_GPU=software` once the
|
||
emulator is free, confirm `dev.iris.android.demo` no longer aborts
|
||
on `request_device`, and take the `iris-scroll.sh` FrameReport row
|
||
that pairs with this box's host-GPU one.
|
||
|
||
**Verification, this update.** `cargo fmt --all` (no diff),
|
||
`cargo clippy --workspace --all-targets` (no warnings from the new
|
||
code; pre-existing `wgpu`/`winit`/`naga` future-incompat notices
|
||
only) both re-run and clean this pass. `cargo test --workspace` and
|
||
`cargo ndk ... test`/`clippy` for `iris-android-app` were **not**
|
||
re-run this pass -- the previous pass on this identical diff had
|
||
already run and reported them clean, and this pass's host was
|
||
disk-pressure-limited (93% full, a concurrent `ai-server` rebuild in
|
||
progress) when the repeat attempt was made, so it was stopped rather
|
||
than left to spend 50+ minutes doing no useful work; see commit
|
||
`e2a1fad`'s own message. `docs/DECISIONS.md`'s DEFERRED item is
|
||
updated with this section's host-GPU table below.
|
||
|
||
**Touch-scroll dropout root-caused, 2026-09-05.** Diagnosed as
|
||
instructed: temporary `log::info!` tracing on every touch event
|
||
reaching `IrisViewPeer::on_touch_event` (`iris/src/android/view.rs`),
|
||
every `DragArbiter` state transition (`press_start`/`update`/
|
||
`release`, `iris/src/sense.rs`), and every `Selection::drag`
|
||
dispatch (`iris/transcript-ui/src/selection.rs`) -- all removed once
|
||
the cause was confirmed, per AGENTS.md's "keep the build clean."
|
||
Reproduced with `app/iris-scroll.sh` against a real sandbox session
|
||
(30 sent markdown messages, `EMU_GPU` unset / `-gpu host`,
|
||
`--features transcript-screen,force-gles`, release build, same
|
||
recipe as this box's own "-gpu host" pass above).
|
||
|
||
*The trace.* Of 24 swipes in one run, 5 produced zero `render()`
|
||
calls each -- one at the very start of the run, four consecutive
|
||
later (swipes 22-25) -- exactly the "several consecutive swipes
|
||
produce nothing, an identical retry then works" shape from the
|
||
earlier pass's report. Correlating the three log streams by
|
||
timestamp: every one of those 5 swipes delivered a normal
|
||
`Down`/`Move`×N/`Up` sequence to `on_touch_event` (touch delivery
|
||
was never the problem), but `Selection::drag` never once saw
|
||
`PressStart` for the whole gesture -- only `Pressing`, starting from
|
||
the very first `Move`. `DragArbiter::update`'s `Idle` arm answers
|
||
every such frame with `Undecided` and never transitions state (there
|
||
is no way for pure state to tell "no press is happening" from "a
|
||
press is happening but I missed its start"), so the arbiter sat in
|
||
`Idle` from the gesture's first frame to its last, `release()` on
|
||
`Up` its only state change (`Idle` -> `Idle`, a no-op). The row this
|
||
landed on registered its `PressStart` correctly on a *different*
|
||
point in the very next successful swipe at the identical screen
|
||
coordinate -- confirming the miss is about *where the content
|
||
happens to be under that pixel when `ACTION_DOWN` fires*, not about
|
||
timing or a coalesced event.
|
||
|
||
*Why `ACTION_DOWN` misses a row's sensor.* Each row's `CursorSense`
|
||
handler is registered only on its `TextEdit` field
|
||
(`transcript-ui/src/row.rs`'s `build_text_row`), not on the row's
|
||
`.pad(10)` margin, the `.gap(4)` between the sender-name header and
|
||
the field, or the header itself (`Span::empty`/a plain `wtext` with
|
||
no handler). A real touch's down-point is wherever the finger
|
||
actually is, with no reason to prefer text over padding, and the
|
||
list has no sensor of its own to fall back to (`iris::widget::list`
|
||
registers none) -- pan is reachable *only* through a row's own
|
||
arbiter. So roughly one in five swipes in this run started on a
|
||
pixel no sensor covered.
|
||
|
||
*The fix.* `iris::sense::DragArbiter` gains `pub fn is_idle(&self)`,
|
||
documented as the recovery signal: a caller that gets a `Pressing`
|
||
frame while the arbiter reports `is_idle()` knows the button is
|
||
genuinely down (that is what `Pressing` means) with no matching
|
||
`press_start` on record, which can only mean it was missed.
|
||
`Selection::drag`'s match gains one arm, checked after `PressStart`/
|
||
`PressEnd` and before the ordinary `_ => update(...)` case: `_ if
|
||
self.arbiter.is_idle()` starts the press right there instead of
|
||
where it was missed, using whatever `already_selected` holds at
|
||
that later frame (the best available answer -- the true value at
|
||
the actual `ACTION_DOWN` is unrecoverable once missed). This is
|
||
the caller's fix, not the arbiter's, because only the caller knows
|
||
what `already_selected` should be; the arbiter's own slop/long-press
|
||
logic was correct throughout and needed no change.
|
||
|
||
*Tests.* Four new, all passing on the fix and the first three failing
|
||
without it: `sense.rs`'s `drag_arbiter_tests::
|
||
is_idle_reports_a_press_that_was_never_started`,
|
||
`::update_on_an_idle_arbiter_stays_undecided_forever_without_recovery`
|
||
(documents the failure mode itself), `::
|
||
a_caller_can_recover_a_missed_press_start_via_is_idle` (the pure-state
|
||
half); and `transcript-ui/src/selection.rs`'s `tests::
|
||
a_missed_press_start_recovers_on_the_next_pressing_frame`, which
|
||
drives `Selection::drag` directly with only `Pressing` frames (no
|
||
`PressStart` ever sent) and asserts the arbiter is no longer idle
|
||
afterward -- this one fails on the pre-fix code (`is_idle()` stays
|
||
true forever, matching the real trace).
|
||
|
||
**Not completed this pass, and why.** The task asked for
|
||
`iris-scroll.sh` run three times clean and a re-taken host-GPU
|
||
`FrameReport` row. Partway through that verification, this
|
||
checkout's shared emulator (`ai-app-2`, per-checkout per AGENTS.md)
|
||
turned out to be concurrently in use by another session actively
|
||
working the P0 phone-benchmark item added to this same file earlier
|
||
today: `adb shell dumpsys activity processes` showed
|
||
`com.example.aiapp`/`com.example.aiapp.bench` processes running
|
||
alongside `dev.iris.android.demo`, window focus was observed to have
|
||
moved to the Compose app mid-test, and the sandbox server's own log
|
||
showed a fresh `start` (not `keep`) at 00:55 that wiped this pass's
|
||
30-message test session and replaced it with the peer's own
|
||
`bench-check` session -- confirmed by `ui-sandbox.sh api /sessions`
|
||
returning "no session" for the id this pass had been sending to.
|
||
Rather than disrupt that session's work (deleting its session,
|
||
restarting its server, or fighting over emulator focus), this pass
|
||
stopped chasing a clean aggregate number once the cause was
|
||
confirmed external. What *is* verified is the fix itself, from
|
||
direct traces taken before the interference began (above) plus two
|
||
manual, shorter `ui-trace` swipe sequences (not the full script) that
|
||
each showed full, healthy per-swipe `render()`/`selection::drag`
|
||
coverage with the fix in place. The FrameReport row in this box's
|
||
own table above is therefore **not re-taken this pass** -- a future
|
||
pass should re-run `iris-scroll.sh` three times and retake it once
|
||
the emulator is free, per AGENTS.md's "ask before/tell peers" and
|
||
"coordinate with peer agents" guidance rather than contending for it.
|
||
Also correctly ruled out, not left ambiguous: a hypothesis raised
|
||
mid-pass that `iris::widget::list::List::scroll`'s deliberately
|
||
unclamped anchor (its own module doc, "no overscroll clamping ...
|
||
leaves a gap rather than rubber-banding back") could itself explain
|
||
a run of consecutive failed swipes once enough net drift
|
||
accumulates -- plausible in isolation, but the run where it seemed
|
||
to reproduce is exactly the run now attributed to the peer
|
||
session's interference (same timestamps), so it was not
|
||
independently confirmed and is recorded here as ruled out for now
|
||
rather than as a second bug.
|
||
|
||
## The port, in order (decided 2026-09-05)
|
||
|
||
Iris decided iris over Masonry (`DECISIONS.md`). This is the ordered plan
|
||
for the rest of the app, decided by the design agent per the standing
|
||
"decide technical questions yourself" instruction — a serious
|
||
user-facing tradeoff is not in play in the ordering itself, so it is not
|
||
deferred to her. **Crate shape, decided here**: the screens live in one
|
||
crate, **`iris/app-ui`**, grown from `iris/transcript-ui` rather than
|
||
started beside it — `transcript-ui` already has the right generic shape
|
||
(`Rsc: HasEvents` + `Rsc::State: FocusHost`, the same axis `tabs-ui`
|
||
varies along) and the same `client-core`/`event-model` path
|
||
dependencies every later screen needs, so growing it in place is a
|
||
rename plus new modules rather than a second crate re-declaring
|
||
dependencies the first already has. It holds a `Screen` enum and a back
|
||
stack — the direct equivalent of `AppRoot.kt`'s `when` and `MainScreen.kt`'s
|
||
tab `enum` — with each Compose screen becoming one `iris::widget`
|
||
subtree module. `iris/desktop-app` (E4) and `iris/android-app` (I2/I5)
|
||
become thin entry points that call into `app-ui`, the way `AppRoot`/
|
||
`MainActivity` today call into Compose screens they don't otherwise own.
|
||
Platform-only code (the notification foreground service, the share
|
||
target, the QR scanner, the Keystore-sealed token, deep-link enrolment)
|
||
stays exactly where E3/E5 already put it — `android-shell/` and
|
||
`app/shellApp` — since none of it is a screen `app-ui` could draw.
|
||
|
||
Order is by **risk to the daily-use path**, not by screen count: the
|
||
session screen is what the app is for and where every hard behaviour
|
||
(paging, cache, keyboard insets, selection) already lives, so it goes
|
||
first and on the phone as reachable code as soon as possible, before the
|
||
lower-risk screens.
|
||
|
||
Every step below assumes the `app/ui-sandbox.sh` fixtures (AGENTS.md's
|
||
"The rigs") and the `this-machine-android` skill's facts (per-checkout
|
||
AVD, `ui-trace` by accessibility name, GrapheneOS phone quirks, the
|
||
`adb shell` quoting traps) apply unchanged — read that skill before
|
||
running any pass condition below that touches an emulator or a real
|
||
device.
|
||
|
||
- [ ] **P0 — the phone benchmark gate (asked for 2026-09-05; must pass
|
||
before P1 starts).** Iris runs both apps on her own phone and pastes
|
||
the reports back; the emulator's numbers are not a substitute.
|
||
Two halves, buildable independently:
|
||
- **Compose half** (`app/`): a `bench` build type (release
|
||
optimisations, `applicationIdSuffix ".bench"`, own label "AI
|
||
Sessions bench") whose session screen can open an embedded fixture
|
||
transcript from assets with no server, and a "Run benchmark"
|
||
control in the existing render-report place that programmatically
|
||
performs the fixed scroll loop (same distances and timings as
|
||
`transcript-bench.sh`, driven through the `LazyListState`), then a
|
||
streaming phase (append fixture events at 20/s for 20 s into the
|
||
same fold path a live SSE reply uses), then shows the report with
|
||
the existing copy button. The report adds process CPU time over
|
||
the run (`Process.getElapsedCpuTime`), peak RSS, and
|
||
`BatteryManager.BATTERY_PROPERTY_CURRENT_NOW` samples.
|
||
- **iris half** (`iris/android-app`, `transcript-screen` feature): the
|
||
same fixture embedded, the same scripted loop and streaming phase
|
||
driven through `List::scroll` and the fold, the same report fields
|
||
added to `FrameReport`'s line, shown on screen with a copy-to-
|
||
clipboard control (through the shell's Java side), arm64 release.
|
||
- **The fixture**: one synthetic transcript generated from
|
||
`app/ui-sandbox.sh`'s invented sessions (never a real one), at
|
||
least 3,000 events, with headings, code fences, links, tool calls
|
||
with kilobyte outputs, and a few images; committed once under
|
||
`app/bench-fixture/` and read by both apps.
|
||
- **Delivery**: `~/host/bench/` gets `iris-bench-arm64.apk`,
|
||
`compose-bench-arm64.apk`, and `README.md` saying how to run each
|
||
and what to paste back.
|
||
**Pass**: Iris's call from the two reports — iris within a reasonable
|
||
margin of Compose on p50, p99 and CPU time, no crash, no stutter she
|
||
can see. Fail stops the port.
|
||
|
||
**Compose half: done, 2026-09-05.** `app/androidApp`'s `bench` build
|
||
type, `app/bench-fixture/` (generator + generated `assets/`),
|
||
`BenchFixture.kt`/`BenchNetwork.kt` (an in-process fake backend: a
|
||
`URLStreamHandlerFactory` installed only in `FIXTURE_MODE` answers
|
||
`https://bench.fixture.invalid:1/...` from an in-memory event log
|
||
instead of opening a socket, so `TranscriptSource`, `EventStream`,
|
||
the fold and the paging are the *real* ones, unmodified), and
|
||
`BenchRun.kt` (the scripted scroll-and-stream, driven against the
|
||
real `LazyListState`) are all in. "Run benchmark" sits beside "Copy"
|
||
in the session settings dialog, bench-build only
|
||
(`SessionSettingsDialog`'s `onRunBenchmark`). `./build-apk.sh bench`
|
||
works, produces a universal APK (no ABI splits in this project, so
|
||
arm64-v8a is included alongside the others — confirmed with `aapt2
|
||
dump badging`), signed with the same release key, own application id
|
||
`com.example.aiapp.bench`, own label "AI Sessions bench" via a
|
||
build-type `resValue` overriding `@string/app_name`.
|
||
|
||
Checks all clean: `ktfmtFormat`, `compileDebugKotlin`,
|
||
`compileBenchKotlin`, `lintDebug`, `lintBench` (both "No issues
|
||
found"), `testDebugUnitTest`. `grep -n "tap [0-9]" app/*.sh` has one
|
||
hit, pre-existing and unrelated — a comment in `bench-lib.sh`
|
||
recounting the 2026-09-03 incident that made that grep a rule, not a
|
||
literal `tap` call.
|
||
|
||
**Emulator smoke run, 2026-09-05** (this checkout's AVD,
|
||
`ui-trace` tap-by-label throughout — `tap 'Session settings'` then
|
||
`tap 'Run benchmark'`, report read back over `adb logcat`):
|
||
|
||
ai-app render report
|
||
device: sdk_gphone64_x86_64 (Google), Android 16
|
||
build: release
|
||
|
||
transcript:
|
||
28 events, 26 rows, 58 units loaded
|
||
viewport 1536px, 2 units visible
|
||
on screen: the list's own 0px, AssistantMsg 18732px
|
||
0 tool calls and 0 groups open
|
||
|
||
frames:
|
||
1361 frames over 38.1s at 60Hz (16.7ms budget)
|
||
late: 1353 (99.4%)
|
||
total p50 27.8ms p90 37.7ms p99 50.1ms
|
||
gpu p50 18.9ms p90 28.9ms p99 31.5ms
|
||
|
||
where the draw phase went:
|
||
draw phase 3.12ms per frame, of which:
|
||
the transcript: 0.33ms (measure 0.18, place 0.14, record 0.00)
|
||
everything else: 2.79ms (89%)
|
||
|
||
bench:
|
||
scroll: 6 cycles (24 swipes), streamed 400/400 fixture events
|
||
process CPU time over this run: 23005ms
|
||
peak RSS: 209348kB
|
||
battery current: mean 900000µA over 39 samples (min 900000, max 900000)
|
||
|
||
Read this as "the harness runs end to end and produces every field
|
||
P0 asked for," not as a phone number: it is software-rendered
|
||
emulator rasterisation (this-machine-android's skill — the stock
|
||
Settings app scrolls worse on the same device), and the battery
|
||
current is a fixed 900mA on every sample, which is the emulator's
|
||
mocked charger reporting a constant rather than a real battery —
|
||
expect that field to read "unavailable" or a real varying number
|
||
only on Iris's own phone. The ordinary debug build was rebuilt and
|
||
driven with `./transcript-bench.sh` against `ui-sandbox.sh` alongside
|
||
this and produced its usual report with no `bench:` section, so nothing
|
||
changed for it.
|
||
|
||
`~/host/bench/compose-bench-arm64.apk` (9.7M) and
|
||
`~/host/bench/README.md` are written, with a heading left for the
|
||
iris half. **Known interaction**: the bench build keeps the same
|
||
`aiapp://enroll` intent filter as the ordinary app (it never uses
|
||
it), so with both installed, driving enrollment through a raw `am
|
||
start -d aiapp://...` intent (not the in-app QR scanner, which is
|
||
the primary path and calls straight into the matched activity) opens
|
||
Android's "Open with" chooser between the two. Cosmetic — the real
|
||
enrollment path is unaffected — and left as is rather than pulling
|
||
the intent-filter out of the bench manifest via source-set merging,
|
||
which was more diff than the problem was worth.
|
||
|
||
**Not done this pass**: the iris half (a separate agent's scope —
|
||
this session was told not to touch `iris/`), and anything past the
|
||
emulator — the actual on-phone runs and Iris's pass/fail call.
|
||
|
||
**iris half: done, 2026-09-05.** A `bench` Cargo feature on
|
||
`iris-android-app`, built on top of `transcript-screen`
|
||
(`bench = ["transcript-screen", "dep:libc", "dep:tokio"]`,
|
||
`iris/android-app/Cargo.toml`), gives `lib.rs`'s `ActiveClient`
|
||
priority a third `AndroidAppState` (`bench_client::BenchClient`)
|
||
over `TranscriptClient` when both features are listed together --
|
||
matching the exact build command below, which lists both.
|
||
|
||
**Fixture.** `include_str!("../../../app/bench-fixture/assets/
|
||
transcript.jsonl")` (1,915,760 bytes) at compile time -- no asset
|
||
pipeline needed the way the Compose half's Gradle source set does.
|
||
`bench_client::parse_fixture` splits the same way `BenchFixture.kt`
|
||
does: the first 3,200 non-blank lines parsed as `serde_json::Value`s
|
||
and folded once through `client_core::transcript_fold::fold_page`
|
||
(the real fold a `/transcript` page goes through), the rest parsed
|
||
as `event_model::SeqEvent`s and held back as the streaming tail.
|
||
`build.rs` (transcript-screen's own) now exits early under `bench`
|
||
before requiring a live server's host/port/token/CA -- `BenchClient`
|
||
never calls `build_transport()`, so that requirement made no sense
|
||
for a build that talks to nothing.
|
||
|
||
**"Run benchmark" (`.label("Run benchmark")`) and "Copy report"
|
||
(`.label("Copy report")`)** sit in a fixed bar above the transcript;
|
||
a selectable `TextEdit` (`.attr::<Selectable>(())`, the same
|
||
attribute the composer field uses) below it shows the report text.
|
||
Pressing "Run benchmark" resets `FrameReport`, then drives
|
||
`List::scroll` in ~60Hz steps (`ANIM_STEP_MS = 16`) to animate each
|
||
900px/200ms swipe rather than jumping it -- iris's `List` has no
|
||
built-in tween the way `animateScrollBy(tween(...))` gives Compose,
|
||
so this is the one place the two backends' bench code has to differ
|
||
in shape rather than only in numbers -- through the same
|
||
`rsc.tasks.redraw_handle()` + manual `request_redraw()` per step
|
||
`transcript_client.rs` already established (a `Tasks::spawn`d
|
||
future's *automatic* redraw fires once, after the whole future
|
||
completes, which would show nothing moving until the run ends).
|
||
After the scroll loop, `List::jump_to_end()` pins to the newest
|
||
content (matching `stream-bench.sh`'s "Jump to latest" tap), then
|
||
400 fixture events replay at 20/s through `fold_event` -- the same
|
||
fold path a live SSE frame takes in `transcript_client.rs`'s own
|
||
`apply_event` -- each one triggering `rebuild_transcript`'s full
|
||
`transcript_ui::build_tree` rebuild at the time this box was
|
||
written, same tradeoff as `TranscriptClient`/`desktop-app`. **Fixed
|
||
2026-09-05, later the same day**: all three now call
|
||
`TranscriptScreen::apply` instead -- see this same section's
|
||
"Streaming no longer costs a full rebuild" entry below for the
|
||
before/after numbers. A battery sampler runs
|
||
concurrently on its own `tokio::spawn`d task (not through
|
||
`ctx.update`, since a JNI battery read needs no widget-tree access),
|
||
attaching whichever thread it runs on via a stored `JavaVM` --
|
||
`AndroidAppState::platform_ready` (new, `IRIS.md`) is what hands
|
||
`bench_client.rs` that `JavaVM` + a `GlobalRef` to the view, since
|
||
neither was reachable from `AndroidAppState::new` before this box.
|
||
|
||
**Report fields.** `FrameStats`'s existing `Display` (frames, janky
|
||
%, p50/p90/p99, worst, and I5's own `cpu_p50`/`gpu_wait_p50` CPU/GPU
|
||
split) plus a `bench:`-shaped tail this box added: process CPU time
|
||
via `libc::getrusage(RUSAGE_SELF)` (user+system time; chosen over
|
||
parsing `/proc/self/stat` by hand to avoid assuming `USER_HZ`), peak
|
||
RSS from `/proc/self/status`'s `VmHWM` (same source `BenchRun.kt`
|
||
reads), and battery current sampled once a second via
|
||
`BatteryManager.getIntProperty(BATTERY_PROPERTY_CURRENT_NOW)`
|
||
through direct JNI calls (`bench_jni.rs`'s `PlatformHandle` --
|
||
`android_view::context`'s own `Context`/`Resources` wrappers have no
|
||
`getSystemService`, so this calls it directly rather than growing
|
||
that crate's wrapper for two one-off calls). `0`/`Integer.MIN_VALUE`
|
||
read as "unavailable" rather than folded into the average, matching
|
||
`BatterySampler`'s own rule and UI_RULES.md's "never present an
|
||
inferred value as a measured one." The report is logged under the
|
||
existing `iris-android-app` logcat tag on a line starting `iris
|
||
bench report:` (grep-able the same way `transcript_client.rs`'s
|
||
"Frame report" control already is), shown in the on-screen
|
||
`TextEdit`, and copied to the system clipboard by "Copy report"
|
||
through `ClipboardManager.setPrimaryClip` (`bench_jni.rs`, same
|
||
`PlatformHandle`).
|
||
|
||
**Build commands, all clean this pass:**
|
||
- `cargo fmt --all -- --check` (iris workspace) and
|
||
`cd iris/android-app && cargo fmt --all -- --check`: clean.
|
||
- `cargo clippy --workspace --all-targets` (iris workspace): clean
|
||
(only the pre-existing `wgpu`/`winit`/`naga` future-incompat
|
||
notice).
|
||
- `cargo test --workspace` (iris workspace): 39 + 8 + 10 = the same
|
||
pre-existing counts, all passing, unaffected by this box (it
|
||
touched no logic under test there beyond `AndroidAppState`'s new
|
||
default no-op method).
|
||
- `cargo ndk -t x86_64 -P 26 clippy --features "transcript-screen
|
||
force-gles bench" --lib -- -D warnings` (`iris/android-app`):
|
||
clean.
|
||
- `cargo ndk -t arm64-v8a -P 26 -o app/src/main/jniLibs/ build
|
||
--release --features "transcript-screen force-gles bench"`:
|
||
clean, `arm64-v8a/libmain.so` produced. The pre-existing "unused
|
||
dependency `tabs-ui`" Cargo advisory also appears on a plain
|
||
`--features transcript-screen` build with no `bench` (confirmed
|
||
by building that combination alone with fake env vars) -- not
|
||
something this box introduced, and not a clippy/rustc warning
|
||
(AGENTS.md's "keep the build clean" gate is `cargo clippy`, which
|
||
stays silent on it).
|
||
|
||
**Packaging.** No `cargo xtask apk` exists for `iris/android-app`
|
||
yet (I2's own Gradle project is the only pipeline), so this reused
|
||
that split rather than inventing one: `cargo ndk --release` above
|
||
builds the cdylib straight into `app/src/main/jniLibs/`, then a new
|
||
`release` build type in `app/build.gradle` (there was previously
|
||
only `debug`) packages and signs it --
|
||
`AI_APP_KEYSTORE=~/.config/ai-app/release.jks` +
|
||
`AI_APP_KEYSTORE_PASSWORD` (the same key `app/build-apk.sh`
|
||
generates for the Compose app) via `gradle :app:assembleRelease`,
|
||
with `applicationIdSuffix ".bench"` so it installs beside the plain
|
||
tabs demo rather than replacing it. `aapt2 dump badging` on the
|
||
result: `package: name='dev.iris.android.demo.bench'`, one native
|
||
library, `lib/arm64-v8a/libmain.so`. `apksigner verify
|
||
--print-certs` shows the same `CN=ai-app` certificate
|
||
`compose-bench-arm64.apk` is signed with.
|
||
|
||
**Emulator smoke run, 2026-09-05.** This checkout's own AVD
|
||
(`ai-app-2`) was in use by the session recording I5's clean-scroll
|
||
comparison in this same file (its Compose app was in the
|
||
foreground, confirmed via `dumpsys window`/`dumpsys activity
|
||
processes` before touching anything) -- rather than contend for it
|
||
(AGENTS.md's "coordinate with peer agents"), a second,
|
||
differently-named AVD was created (`AVD_NAME=ai-app-2-bench emu
|
||
up`, `pixel_10`/`android-36`/`google_apis`/`x86_64`, cold boot, host
|
||
GPU, no `EMU_GPU=software`), with 12GB of the VM's memory still
|
||
available after both were up (this-machine-android's "two are
|
||
comfortable" guidance). Installed via `adb -s emulator-5556 install
|
||
-r`, launched, driven by `ui-trace record -s emulator-5556 --do
|
||
"tap 'Run benchmark'"` (the control resolved by its accessibility
|
||
label, per AGENTS.md's "no coordinate" rule), then read back over
|
||
`adb logcat`:
|
||
|
||
iris bench report
|
||
frames=372 janky%=56.99 p50=19.5ms p90=219.5ms p99=284.5ms worst=369.3ms (measures redraw-start to after present() is called, not GPU/compositor completion) cpu_p50=0.4ms gpu_wait_p50=13.9ms (redraw-start-to-submit vs. submit-to-after-present)
|
||
scroll: 6 cycles (24 swipes), streamed 400/400 fixture events
|
||
process CPU time over this run: 24665ms
|
||
peak RSS: 224600kB
|
||
battery current: mean 900000µA over 21 samples (min 900000, max 900000)
|
||
|
||
"Copy report" was pressed immediately after and logged `iris bench
|
||
report: copied to clipboard` (`ClipboardManager.setPrimaryClip`
|
||
succeeded). No crash (`adb logcat`'s `FATAL`/`AndroidRuntime` lines
|
||
checked -- only `ui-trace`'s own runtime, unrelated), process alive
|
||
throughout (`dumpsys activity processes`), 400/400 stream events
|
||
confirmed sent.
|
||
|
||
Read this the same way the Compose half's own box already asks to
|
||
read its number: this is software-rasterised (well, GLES-over-virgl
|
||
under `force-gles`, per I5's "Where iris's frame time goes")
|
||
emulator output, "the harness runs end to end and produces every
|
||
field P0 asked for," not a phone number -- and the battery current
|
||
is again the emulator's fixed 900000µA mocked charger reporting a
|
||
constant, exactly what the Compose box's own run found, not a real
|
||
battery answering. `cpu_p50=0.4ms` (iris's own per-frame CPU work)
|
||
against a much larger `gpu_wait_p50`/`p50` again matches I5's "Where
|
||
iris's frame time goes" finding under real GPU rendering (`-gpu
|
||
host`, `force-gles`) -- the frame-time budget here is dominated by
|
||
the driver/compositor wait, not by iris's layout or primitive
|
||
building, though this run's `janky%`/`p90`/`p99` are considerably
|
||
worse than that earlier isolated pass, most likely the cost of this
|
||
AVD's very first cold boot plus running two emulators on this VM at
|
||
once (a fair comparison against Compose would need both apps run
|
||
back-to-back on the same freshly-booted device, not attempted this
|
||
pass since the second AVD was torn down immediately after per
|
||
AGENTS.md's "stop yours when you are done with it").
|
||
|
||
Copied to `~/host/bench/iris-bench-arm64.apk` (15,445,468 bytes) and
|
||
`~/host/bench/README.md`'s "iris" section filled in (install, open,
|
||
tap "Run benchmark", read the report from the on-screen text or
|
||
logcat, tap "Copy report", paste back).
|
||
|
||
**Not done this pass**: the actual on-phone runs and Iris's
|
||
pass/fail call between the two reports (P0's own pass condition) --
|
||
that needs Iris's phone, which this session has no access to.
|
||
`iris/src/android/view.rs` was touched (`AndroidAppState::
|
||
platform_ready`, `new_peer`'s wiring) -- confirmed to not be one of
|
||
the three files the concurrent `device_limits()` work on this
|
||
branch was using (`iris/core/src/render/mod.rs`,
|
||
`iris/src/android/render.rs`, `iris/src/default/render.rs`).
|
||
|
||
**Streaming no longer costs a full rebuild, 2026-09-05.** The gap
|
||
named above and in E4/I5 (`push_row` can only append; a streaming
|
||
reply is a row that keeps *changing* after it appears) is closed:
|
||
`iris::widget::List` gained `replace_back` (swap the last row's
|
||
widget in place, same slot, so a pinned-to-newest list stays pinned
|
||
and an off-screen replace moves nothing on screen -- two new unit
|
||
tests, `replacing_the_last_row_stays_pinned_to_the_bottom` and
|
||
`replacing_the_last_row_out_of_view_does_not_move_visible_rows` in
|
||
`iris/src/widget/list.rs`) and `clear` (drop every row, the fallback
|
||
path). `transcript_ui::TranscriptScreen::apply(rsc, old_items,
|
||
new_items)` diffs `group_tool_runs(old)`/`group_tool_runs(new)`
|
||
(pure bookkeeping, no widget built doing it) and picks the cheapest
|
||
update: unchanged (no-op), pure append (`push_row`, same as before),
|
||
the common streaming case -- only the last row's content changed --
|
||
rebuilds just that one row and swaps it in with `replace_back`, or
|
||
(rare: `group_tool_runs` regrouping a row before the tail) a full
|
||
`List::clear` rebuild, counted by `TranscriptScreen::take_rebuilds()`.
|
||
Seven new unit tests in `transcript-ui/src/lib.rs`'s `diff_tests`
|
||
cover all three cases directly against synthetic `Vec<FoldedRow>`s
|
||
(no widget/Rsc needed for the decision itself). `bench_client.rs`,
|
||
`transcript_client.rs` and `desktop-app/app.rs` all call `apply` now
|
||
instead of rebuilding per event; `IRIS.md`'s 2026-09-05 entry has
|
||
the full API account. `TextEditCtx` also gained `set_with_spans`
|
||
(`iris/src/widget/text/edit.rs`) -- `set()` plus a fresh span list
|
||
in one call, since a streamed row's re-rendered markdown needs both
|
||
to land together.
|
||
|
||
**Two new scripts, `iris/android-app/build-apk.sh` and
|
||
`iris/android-app/run-bench.sh`**, written this pass after repeating
|
||
the ANDROID_HOME/NDK-export/`cargo ndk`/Gradle-release/keystore/
|
||
apksigner incantation by hand one too many times. `build-apk.sh
|
||
[debug|release] [--abi arm64-v8a|x86_64] [--features "..."]` builds
|
||
the cdylib and the APK and verifies it (badging, and signing for a
|
||
release build); `run-bench.sh [--apk PATH]` installs on this
|
||
checkout's own emulator (`emu serial`), taps "Run benchmark" by
|
||
label (no coordinates), polls logcat for the report line, and prints
|
||
it. Used for everything below and for the redelivery at the end of
|
||
this box.
|
||
|
||
**Numbers, this checkout's AVD (`ai-app-2`), release, x86_64,
|
||
`force-gles`, via `run-bench.sh` -- three separate runs, same warm
|
||
AVD (not a fresh cold boot each time):**
|
||
|
||
frames=690 janky%=78.26 p50=26.9ms p90=60.3ms p99=103.4ms worst=130.1ms cpu_p50=7.4ms gpu_wait_p50=15.7ms
|
||
frames=691 janky%=84.95 p50=28.3ms p90=60.9ms p99=95.2ms worst=120.7ms cpu_p50=5.4ms gpu_wait_p50=18.2ms
|
||
frames=691 janky%=58.32 p50=18.9ms p90=40.3ms p99=75.6ms worst=101.4ms cpu_p50=3.5ms gpu_wait_p50=12.5ms
|
||
|
||
Against this same box's earlier iris-half reading (full rebuild per
|
||
event, a *different*, freshly-booted x86_64 AVD, host GPU):
|
||
`frames=372 janky%=56.99 p50=19.5ms p90=219.5ms p99=284.5ms
|
||
worst=369.3ms cpu_p50=0.4ms gpu_wait_p50=13.9ms`. The tail is what
|
||
moved: `worst` dropped from 369.3ms to 101–130ms and `p99` from
|
||
284.5ms to 76–103ms across all three post-fix runs, consistent with
|
||
removing the periodic full-tree-rebuild stall during the
|
||
20-events/second streaming phase. `p50`/`cpu_p50` are *not* a clean
|
||
comparison -- these three runs share one already-warm AVD instance
|
||
rather than each getting its own fresh cold boot the way the earlier
|
||
reading did, and `cpu_p50` in particular is noisy run to run (3.5 to
|
||
7.4ms here) in a way a controlled A/B would need to separate from
|
||
the code change itself. **What a future pass should do for a clean
|
||
number**: two fresh cold boots of the same AVD, one per build,
|
||
`run-bench.sh` on each, nothing else running.
|
||
|
||
**The three remaining I5 verifications, closed 2026-09-05.** The
|
||
`server`/`event_model` drift the previous pass hit (`no variant
|
||
named 'LimitReached'`) was already fixed upstream on `rustify` by
|
||
the time this pass started (commit `c07d544`, "carry main's
|
||
`LimitReached` event") -- `cargo build --release` under `server/`
|
||
is clean, `./ui-sandbox.sh start` builds and runs. All three items
|
||
this left open:
|
||
|
||
1. **Three clean `iris-scroll.sh` runs against a cold `-gpu host`
|
||
boot, all 24/24 swipes scrolling in every run** -- results and
|
||
the per-swipe verification method are recorded just above, in
|
||
this same "Touch-scroll dropout root-caused" subsection's
|
||
"Update, 2026-09-05" paragraph, and the host-GPU table above now
|
||
carries the best-of-three row.
|
||
2. **`EMU_GPU=software` + `force-gles`, cold boot -- still cannot
|
||
isolate SwiftShader-Vulkan from SwiftShader-GL, now for a third
|
||
and structural reason.** Same release build
|
||
(`transcript-screen force-gles`), fresh cold boot under
|
||
`EMU_GPU=software`. The earlier compute-limit abort this same
|
||
box's "Fixed, 2026-09-05, later the same day" paragraph resolved
|
||
(`iris_core::device_limits()` zeroing `max_compute_*`) no longer
|
||
fires -- adapter selection now succeeds and picks up
|
||
SwiftShader's ES 3.0 GL path -- but device creation aborts on a
|
||
*different* limit immediately after:
|
||
Abort message: 'Could not get device!: RequestDeviceError { inner: Core(LimitsExceeded(
|
||
FailedLimit { name: "max_storage_buffer_binding_size", requested: 134217728, allowed: 0 } )) }'
|
||
i.e. SwiftShader's ES 3.0 reports zero storage-buffer capacity at
|
||
all -- SSBOs are an ES 3.1+ feature, the same generation gap the
|
||
compute-limit failure came from, and exactly the trap this box's
|
||
own "Fixed" paragraph flagged when it rejected
|
||
`Limits::downlevel_webgl2_defaults()` for zeroing
|
||
`max_storage_buffers_per_shader_stage` while `shader.wgsl`'s
|
||
vertex stage reads four `var<storage>` buffers unconditionally.
|
||
**Closing the open question, one sentence**: whether
|
||
SwiftShader-Vulkan or GLES-in-general explains the ~80-150ms
|
||
software-mode numbers cannot be answered on this hardware at
|
||
all, because `shader.wgsl`'s storage-buffer reads make a GLES
|
||
path on downlevel (ES 3.0) SwiftShader structurally unreachable
|
||
rather than merely unmeasured -- reaching it is a shader rewrite
|
||
(moving those reads off `var<storage>`), which is real scope, not
|
||
a measurement task, and was not attempted here.
|
||
3. **P0's bench build, cold-boot `run-bench.sh` number**: same
|
||
`-gpu host` cold boot as item 1 (re-cold-booted after the
|
||
`EMU_GPU=software` boot above), `./build-apk.sh release --abi
|
||
x86_64 --features "transcript-screen force-gles bench"`,
|
||
`./run-bench.sh`:
|
||
frames=690 janky%=62.03 p50=19.6ms p90=42.4ms p99=56.5ms worst=62.5ms cpu_p50=4.4ms gpu_wait_p50=12.6ms
|
||
scroll: 6 cycles (24 swipes), streamed 400/400 fixture events
|
||
process CPU time over this run: 15394ms
|
||
peak RSS: 164348kB
|
||
battery current: mean 900000µA over 21 samples (min 900000, max 900000, the emulator's fixed mocked-charger reading, not a real battery -- see P0's own box)
|
||
Against the P0 box's own three same-warm-AVD readings
|
||
(`frames=690/691/691`, `p50` 18.9-28.3ms, `worst` 101-130ms),
|
||
this cold-boot run's `worst` (62.5ms) and `p99` (56.5ms) are
|
||
*lower* than any of the three warm-AVD runs, and its `p50`
|
||
(19.6ms) sits inside their range -- so the P0 box's caveat that
|
||
the warm-AVD numbers might be inflated by AVD staleness does not
|
||
hold up under a fresh cold boot; if anything this run is cleaner.
|
||
`cpu_p50` (4.4ms) is within the 3.5-7.4ms noise band the P0 box
|
||
already flagged as run-to-run noisy on a shared warm AVD.
|
||
|
||
**Redelivered, 2026-09-05.** `./build-apk.sh release --abi
|
||
arm64-v8a` (arm64-only jniLibs; an earlier step in this same pass
|
||
had left an x86_64 slice in there from the emulator testing above,
|
||
removed before this build so the delivered APK matches P0's
|
||
original arm64-only shape) -- `aapt2 dump badging` confirms
|
||
`native-code: 'arm64-v8a'` and the same
|
||
`dev.iris.android.demo.bench` id, `apksigner verify` the same
|
||
`CN=ai-app` cert as before. Copied over
|
||
`~/host/bench/iris-bench-arm64.apk`; `~/host/bench/README.md` gained
|
||
a one-line build-date/commit note so Iris can tell which build she
|
||
has.
|
||
|
||
**iris bench crash on the phone, 2026-09-06.** The delivered APK
|
||
(`dev.iris.android.demo.bench`, arm64, release) aborted on Iris's own
|
||
phone (a Pixel, GrapheneOS, Mali GPU) on the very first
|
||
`surface_changed`: `AndroidRenderer::new` -> `UiRenderNode::new` ->
|
||
`create_bind_group_layout` -> wgpu's `default_error_handler` panics
|
||
with `wgpu error: Validation Error`, and Android's crash report
|
||
truncated the message right there, so the actual validation failure
|
||
was unknown. Ran fine on the emulator's Vulkan (SwiftShader) and GLES
|
||
(`force-gles`/virgl) and on the desktop GPU. Nobody on this session
|
||
has the phone or `adb` access to it; this pass worked from the crash
|
||
report alone plus reading wgpu-core's own validation source
|
||
(`wgpu-core-28.0.0/src/{binding_model,device/resource}.rs`, the
|
||
version this workspace pins).
|
||
|
||
**1. Diagnostic, not guesswork -- what actually happens is now
|
||
visible.** `iris_core::UiRenderNode::new` (`core/src/render/mod.rs`)
|
||
wraps every `create_bind_group_layout`/pipeline call in three nested
|
||
wgpu error scopes (one per `ErrorFilter`: `OutOfMemory`,
|
||
`Validation`, `Internal`), pops them in reverse once creation is
|
||
done, and returns `Result<Self, String>` -- the `String` is wgpu's
|
||
own `Display` text for whichever scope caught something, which is
|
||
already wgpu-core's `format_error` output (`"Validation Error\n\n
|
||
Caused by:\n ..."`, confirmed by reading
|
||
`wgpu-28.0.0/src/backend/wgpu_core.rs`'s `format_error` -- the exact
|
||
text the panic would have printed, just no longer thrown away).
|
||
`android::render::AndroidRenderer::new` turns a failure into a full
|
||
report: the adapter's name/backend/driver
|
||
(`Adapter::get_info`), the limits bind-group-layout validation
|
||
checks a storage/texture binding against
|
||
(`max_storage_buffers_per_shader_stage`,
|
||
`max_sampled_textures_per_shader_stage`, `max_bind_groups`,
|
||
`max_bindings_per_bind_group`, `max_storage_buffer_binding_size`,
|
||
`min_storage_buffer_offset_alignment`), and
|
||
`DownlevelCapabilities.flags` (`Adapter::get_downlevel_capabilities`)
|
||
-- then wgpu's own error text. `android::view::IrisViewPeer::
|
||
surface_changed` logs it as one logcat line (`iris renderer init
|
||
failed: ...`, newlines replaced with ` | `) and shows the full
|
||
multi-line text on screen: a new `IrisView.showRendererError(String)`
|
||
(an ordinary instance method Rust calls into via JNI, not a `native`
|
||
one -- the direction is Rust reaching into Java, the opposite of
|
||
every `native fn` this view already declares) swaps the activity's
|
||
whole content for a plain, selectable, scrollable `TextView`, opening
|
||
with "Copy this text and send it to Iris" (UI_RULES.md: a failure is
|
||
reported where it happened and says what to do next). Desktop's
|
||
`UiRenderer::new` keeps panicking on failure (no on-screen fallback
|
||
exists there) but now with wgpu's full chain as the message, since it
|
||
no longer relies on wgpu's own handler getting there first.
|
||
|
||
**A real, separate reentrancy bug turned up while testing this, and
|
||
is fixed alongside it.** Calling `Activity::setContentView` directly
|
||
from inside `surface_changed` deadlocked -- not literally, but hit
|
||
Rust's `RefCell already borrowed` abort: `setContentView` tears the
|
||
old view hierarchy down synchronously, which fires `IrisView`'s own
|
||
`onFocusChanged` *before* `setContentView` returns, straight back
|
||
into the same `IrisViewPeer` through `on_focus_changed` while
|
||
android-view's own dispatch (`with_peer` in its `view.rs`) still
|
||
holds this peer's `RefCell` borrow for the `surface_changed` call in
|
||
progress. Found by deliberately inducing a validation error (see
|
||
below) and watching it abort a different way than the crash this
|
||
pass was fixing. Fixed by moving the Java call into
|
||
`ctx.push_dynamic_deferred_callback`, which android-view already
|
||
runs only after dropping the borrow (confirmed by reading
|
||
`with_peer`'s body) -- the same mechanism `raise_if_enabled` (this
|
||
file's AccessKit push) already relies on for the identical reason.
|
||
Left as a comment at the call site rather than only here, since the
|
||
next thing that reaches into Java from inside a `ViewPeer` callback
|
||
needs the same warning.
|
||
|
||
**2. The audit -- every bind-group-layout entry, checked against
|
||
wgpu-core's actual validation, not guessed.** `CreateBindGroupLayoutError`
|
||
(`wgpu-core::binding_model`) has seven variants; the ones a static,
|
||
no-`count`, no-feature layout like this crate's can hit are
|
||
`Entry { error: MissingDownlevelFlags(_) }` and
|
||
`Entry { error: MissingFeatures(_) }`. Walked every entry in
|
||
`uniform_layout`, `primitive_layout`, `rsc_layout`, `masks_layout`
|
||
(all four in `UiRenderNode::new`):
|
||
- `uniform_layout` (group 0): one uniform buffer, `VERTEX|FRAGMENT`.
|
||
Uniform buffers need no downlevel flag or feature at any
|
||
visibility. Not it.
|
||
- `primitive_layout` (group 1, `rects`/`glyphs`): two storage
|
||
buffers, both `FRAGMENT`-only (confirmed against `shader.wgsl`:
|
||
`rects`/`glyphs` are read only in `fs_main`). `FRAGMENT`-visible
|
||
storage buffers need `DownlevelFlags::FRAGMENT_STORAGE`, which
|
||
every backend in wgpu-hal grants unconditionally for a
|
||
non-write-only binding (`ty: Storage { read_only: true }` here).
|
||
Not it.
|
||
- `rsc_layout` (group 2, atlas/image/sampler): a `D2Array` texture,
|
||
a `D2` texture, a `NonFiltering` sampler, all `FRAGMENT`, no
|
||
`count`. `Bt::Texture`'s only feature requirement
|
||
(`TEXTURE_BINDING_ARRAY`) gates on `count.is_some()`, which none
|
||
of these set. Not it.
|
||
- `masks_layout` (group 3, `masks`/`move_offsets`): `masks` is
|
||
`FRAGMENT`-only (read only in `fs_main`'s mask lookup). But
|
||
`move_offsets` is `VERTEX | FRAGMENT` -- `shader.wgsl`'s
|
||
`resolve_move` is called from both `vs_main` (a primitive's own
|
||
corners) and `fs_main` (a mask's move chain) -- and it is a
|
||
storage buffer, which is exactly what
|
||
`wgpu-core/src/device/resource.rs` gates on
|
||
`DownlevelFlags::VERTEX_STORAGE` whenever `visibility` contains
|
||
`VERTEX` (confirmed by reading that check directly, not inferring
|
||
it from the flag's name). **This is the one entry among all four
|
||
layouts whose validity is device-dependent rather than static.**
|
||
|
||
**Named hypothesis: the delivered build forced GLES, and GLES's
|
||
`VERTEX_STORAGE` is not unconditional the way Vulkan's is.**
|
||
Read `wgpu-hal-28.0.0`'s two backends' own downlevel-flag
|
||
construction (`vulkan/adapter.rs`, `gles/adapter.rs`):
|
||
- **Vulkan** grants `Df::VERTEX_STORAGE` unconditionally for any
|
||
Vulkan 1.0 device, alongside `COMPUTE_SHADERS`/`FRAGMENT_STORAGE`
|
||
and others in one unconditional `Df::empty() | ... ` -- there is
|
||
no `.set(VERTEX_STORAGE, <device check>)` call anywhere in that
|
||
file. This is why the emulator's SwiftShader-Vulkan run and the
|
||
desktop's real Vulkan both pass: **on Vulkan, this exact layout
|
||
cannot fail this check, on any conforming device.**
|
||
- **GLES** computes it explicitly:
|
||
`downlevel_flags.set(VERTEX_STORAGE, max_storage_block_size != 0
|
||
&& max_storage_buffers_per_shader_stage != 0 &&
|
||
(vertex_shader_storage_blocks != 0 || vertex_ssbo_false_zero))` --
|
||
i.e. it depends on the driver actually reporting a nonzero
|
||
`GL_MAX_VERTEX_SHADER_STORAGE_BLOCKS`. This is a known-weak spot
|
||
on Android GLES drivers specifically (vertex-stage SSBO support
|
||
lags fragment-stage support even on ES 3.1+ hardware), and this
|
||
exact document already found the adjacent failure mode once this
|
||
pass: SwiftShader's ES 3.0 GL path reports zero storage-buffer
|
||
capacity at all (`max_storage_buffer_binding_size: 0`, this box's
|
||
item 2 under "The three remaining I5 verifications"). A phone
|
||
that negotiates a GLES context with no (or driver-buggy)
|
||
vertex-stage SSBO support hits precisely this: `masks_layout`'s
|
||
`move_offsets` entry, `MissingDownlevelFlags(VERTEX_STORAGE)`.
|
||
|
||
**And the delivered build does force GLES.** `~/repos/
|
||
ai-app-bench`'s own README (committed alongside the P0 APKs)
|
||
says so directly: "`force-gles` matches I5's own ... finding: the
|
||
default Vulkan backend has no adapter under a plain `-gpu host`
|
||
boot on this AVD" -- true and reasoned correctly *for this VM's
|
||
emulator*, but `build-apk.sh`'s default `FEATURES` applied the
|
||
same flag to every arm64 build regardless of target, so the exact
|
||
same cfg-locked GLES-only path that was a deliberate, documented
|
||
emulator workaround shipped to a real Mali phone with no way to
|
||
turn it back to Vulkan short of a rebuild. `force-gles`'s own doc
|
||
comment (`iris/Cargo.toml`) only ever talks about the emulator
|
||
("the same build can be measured against SwiftShader's software
|
||
Vulkan ICD ... or virgl's GLES path") -- real hardware was never
|
||
the case it was written for.
|
||
|
||
**What would confirm or kill this, from the on-screen report
|
||
alone**: `backend: Gl` (confirms GLES was in fact what ran) and
|
||
`downlevel flags: DownlevelFlags(...)` *not* containing
|
||
`VERTEX_STORAGE` in the list. If a future report instead shows
|
||
`backend: Vulkan` with `VERTEX_STORAGE` present, this hypothesis is
|
||
wrong and the "Caused by" chain in that same report names the real
|
||
one directly -- which is the entire point of doing (1) first.
|
||
|
||
**Fix applied**: `build-apk.sh`'s default `--features` dropped
|
||
`force-gles` (now `"transcript-screen bench"`, was
|
||
`"transcript-screen force-gles bench"`), with a comment explaining
|
||
why and telling a future emulator-isolation run to pass it back
|
||
explicitly. This is a build/delivery fix, not a shader rewrite --
|
||
the shader itself is untouched, since moving `move_offsets` off a
|
||
storage buffer is real scope this document already declined once
|
||
this pass for the adjacent SwiftShader-ES-3.0 finding, and the
|
||
actual defect here is that a debug-only backend override reached a
|
||
real device, not that the shader's design is wrong. The rebuilt
|
||
arm64 APK (below) uses the default backend, i.e. Vulkan on a real
|
||
phone -- if it still fails, (1)'s on-screen report is what comes
|
||
back this time, not a truncated abort.
|
||
|
||
**Verified, this checkout's emulator, cold `emu up`
|
||
(`ai-app-2`):**
|
||
- GLES (`force-gles`, matching every prior P0 GLES reading's
|
||
backend): `run-bench.sh` end to end, no crash, report unchanged
|
||
in shape from the pre-fix readings above
|
||
(`frames=691 janky%=58.90 p50=19.2ms p90=43.7ms p99=62.4ms
|
||
worst=69.3ms cpu_p50=4.7ms gpu_wait_p50=12.6ms`, 24/24 swipes,
|
||
400/400 streamed events) -- the diagnostic wrapper adds no
|
||
measurable cost or behaviour change on the success path.
|
||
- **Induced failure, confirmed the fix works end to end**: added a
|
||
temporary `count: Some(NonZeroU32::new(2))` to `uniform_layout`'s
|
||
one entry (an artificial `ArrayUnsupported`/`MissingFeatures`,
|
||
chosen because it is guaranteed to fail on every backend rather
|
||
than depending on this VM's flaky adapter enumeration), rebuilt,
|
||
installed, launched: logcat showed the full one-line report
|
||
(adapter `Android Emulator OpenGL ES Translator (virgl ...)`,
|
||
every named limit, the downlevel flags, and wgpu's "Caused by"
|
||
chain naming `Binding 0 entry is invalid` / the missing
|
||
`BUFFER_BINDING_ARRAY` feature) and `ui-trace elements` confirmed
|
||
a `TextView` labelled with that exact report text was on screen
|
||
-- process alive, no abort. This is also what caught the
|
||
reentrancy bug above (the first attempt aborted a different way,
|
||
`RefCell already borrowed`, fixed, then reproduced clean). The
|
||
temporary `count: Some(...)` was reverted before anything else.
|
||
- **Vulkan (no `force-gles`) could not be re-verified this pass**:
|
||
this cold `emu up` enumerates zero Vulkan adapters
|
||
(`wgpu_core::instance: enabled backend 'Vulkan' has no adapters`,
|
||
the *unrelated*, already-panicking `.expect("Could not get
|
||
adapter!")` path this fix does not touch) even with the default
|
||
`-gpu host` boot the same README quoted above once relied on --
|
||
matching this document's own prior notes that Vulkan
|
||
availability on this VM's emulator is flaky across cold boots,
|
||
not something this pass's diff caused (confirmed by checking the
|
||
panic message is byte-for-byte the pre-existing
|
||
`RequestAdapterError` shape, not a new one). Not chased further:
|
||
it is orthogonal to the crash this pass fixes, and the real test
|
||
of "does Vulkan work" is the phone itself, not this VM.
|
||
|
||
**Checks, all clean**: `cargo fmt --all -- --check` and
|
||
`cargo clippy --workspace --all-targets` (iris workspace, zero
|
||
warnings beyond the pre-existing wgpu/winit future-incompat
|
||
notice), `cargo test --workspace` (unchanged counts, all passing --
|
||
nothing here touched logic under test), `cargo ndk -t x86_64 -P 26
|
||
clippy --features "transcript-screen force-gles bench" --lib -- -D
|
||
warnings` (clean), `cargo ndk -t arm64-v8a -P 26 build --release
|
||
--features "transcript-screen bench"` (clean, arm64-only
|
||
`jniLibs`).
|
||
|
||
**Redelivered, 2026-09-06.** New arm64 APK (Vulkan, no
|
||
`force-gles`), same `dev.iris.android.demo.bench` id, same
|
||
`CN=ai-app` signing cert, copied to `~/host/bench/
|
||
iris-bench-arm64.apk` and `~/repos/ai-app-bench/iris/build/outputs/
|
||
apk/release/iris-bench-arm64.apk`; that repo's own README gained a
|
||
dated entry explaining both changes (the diagnostic and the
|
||
`force-gles` removal) so Iris can tell this build apart from the
|
||
one that crashed. **Not done this pass**: confirming the fix on
|
||
Iris's actual phone -- nobody on this session has it or `adb`
|
||
access to it, so this is read from the crash report and wgpu's
|
||
source, verified as far as this VM's tooling reaches, and handed
|
||
back with a diagnostic that will say the real story on the next
|
||
run either way.
|
||
|
||
**Benchmark v2 (2026-09-06), asked for by Iris after using the
|
||
Compose build on her phone**: "it doesn't fling like I typically do
|
||
when scrolling up to find old messages. It should travel way faster
|
||
which is better for stress testing. You may also want to add typing
|
||
in the textbox as well and seeing how performant wrapping & pushing
|
||
the transcript up are, and also keyboard performance if possible."
|
||
**This is the one spec** -- written once here so both apps' "Run
|
||
benchmark" implement the identical four phases; a change to a
|
||
constant below has to be made in both `app/`'s `BenchRun.kt` and
|
||
`iris/`'s bench client, together, or the two reports stop measuring
|
||
the same thing while still looking like they do.
|
||
|
||
1. **fling.** Starting pinned at the newest end
|
||
(`listState.scrollToItem(0)` / iris's equivalent), 8 flings away
|
||
from it (toward older messages) through the list's own real fling
|
||
path -- Compose: `LazyListState.scroll { with(flingBehavior) {
|
||
performFling(velocity) } }` using the screen's actual
|
||
`FlingBehavior` (`ScrollableDefaults.flingBehavior()`, since
|
||
`TranscriptList`'s `LazyColumn` never overrides it -- **not**
|
||
`animateScrollBy`, which can only ever cover the fixed distance
|
||
and time it is given and was Iris's complaint) -- each fling's
|
||
`initialVelocity = 12,000 px/s`. That number is well above a
|
||
moderate tween-swipe's implied speed (v1's `SWIPE_PX`/`SWIPE_MS`
|
||
is roughly 4,500 px/s) and is meant to be a hard, fast flick for
|
||
stress-testing, per Iris's ask. After each fling, wait for
|
||
`isScrollInProgress` to clear (cap 3s; `performFling` already
|
||
suspends until its own decay ends, this is belt-and-suspenders)
|
||
plus 300ms between flings. Then 8 more flings back toward the
|
||
newest end (`-12,000 px/s`). Record the list's first visible
|
||
index/offset at the start, after the 8 outward flings, and at the
|
||
end, so the two apps' *travel* can be compared directly rather
|
||
than just their frame times.
|
||
2. **stream. Unchanged from v1**: 400 tail events at 20/s (20
|
||
seconds), pinned to the newest end before it starts (the same
|
||
"Jump to latest" pin `stream-bench.sh` does).
|
||
3. **type.** Pin to the newest end, focus the composer, show the IME
|
||
if the platform allows it, then insert this **exact 600-character
|
||
string** one character per 50ms through the composer's real
|
||
`TextFieldValue` state (Compose: the same `input` state
|
||
`onValueChange` writes; iris: whatever holds the composer's text
|
||
today), then delete it the same way, one character per 50ms.
|
||
Chosen for long, multisyllabic words specifically so the composer
|
||
wraps across lines and the transcript above it is pushed upward
|
||
by a growing box, which is what Iris asked to see measured:
|
||
|
||
Benchmarking this transcript screen requires unusually long, multisyllabic words so wrapping and reflow are properly exercised: internationalization, counterproductiveness, disproportionately, incomprehensibility, deinstitutionalization, uncharacteristically, overenthusiastically, misunderstanding, straightforwardness, telecommunications, and interdisciplinary collaboration all push a narrow composer field to wrap across several lines while the transcript above is pushed upward by the growing keyboard-adjacent box, which is exactly what a real reader typing a long message sees happening now!!!
|
||
|
||
Report whether the IME was actually open during typing (it should
|
||
be, from this phase's own show-IME step -- see phase 4 for what
|
||
to say if the platform refuses to show it at all).
|
||
4. **keyboard.** Show the IME (`WindowInsetsControllerCompat.show
|
||
(WindowInsetsCompat.Type.ime())` against the window/view; iris's
|
||
equivalent through its own shell), wait 1s, hide it, wait 1s;
|
||
five cycles. Confirm each show/hide with the platform's own
|
||
answer (Compose: `ViewCompat.getRootWindowInsets(view)
|
||
?.isVisible(WindowInsetsCompat.Type.ime())`, i.e. the same
|
||
`WindowInsets.isImeVisible` fact `SessionScreen`'s composer-inset
|
||
bug fix already relies on) rather than assuming the request
|
||
worked -- if it is never confirmed shown even once, the report's
|
||
`keyboard:` line has to say "**keyboard: could not be shown**"
|
||
in words (UI_RULES: never present an inferred value as a
|
||
measured one, and design the unknown/failed state before the
|
||
answer's).
|
||
|
||
**Frame accounting**: one recorder, not two. Mark each phase's start
|
||
in the existing per-frame recorder (Compose: `FrameStats.markPhase
|
||
(name)`, a list of `(name, frameIndexAtStart, wallClockAtStart)`
|
||
alongside the existing `total`/`waited`/... arrays) and slice the
|
||
same `FrameMetrics` samples by phase afterward
|
||
(`FrameStats.phaseLines`) rather than running a second listener.
|
||
|
||
**Report shape**: a `per phase:` block appears once any phase marks
|
||
exist (empty/absent on an ordinary "Copy" press, which never marks a
|
||
phase), one entry per phase: frame count, the phase's wall-clock
|
||
duration, late count/percent (against the same refresh-rate budget
|
||
the whole-run section uses), p50/p90/p99, and the worst single
|
||
frame. Then **every existing whole-run section stays, unchanged in
|
||
shape** -- `frames:`, `where the draw phase went:`, `work since this
|
||
was last copied:` -- because that is what the emulator-baseline and
|
||
phone-baseline numbers already on record in this file were read
|
||
against, and a report that dropped or renamed one of those lines
|
||
would silently stop being comparable to them. Finally `bench:` gains
|
||
new lines beside the existing CPU/RSS/battery ones: the fling
|
||
phase's total travel (start/outward/end index+offset), the typed
|
||
character count, and the keyboard phase's shown/hidden-confirmed
|
||
counts (or the "could not be shown" line).
|
||
|
||
**Compose half: done, 2026-09-06.** `FrameStats.markPhase`/
|
||
`phaseLines` (`app/androidApp/src/main/kotlin/com/example/aiapp/
|
||
FrameStats.kt`), `debugReport`'s new `phaseFrames` parameter
|
||
(`DebugStats.kt`), and `BenchRun.kt`'s four-phase `run` (fling via
|
||
`ScrollableDefaults.flingBehavior()` captured in `SessionScreen` and
|
||
passed down since it needs a `@Composable` call site; type via a new
|
||
`composerFocus: FocusRequester` attached to the composer's
|
||
`OutlinedTextField` plus a `setComposerText` callback that writes
|
||
`input` the same way a keystroke does; keyboard via
|
||
`WindowInsetsControllerCompat` against `LocalView.current`) are all
|
||
in. `BenchRun.TYPE_TEXT` is the exact 600-character constant quoted
|
||
above (verified `.length == 600`). A pre-existing, unrelated break
|
||
in `MainActivity.kt`'s `benchSessionSummary()` (missing several
|
||
`SessionSummary` constructor arguments added by a change this pass
|
||
did not otherwise touch -- confirmed pre-existing by reproducing the
|
||
same compile failure after stashing this pass's own diff) was fixed
|
||
alongside this, since it blocked `compileBenchKotlin` outright and
|
||
is in this session's own `app/` scope.
|
||
|
||
Checks all clean: `ktfmtFormat`, `compileDebugKotlin`,
|
||
`compileBenchKotlin`, `lintDebug`, `lintBench` (both "No issues
|
||
found"), `testDebugUnitTest`. `grep -n "tap [0-9]" app/*.sh` still
|
||
has its one pre-existing, unrelated hit.
|
||
|
||
**Compose bench v2, emulator smoke run, 2026-09-05** (this
|
||
checkout's AVD, cold `emu up`, `ui-trace` tap-by-label throughout --
|
||
the dialog needed a swipe to reach "Run benchmark" below the fold,
|
||
report read back over `adb logcat`):
|
||
|
||
ai-app render report
|
||
device: sdk_gphone64_x86_64 (Google), Android 16
|
||
build: release
|
||
|
||
transcript:
|
||
124 events, 26 rows, 58 units loaded
|
||
viewport 1714px, 2 units visible
|
||
on screen: the list's own 0px, AssistantMsg 18732px
|
||
0 tool calls and 0 groups open
|
||
|
||
per phase:
|
||
fling: 1620 frames over 32.3s
|
||
late: 1537 (94.9%)
|
||
total p50 20.5ms p90 29.2ms p99 45.9ms
|
||
worst 61.8ms
|
||
stream: 1079 frames over 20.6s
|
||
late: 1037 (96.1%)
|
||
total p50 21.0ms p90 33.5ms p99 39.3ms
|
||
worst 51.2ms
|
||
type: 3568 frames over 61.4s
|
||
late: 3536 (99.1%)
|
||
total p50 23.8ms p90 32.1ms p99 38.5ms
|
||
worst 50.3ms
|
||
keyboard: 215 frames over 10.0s
|
||
late: 212 (98.6%)
|
||
total p50 21.3ms p90 37.6ms p99 48.4ms
|
||
worst 50.2ms
|
||
|
||
frames:
|
||
6482 frames over 124.3s at 60Hz (16.7ms budget)
|
||
late: 6322 (97.5%)
|
||
total p50 21.7ms p90 33.1ms p99 45.3ms
|
||
gpu p50 17.4ms p90 27.0ms p99 30.6ms
|
||
|
||
where the draw phase went:
|
||
draw phase 1.27ms per frame, of which:
|
||
the transcript: 0.16ms (measure 0.09, place 0.07, record 0.00)
|
||
everything else: 1.10ms (87%)
|
||
|
||
bench:
|
||
fling: 8 flings out + 8 back at 12000px/s, travel start=idx=0/off=0px outward=idx=218/off=73px end=idx=0/off=0px
|
||
scroll: 6 cycles (24 swipes, legacy tween), streamed 400/400 fixture events
|
||
type: 600 characters inserted then deleted, one per 50ms
|
||
keyboard: shown 5/5, hidden 5/5 (confirmed via isImeVisible)
|
||
process CPU time over this run: 61192ms
|
||
peak RSS: 195716kB
|
||
battery current: mean 900000µA over 125 samples (min 900000, max 900000)
|
||
|
||
Read this the same way the v1 emulator smoke run above is read: it
|
||
proves the harness runs end to end and produces every field this
|
||
spec asked for, not a phone number -- software rasterisation, and
|
||
the fixed 900mA battery reading is the emulator's mocked charger
|
||
again. Two things worth carrying forward: the **fling phase reached
|
||
index 218** in 8 flings (against v1's `animateScrollBy` loop, which
|
||
never moved past a handful of indices in the same 8-swipe count),
|
||
which is the direct evidence the new fling travels "way faster" as
|
||
asked; and **the emulator's software keyboard toggled and was
|
||
confirmed by `isImeVisible` all 10 times**, so phase 4 is not a
|
||
guaranteed "could not be shown" on every platform, only where the
|
||
IME genuinely refuses. `frames:`'s 6,482-frame, 124.3s total matches
|
||
the sum of the four phase durations (32.3+20.6+61.4+10.0 ≈ 124.3s),
|
||
confirming the phase marks partition the whole run rather than
|
||
overlapping or dropping frames between them.
|
||
|
||
**Iris's first real phone report, 2026-09-06** (the redelivered,
|
||
no-`force-gles` APK above): no crash. Two screenshots, before any
|
||
touch: headings/links/code/table all render correctly. Four defects
|
||
found and worked this pass:
|
||
|
||
1. **Every glyph disappears on the first tap or scroll; rectangles
|
||
stay drawn** (the keyboard case is the same thing -- a tap on the
|
||
composer). **Not root-caused this pass.** Audited `GpuTextures`'
|
||
atlas-grow/patch path, `ArrBuf`'s resize-on-length-change
|
||
contract, and the masks/move_offsets/rsc bind-group rebuild logic
|
||
in `core/src/render/mod.rs` against wgpu's queue-ordering
|
||
contract -- everything read as spec-correct (a `queue.write_texture`/
|
||
`write_buffer` issued before a later `queue.submit` is guaranteed
|
||
visible to it on the same queue, and a dropped `Buffer`/`Texture`/
|
||
`BindGroup` still in flight is kept alive by wgpu's own tracker).
|
||
No violation found by static reading; reproducing needs either
|
||
the phone or a Mali driver trace, neither available this pass.
|
||
Instrumented for the next report instead: `Device::
|
||
on_uncaptured_error` is now installed on the Android device
|
||
(`WgpuErrorLog`, `android::render::AndroidRenderer`), and
|
||
`IrisViewPeer::render` logs masks/moves-resized, atlas
|
||
pages-grown and image bind-group creates for the first 10 frames
|
||
after every `surface_changed` -- exactly the window this bug
|
||
lands in. The bench screen's new "Diagnostics" button (below)
|
||
surfaces the error log and adapter identity on demand.
|
||
2. **Bold words render as blank gaps of the correct advance width**
|
||
(regular, links, inline code render fine). Fixed by bundling Noto
|
||
Sans/Noto Sans Mono (regular/bold/italic/bold-italic, static
|
||
cuts, OFL) into `iris-core` and registering them ahead of the
|
||
platform's own fonts -- `core/src/primitive/text.rs`'s
|
||
`TextData::register_bundled_fonts`. Named hypothesis, not
|
||
confirmed on the phone: the system "Roboto" on a modern Android
|
||
device is the variable "Roboto Flex," and this crate's glyph
|
||
path (`TextData::place`) does not apply `Synthesis`/variable-axis
|
||
correction at all -- a bundled *static* per-style face sidesteps
|
||
the question rather than answering it. `TextData::font_diagnostics`
|
||
reports what got resolved; logged once at startup and shown on
|
||
the Diagnostics page.
|
||
3. **Text far too small** -- iris had no device-pixel-ratio handling
|
||
on *either* platform before this pass (grepped for `scale_factor`
|
||
across the whole crate: zero hits). `DisplayMetrics.density`
|
||
(Android) / `Window::scale_factor()` (desktop) now divides every
|
||
physical-pixel number (window size, touch coordinates, the
|
||
shader's window uniform) down to logical units before it reaches
|
||
layout, so a `font_size: 16.0` is 16 dp rather than 16 raw device
|
||
pixels on a ~3x-density phone. Cost a second, real bug found only
|
||
by measuring on this checkout's emulator after the first fix
|
||
landed: `android::view::IrisViewPeer::surface_changed`'s call
|
||
into `UiRenderState::resize` (the layout engine's own notion of
|
||
the canvas, which every widget's absolute `PixelRegion` is
|
||
computed against) was still being handed raw physical
|
||
`width`/`height`, while `AndroidRenderer`'s side of the same
|
||
resize had already switched to logical -- splitting layout and
|
||
the shader into two different units. A fixed-size widget (the
|
||
bench screen's `.height(56)` button row) exposed it at ~40
|
||
physical px against the ~147px `56 * content_scale` predicts;
|
||
a proportional (`rest(n)`) size hid it by adapting to whichever
|
||
total it was given. Both are logical now. **Not fully verified**:
|
||
a fresh-install emulator screenshot after both fixes shows
|
||
visibly larger, readable text (`docs/bench/` has neither
|
||
screenshot committed -- see AGENTS.md on transcripts/screenshots
|
||
not going in this repo -- but the before/after is described in
|
||
the commit), and the button row's own height still isn't
|
||
obviously matching `56 * content_scale` on this run -- worth a
|
||
second look with `ui-trace show --field box` once there's time,
|
||
but not a blocker for the magnitude of the original bug (3x too
|
||
small).
|
||
4. **Status-bar inset not applied** -- confirmed nothing in this
|
||
app ever read `insets().top` at all (`android/insets.rs` has
|
||
carried `Insets.top` since it was written; nothing consumed it).
|
||
Fixed with a new, generic hook: `AndroidAppState::
|
||
on_insets_changed(rsc, LogicalInsets)`, called from `render()`
|
||
exactly when `AndroidUiState::insets()` changes, in logical units
|
||
matching everything else `content_scale` now divides.
|
||
`BenchClient::on_insets_changed` rebuilds the root tree with
|
||
`Padding::top(insets.top)` on the button row -- rebuilding the
|
||
whole tree rather than one `WidgetPtr` slot's content, because
|
||
the first attempt (a `Pad` dropped into an unrelated `WidgetPtr`
|
||
slot with no height override of its own) did not propagate the
|
||
wrapped span's fixed height correctly, which is what surfaced
|
||
finding 3's `UiRenderState::resize` bug in the first place.
|
||
Verified via `ui-trace show --field box`: the button row's top
|
||
(150 physical px) sits 8px below `statusBarBackground`'s bottom
|
||
edge (142px) on this checkout's emulator.
|
||
|
||
**A named `Diagnostics` control now exists** (RUST.md's own earlier
|
||
ask): a third button on the bench screen's top row, filling the
|
||
existing benchmark-report `TextEdit` with adapter identity/backend/
|
||
driver, font resolution, the atlas's live view count, every
|
||
uncaptured wgpu error since surface creation, and the frame report
|
||
-- `android::render::AndroidRenderer::diagnostics_report`. Uses the
|
||
existing "Copy report" button/clipboard path rather than a second
|
||
one.
|
||
|
||
**Verified this pass, this checkout's emulator** (`EMU_GPU` default,
|
||
`--features force-gles` -- this cold `emu up` again enumerated zero
|
||
Vulkan adapters, the same pre-existing flakiness earlier boxes
|
||
documented, not something this pass's diff caused): `cargo fmt --all
|
||
-- --check`, `cargo clippy --workspace --all-targets` (zero warnings
|
||
beyond the pre-existing wgpu future-incompat notice), `cargo test
|
||
--workspace` (all passing, unchanged pure-logic counts), `cargo ndk
|
||
-t x86_64 -P 26 check` clean, `./run-bench.sh` end to end
|
||
(`frames=691`, 24/24 swipes, 400/400 streamed events, no crash),
|
||
fresh-install screenshots and `ui-trace` box readouts for the four
|
||
items above. **Not verified this pass**: the actual phone (no
|
||
access), and item 1's root cause (needs either the phone's next
|
||
Diagnostics-page report or a Mali trace).
|
||
|
||
**Recorded but not fixed this pass** (a follow-up agent takes these,
|
||
to avoid colliding with this pass's `bench_client.rs`/`view.rs`
|
||
changes) -- see `IRIS_TODO.md`'s "From the phone, 2026-09-06":
|
||
swiping has no momentum (stops exactly where the finger releases,
|
||
unlike Compose's fling), and scrolling down sometimes jitters the
|
||
text.
|
||
|
||
**Redelivered, 2026-09-06, later the same day.** New arm64 APK
|
||
(Vulkan, no `force-gles`, bundled fonts, content-scale fix,
|
||
Diagnostics control), same `dev.iris.android.demo.bench` id, same
|
||
`CN=ai-app` signing cert, copied to `~/host/bench/
|
||
iris-bench-arm64.apk` and `~/repos/ai-app-bench/iris/build/outputs/
|
||
apk/release/iris-bench-arm64.apk`; that repo's own README gained a
|
||
dated entry. Still not confirmed on Iris's actual phone.
|
||
|
||
**Redelivered again, 2026-09-06, a later pass.** Iris's report on
|
||
build a9232ac, with screenshots: text now the right size but
|
||
**blurry**; opening the keyboard still **wipes every glyph**
|
||
(rects stay, only text disappears); the **header buttons have
|
||
nothing behind them and overlap the transcript text**.
|
||
|
||
**1. The keyboard wipe.** Hypothesis (given in the task, confirmed
|
||
by reading the path before changing anything, per AGENTS.md):
|
||
`android::view::IrisViewPeer::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
|
||
set `renderer = None` and called `AndroidRenderer::new`, which
|
||
builds a fresh, empty glyph atlas and fresh GPU buffers via
|
||
`UiRenderNode::new`, while `iris_core`'s CPU-side glyph cache
|
||
(`primitive/text.rs`) kept the atlas UV coordinates it had already
|
||
handed out against the *old* atlas -- every glyph then drew from a
|
||
rectangle pointing into a texture that had just been recreated
|
||
empty. Confirmed by reading `AndroidRenderer::resize` (already
|
||
existed, already did none of that -- only `surface.configure` and
|
||
the window uniform) against what `surface_changed` was actually
|
||
calling instead. **Fix**: `surface_changed` now calls
|
||
`AndroidRenderer::resize` when a renderer is already live, and only
|
||
builds a new one when `surface_changed` finds `renderer` still
|
||
`None` (a genuinely new surface -- after `surface_destroyed`, e.g.
|
||
backgrounding). Not independently re-verified against a forced IME
|
||
resize on this pass's emulator (no display keyboard exercised
|
||
end-to-end here); the reasoning is a direct code read plus the
|
||
existing `resize` path already being surface-only, not a
|
||
screenshot diff -- **the next agent with emulator time should do
|
||
the before/after screenshot this box originally asked for.**
|
||
|
||
**2. The blur.** Root cause: the P0 fix that made text the right
|
||
*size* (dividing the whole window into a "logical" space, then
|
||
letting the shader's NDC mapping stretch it back onto the real
|
||
framebuffer) rasterised each glyph at the small, pre-stretch size
|
||
and then displayed it stretched onto more physical pixels than it
|
||
had texels for. **Fix, and the density-independent length unit
|
||
Iris asked for the same day (IRIS_TODO.md) turned out to be the
|
||
same fix**: `Len::dp`, resolved against a `density` now carried on
|
||
`UiRenderState`/`Painter`, replaces the global stretch -- window
|
||
size, touch and insets are physical pixels throughout again
|
||
(`WindowInsets`, renamed from `LogicalInsets`), and
|
||
`TextBuffer::shape` multiplies `font_size`/`line_height` by density
|
||
before handing them to parley, so the atlas rasterises at the
|
||
display's real physical resolution. Full design in docs/LAYOUT.md's
|
||
"Density: `Len::dp`" section and the public-API summary in
|
||
docs/IRIS.md's 2026-09-06 entry.
|
||
|
||
**3. The header.** Only each button's own `rect(...)` painted
|
||
anything, so the gaps between/around them and the status-bar strip
|
||
above showed `CLEAR_COLOR` (black) one layer back, and the row's
|
||
reserved height was three `abs` (now-physical-pixel) button boxes
|
||
-- smaller than the dp-correct size the transcript below uses,
|
||
which is what read as "overlap" once the two disagreed. Fixed with
|
||
a `HEADER_SURFACE` rect stacked behind the whole row and every
|
||
header size moved onto `dp(...)`.
|
||
|
||
**4. Keyboard diagnostics, so Iris can report back even if a
|
||
keyboard-triggered regression persists.** `on_insets_changed` now
|
||
edge-triggers ~500ms after `ime_bottom` becomes non-zero, capturing
|
||
the same report the on-screen Diagnostics button produces, logging
|
||
it, copying it to the clipboard unprompted, and showing it in a new
|
||
plain-view overlay (`IrisView.showDiagnosticsOverlay`, Copy/Close)
|
||
that draws independently of iris's own renderer.
|
||
|
||
**Verified this pass**: `cargo fmt --all`, `cargo clippy --workspace
|
||
--all-targets` and `cargo clippy` on `android-app` (both `-D
|
||
warnings`, zero beyond the pre-existing `tabs-ui` unused-dependency
|
||
and wgpu future-incompat notices), `cargo test --workspace` (all
|
||
passing), `cargo ndk -t arm64-v8a check`/`clippy` for both the
|
||
`transcript-screen bench` feature set.
|
||
|
||
**Then run on this checkout's own emulator** (x86_64 debug,
|
||
`--features "transcript-screen force-gles bench"` -- this AVD has no
|
||
Vulkan adapter under a plain `-gpu host` boot, matching every prior
|
||
emulator finding in this file): `run-bench.sh` end to end, no crash,
|
||
`frames=534 janky%=79.03 ... cpu_p50=1.3ms`, 24/24 swipes, 400/400
|
||
streamed events -- unchanged in shape from prior readings, so the
|
||
diff cost nothing on the success path. **Header background**:
|
||
screenshot confirms the `HEADER_SURFACE` panel now sits behind all
|
||
three buttons (`/tmp/bench-after-run.png` this pass). **Keyboard
|
||
wipe**: forced a real `surface_changed` two ways -- `adb shell wm
|
||
size 1080x1900` (screenshot before/after, text intact) and actually
|
||
opening the soft keyboard via `settings put secure
|
||
show_ime_with_hard_keyboard 1` + tapping the message field
|
||
(ui-trace confirmed a real resize, elements moved -547px; keyboard
|
||
visible in the screenshot, text still fully rendered, not wiped).
|
||
Both are real evidence the reuse-renderer fix works, though neither
|
||
is the literal before/after diff this box originally asked for --
|
||
**still worth a deliberate side-by-side screenshot pair in a future
|
||
pass.**
|
||
|
||
**Found during this same verification, not fixed, needs a follow-up
|
||
pass**: after the keyboard-triggered resize, the top button row
|
||
appeared to render a **second time**, well below its real position,
|
||
inside the transcript's scroll area (same colours/text, unmistakably
|
||
the same three buttons) -- and a tap aimed at the composer's
|
||
"Message" field landed on "Run benchmark" instead (a second
|
||
benchmark run started, visible in logcat as two `iris bench report:`
|
||
lines from one session). Only seen after a resize with the keyboard
|
||
genuinely open; the plain `wm size` resize screenshot pair did not
|
||
show it, nor did the fresh-install screenshot before either resize.
|
||
**Not root-caused this pass** -- time ran out before isolating
|
||
whether this is the `Span::DOWN` two-phase draw (LAYOUT.md's
|
||
provisional-then-real placement) leaving a phase-1 primitive
|
||
retained somewhere it should have been moved from, something
|
||
specific to the keyboard's `on_insets_changed` rebuild racing a
|
||
redraw, or unrelated to this pass's changes entirely (not verified
|
||
against a build predating this session's commits, so do not treat
|
||
"caused by this pass" as established -- MACHINE.md's pinned rule
|
||
about not attributing without measuring applies here too). Also
|
||
noteworthy: `capture_keyboard_diagnostics` never fired in this
|
||
session (no "iris keyboard diagnostics" log line) despite the
|
||
keyboard visibly opening -- `on_insets_changed`'s `ime_bottom` may
|
||
not be populated the way expected on this emulator/API level, or
|
||
the duplicate-row state above interfered; **also needs a follow-up
|
||
pass** before relying on the auto-capture on a real phone.
|
||
|
||
**Not verified this pass**: anything on Iris's real phone, the
|
||
two-density crispness check IRIS_TODO.md's unit item asks for, and
|
||
the two open items just above.
|
||
|
||
**Fling and jitter, 2026-09-06.** The two `IRIS_TODO.md` "From the
|
||
phone" items this box's own text names as follow-ups are fixed --
|
||
`List::fling`/`VelocityTracker`/`FlingCalculator` (IRIS.md's
|
||
2026-09-06 entry) and the `DragArbiter` slop-release jump (fixed by
|
||
applying only the excess past `DRAG_SLOP` on the crossing frame,
|
||
not the whole pre-threshold drag) -- both wired through
|
||
`Selection::drag`'s release path, both covered by new unit tests in
|
||
`iris/src/sense.rs` and `iris/src/widget/list.rs`. **Root-caused by
|
||
reading `DragArbiter::update` and testing it directly, not by an
|
||
emulator trace** -- this pass did not open an emulator, so the
|
||
"trace the list's offset per frame" verification this box's own
|
||
todo asked for is still open, as is a feel-check of the fling on
|
||
real touch input.
|
||
|
||
**Benchmark v2, iris half, done 2026-09-06, later the same day.**
|
||
`bench_client.rs` implements all four phases against the identical
|
||
constants this box's "Benchmark v2" spec names: fling (8 out + 8
|
||
back at 12,000px/s through `List::fling`, waiting for
|
||
`!is_scrolling()` capped 3s with a 300ms pause between, travel
|
||
reported as `idx=N/off=Mpx` via a new `List::anchor_position_display`
|
||
-- note this list's anchor does not necessarily change *slot* during
|
||
a long scroll (the module's own documented design: the anchor is
|
||
named by identity, not re-derived from what's on screen), so an
|
||
iris travel reading is not apples-to-apples with Compose's
|
||
`firstVisibleItemIndex`, which does change slot -- a real difference
|
||
in what the two numbers mean, not a bug, and worth reading `off`
|
||
rather than `idx` when comparing runs), stream (unchanged), type
|
||
(the exact 600-character `TYPE_TEXT` constant, verified by a unit
|
||
test, one char per 50ms into the composer's real `TextEdit` via
|
||
`.set()` -- the same whole-string-replace shape `BenchRun.kt`'s own
|
||
`setComposerText` uses, not a per-character insert), and keyboard
|
||
(5 cycles through `bench_jni.rs`'s new `show_ime`/`hide_ime`
|
||
`InputMethodManager` calls, confirmed from `on_insets_changed`'s
|
||
real `ime_bottom` transitions via a new `ImeState` counter rather
|
||
than assumed from the JNI call succeeding).
|
||
|
||
`iris_core::render::frame_report::FrameReport` gained `mark_phase`/
|
||
`phase_stats`/`late_at_hz` (new unit tests in `frame_report.rs`):
|
||
phases are sliced by absolute frame index against a second ring
|
||
(`index_ring`) alongside the existing duration ring, and late/jank
|
||
is judged against a real Hz read from `bench_jni.rs`'s new
|
||
`refresh_rate_hz` (`View::getDisplay().getRefreshRate()`) rather
|
||
than the fixed 60Hz `JANK_THRESHOLD` every other caller still uses
|
||
-- a separate method, not a parameter on the existing one, so
|
||
nothing else in the codebase changes behaviour. `RING_CAPACITY`
|
||
4096->16384 since one full v2 run is 3,000+ frames.
|
||
|
||
**A real deadlock, found and fixed while wiring this up.** Getting
|
||
a value back out of a task spawned via `rsc.spawn_task` has no
|
||
built-in return channel (`ctx.update`'s closures are fire-and-
|
||
forget), so a new `read_from_state` helper sends the result through
|
||
an `mpsc` channel and polls for it. Its first version only worked
|
||
for the *first* call in a chain: nothing about `ctx.update` drains
|
||
itself, so unless something calls `redraw.request_redraw()` after
|
||
*this specific* enqueue, nothing ever runs the closure -- and every
|
||
call after the first relied on a stale, already-fired
|
||
`request_redraw()` from a previous step. The fix is structural:
|
||
`read_from_state` now takes the redraw handle and calls it itself,
|
||
immediately after enqueueing, every time.
|
||
|
||
**Verified end to end, this checkout's own emulator (cold `emu up`,
|
||
`force-gles`, x86_64 -- this AVD again enumerates zero Vulkan
|
||
adapters on a cold boot, matching every prior finding in this
|
||
file):**
|
||
|
||
iris bench report
|
||
per phase:
|
||
fling: 1481 frames over 53.2s
|
||
late: 158 (10.7%)
|
||
total p50 11.2ms p90 16.9ms p99 26.5ms
|
||
worst 43.3ms
|
||
stream: 401 frames over 20.8s
|
||
late: 342 (85.3%)
|
||
total p50 26.1ms p90 49.1ms p99 57.2ms
|
||
worst 61.3ms
|
||
type: 1202 frames over 63.1s
|
||
late: 89 (7.4%)
|
||
total p50 12.8ms p90 15.1ms p99 23.3ms
|
||
worst 26.7ms
|
||
keyboard: 9 frames over 9.1s
|
||
late: 2 (22.2%)
|
||
total p50 6.3ms p90 25.8ms p99 25.8ms
|
||
worst 25.8ms
|
||
|
||
frames:
|
||
3093 frames over 146.3s at 60Hz (16.7ms budget)
|
||
late: 591 (19.1%)
|
||
total p50 12.4ms p90 22.2ms p99 51.2ms
|
||
worst 61.3ms
|
||
cpu_p50 0.7ms gpu_wait_p50 11.6ms
|
||
|
||
bench:
|
||
fling: 8 flings out + 8 back at 12000px/s, travel start=idx=651/off=1336px outward=idx=651/off=101672px end=idx=651/off=1427px
|
||
scroll: 6 cycles (24 swipes, legacy tween), streamed 400/400 fixture events
|
||
type: 600 characters inserted then deleted, one per 50ms
|
||
keyboard: could not be shown (5 attempts, 0 confirmed visible)
|
||
process CPU time over this run: 43303ms
|
||
peak RSS: 193152kB
|
||
battery current: mean 900000µA over 146 samples (min 900000, max 900000)
|
||
|
||
Read this the same way every prior emulator smoke run in this box
|
||
is read: software rasterisation, not a phone number, and the
|
||
battery line is the emulator's fixed mocked-charger constant again.
|
||
**Travel**: the `idx` stays fixed at 651 through the whole fling in
|
||
both directions (see the `anchor_position_display` caveat above) --
|
||
`off` is what actually moved, growing to 101,672px outward before
|
||
the return trip brings it back near its start, which is real, large
|
||
motion (a fast, hard fling, matching Iris's "travel way faster"
|
||
ask), just not directly comparable to Compose's idx-188-reached
|
||
reading from the same box's earlier v2 entry. **`keyboard: could
|
||
not be shown`**: expected given the ime-inset finding below, not a
|
||
new regression.
|
||
|
||
Redelivered: `./build-apk.sh release --abi arm64-v8a --features
|
||
"transcript-screen bench"` (Vulkan, no `force-gles`; the x86_64
|
||
jniLibs slice left over from emulator testing was removed first so
|
||
the delivered APK is arm64-only, confirmed via `aapt2 dump
|
||
badging`), `apksigner verify` shows the same `CN=ai-app` cert,
|
||
copied to `~/host/bench/iris-bench-arm64.apk` and `~/repos/
|
||
ai-app-bench/iris/build/outputs/apk/release/iris-bench-arm64.apk`;
|
||
that repo's own README gained a dated entry. `run-bench.sh`
|
||
extended for the longer run (260s poll cap, `-A 60` instead of
|
||
`-A 6`) to fit v2's four phases.
|
||
|
||
**Redelivered, 2026-09-06, the defect pass.** `./build-apk.sh
|
||
release --abi arm64-v8a --features "transcript-screen bench"`
|
||
(Vulkan, no `force-gles`; the x86_64 `jniLibs` slice from this
|
||
pass's emulator work was removed first, confirmed arm64-only by
|
||
listing the APK's `lib/` entries), `apksigner verify` showing the
|
||
same `CN=ai-app` cert, copied to `~/host/bench/
|
||
iris-bench-arm64.apk`; that README gained a dated entry naming what
|
||
to look for. What changed: the composer sits at the bottom of the
|
||
screen at launch again (the black third was the bench shell's empty
|
||
report pane, not an inset -- see the plan box above), typing into it
|
||
works at all (the empty-field caret bug), the keyboard no longer
|
||
throws up an undismissable diagnostics overlay, and every surface
|
||
and insets event is logged so a phone `logcat` can answer the
|
||
app-switch text loss. Not fixable from here and still open: the
|
||
duplicated `Compacted:` row.
|
||
|
||
**(a) The header-duplicate bug (found by a concurrent pass on this
|
||
branch): investigated, not fixed.** Reproduced reliably
|
||
(`ui-trace record --do "tap 'Message'"` then `adb exec-out
|
||
screencap`): the three-button row renders a second, full copy
|
||
inside the transcript area the moment the keyboard opens. Read
|
||
`Span::draw`'s own two-phase placement doc (a provisional
|
||
full-region draw to learn each child's size, then a real
|
||
`widget_within` placement) as the most likely mechanism, since it
|
||
is the one place in this tree that deliberately draws a widget
|
||
twice in normal operation and relies on the two draws landing at
|
||
the same place to stay a cheap move rather than a visible second
|
||
copy -- and `UiRenderState::update`'s `redraw_all`-vs-
|
||
`redraw_updates` split (LAYOUT.md) means a `.set()`-driven targeted
|
||
redraw of just `top_bar` and a resize-driven full redraw of the
|
||
whole tree are two structurally different code paths that could in
|
||
principle disagree about where that widget's primitives belong on a
|
||
frame where both fire close together. **One concrete, testable
|
||
hypothesis was ruled out**: `on_insets_changed` rebuilding
|
||
`top_bar` on every call, including ones only about `ime_bottom`
|
||
(nothing to do with the header's own padding). Added a guard
|
||
(`last_top_pad`, skips the rebuild unless `insets.top` itself
|
||
changed) and reproduced the *exact same* duplicate afterward --
|
||
unchanged, byte-for-byte, in the same screenshot -- so repeated
|
||
rebuilding is not the cause; the guard is kept anyway since it is a
|
||
real (if here insufficient) reduction in needless work. **Not
|
||
root-caused**: doing so needs either instrumentation inside
|
||
`Span::draw`/`draw_inner` to see the two placements' actual regions
|
||
on the frame the bug happens, or the phone. Left for a follow-up
|
||
pass rather than guessed at further.
|
||
|
||
**(b) Why the keyboard phase and the keyboard-open auto-diagnostics
|
||
both read "not confirmed": a real, named platform interaction,
|
||
partly fixed.** `MainActivity.java`'s manifest declares
|
||
`windowSoftInputMode="adjustResize"` (AGENTS.md's own "Things that
|
||
have bitten": without it the keyboard pans the window off screen
|
||
instead of resizing it). Under `adjustResize`, `WindowInsets.
|
||
Type.ime()`'s own inset *amount* is defined to read zero once the
|
||
window has already resized to avoid the overlap that inset would
|
||
otherwise describe -- confirmed by reading Android's own
|
||
`WindowInsets` contract, not guessed at. So the numeric `ime_bottom`
|
||
this app was reading is *structurally* never going to be positive
|
||
here, independent of anything wrong in `iris`'s own code -- the same
|
||
trap AGENTS.md already names for the Compose side
|
||
(`WindowInsets.isImeVisible` "does not share the failure mode").
|
||
**Fixed**: `MainActivity.java`'s `OnApplyWindowInsetsListener` now
|
||
reads `insets.isVisible(WindowInsets.Type.ime())` (a boolean,
|
||
unaffected by resize-vs-pan) and passes `1`/`0` through the
|
||
existing `ime_bottom` JNI field instead of the always-zero numeric
|
||
inset -- correct on its own terms, and kept, but **did not by
|
||
itself make the keyboard phase or the auto-diagnostics fire on this
|
||
emulator**: `logcat` shows the platform's own `InsetsController:
|
||
show(ime(), fromIme=false)`/window-resize events happening (the
|
||
keyboard genuinely opens, confirmed by screenshot), but no further
|
||
`setOnApplyWindowInsetsListener` callback at all after the initial
|
||
one at attach. Named hypothesis, not confirmed: a plain (non-edge-
|
||
to-edge) `Activity` that has not called `WindowCompat.
|
||
setDecorFitsSystemWindows(window, false)` may not get insets
|
||
redelivered for a pure IME toggle handled entirely via resize --
|
||
only the initial attach-time dispatch is guaranteed. Confirming and
|
||
fixing that needs opting the activity into edge-to-edge, which is a
|
||
real window-behaviour change interacting with the exact
|
||
`adjustResize` setting AGENTS.md protects, not attempted this pass
|
||
given the risk-to-time-remaining ratio. Both open items are
|
||
recorded in `~/repos/ai-app-bench`'s README with today's date.
|
||
|
||
**Composing text, the tap-vs-swipe focus rule, and app-switch text
|
||
loss, 2026-09-06.** Iris's report on this same dc01f88 build: typing
|
||
doesn't enter text or move the caret until a space is hit; typed
|
||
text doesn't visibly appear and there is empty black space below the
|
||
composer bar; text disappears again after leaving and returning to
|
||
the app; and (a follow-up message the same day) swiping over the
|
||
composer bar wrongly summons the keyboard.
|
||
|
||
1. **The caret/composing bug's cause**: `android/ime.rs`'s
|
||
`InputConnection` never called `InputMethodManager.updateSelection`
|
||
after an edit -- confirmed by reading android-view's own demo
|
||
(`~/src/android-view/demo/src/lib.rs`'s `render()`), which calls it
|
||
every time its editor's generation changes. Without it, Gboard has
|
||
no confirmation the app is keeping up and holds keystrokes back
|
||
rather than trusting a screen it believes is stale -- exactly
|
||
"doesn't enter it until I hit space." **Fix**: `IrisViewPeer::
|
||
update_ime_selection` (new, `ime.rs`) reports the real selection
|
||
and (an approximation, `compose_len` chars back from the caret)
|
||
the composing region, called from `after_input`'s existing tail so
|
||
every touch/key/IME callback already runs it. The buffer-level
|
||
half (`replace`/`insert_str` correctly advancing the caret) was
|
||
already correct and is now covered by four new unit tests in
|
||
`iris/src/widget/text/edit.rs` (composing, `commitText`,
|
||
`deleteSurroundingText`, `setSelection`). **Verified**: on the
|
||
emulator (`force-gles`, no Vulkan adapter on this AVD), tapping a
|
||
real Gboard key now shows a real, single-character-appropriate
|
||
suggestion strip ("H | How | Hey") rather than stale state, and a
|
||
`render()` log line fires for every keystroke -- both confirm the
|
||
`InputConnection` calls are landing and are being processed, which
|
||
a hand-typed `adb shell input text` did *not* reliably exercise on
|
||
this AVD (no `render()` at all followed one such call -- most
|
||
likely a modern `input text` no longer round-trips through
|
||
`commitText` the way older docs assume; Gboard-key taps are the
|
||
real path and the one this fix was verified against).
|
||
|
||
2. **A second, deeper bug found while verifying (1), not root-caused
|
||
this pass**: composed text never becomes visible on screen at
|
||
all -- the grey composer bar stays empty, with no glyph anywhere
|
||
in the frame, confirmed on repeated Gboard-key taps and across a
|
||
keyboard-resize. **Ruled out**: the widget tree's own layout math.
|
||
A new unit test, `layout_tests::
|
||
composing_text_after_a_keyboard_resize_lands_in_the_bars_own_region`,
|
||
builds the composer's exact tree shape (`Stack{rect, Span{Pad{
|
||
TextEdit}}}` inside an outer `Span::DOWN`) with no GPU or window,
|
||
resizes it the way a real keyboard-triggered `surface_changed`
|
||
does, edits the field both before and after, and asserts the
|
||
field's `window_region` stays a small box near the bottom of
|
||
whichever window size is current -- it passes, both before and
|
||
after this pass's composer rebuild (item 3 below), so the CPU-side
|
||
region a redraw lands at is provably correct. The bug is
|
||
therefore downstream of that -- most likely something specific to
|
||
the GPU-side redraw a content-only edit takes (`UiRenderState::
|
||
redraw`, which redraws a single dirtied widget directly at its
|
||
stored region rather than re-running its ancestors' layout) or to
|
||
this AVD's forced `force-gles` backend (the only one available
|
||
here; Iris's phone deliveries have used real Vulkan) -- neither
|
||
isolated this pass. **Not attributable to this pass's changes**:
|
||
reproduced identically before touching `composer.rs` (the very
|
||
first build tested, before the composer rebuild below, already
|
||
had it) and the render-engine files this pass did not touch
|
||
(`core/src/render/mod.rs`, `core/src/ui/render_state.rs`) are the
|
||
likely next place to look -- specifically `UiRenderState::redraw`'s
|
||
reuse of a widget's own last-drawn region versus a full tree walk.
|
||
**Needs**: either a Vulkan-capable emulator boot or the real phone
|
||
to rule `force-gles` in or out, and a GPU-side primitive dump
|
||
(the existing `frame diagnostics` log line, extended to name which
|
||
primitives a frame actually wrote) to see whether the glyph quads
|
||
are emitted at all or emitted somewhere off-screen.
|
||
|
||
3. **The composer bar rebuilt as one widget**, per this box's own
|
||
ask: `transcript_ui::composer::build_composer` (unchanged
|
||
`Stack{background, Span{Pad{TextEdit}}}` idiom, the same one the
|
||
header row's `HEADER_SURFACE` already uses) now also caps the
|
||
field at roughly six lines (`MaxSize` + `.scrollable()` for a
|
||
wheel/trackpad overflow scroll -- a real touch-drag scroll on
|
||
overflowing composer text is not wired and is a follow-up) and
|
||
wraps the whole bar in one `Pad` whose `bottom` a new
|
||
`Composer::set_bottom_inset(rsc, inset)` rewrites in place
|
||
whenever the platform's insets change, called from
|
||
`bench_client.rs`'s existing `on_insets_changed` with
|
||
`insets.bottom.max(insets.ime_bottom)` -- the IME's own inset
|
||
while it is open, the navigation bar's otherwise. Rewritten in
|
||
place rather than rebuilt through a `WidgetPtr` swap (`top_bar`'s
|
||
own pattern) because the field is strongly owned inside this tree
|
||
and cannot be re-added to a new wrapper without panicking
|
||
("was already added") -- rebuilding would also drop focus,
|
||
selection and in-progress text on every keyboard toggle.
|
||
**Verified**: `ui-trace` box readouts before/after a keyboard
|
||
open on the emulator (the field's row correctly reports a
|
||
547px move matching the real IME-triggered resize); the
|
||
known-separate "top row renders twice after a keyboard resize"
|
||
bug this box already recorded is unrelated and still open. **Not
|
||
fixed by this alone**: item 2 above -- the text still does not
|
||
render, so the "empty space at the bottom" symptom's other half
|
||
(nothing filling the space the bar itself now correctly reserves)
|
||
needs item 2's fix first before a real before/after screenshot is
|
||
worth taking.
|
||
|
||
4. **App-switch text loss, fixed and verified.** `surface_destroyed`
|
||
(backgrounding) drops the whole `AndroidRenderer` -- device,
|
||
atlas, buffers -- and a subsequent `surface_changed` with no live
|
||
renderer builds a genuinely new one (`AndroidRenderer::new`,
|
||
distinct from the keyboard-resize path this box already fixed by
|
||
*reusing* the renderer). But `iris_core::TextData::atlas` (the
|
||
CPU-side glyph cache) and `UiData::textures` (the CPU-side texture
|
||
bookkeeping the atlas is built on) live on `AndroidRsc`, which
|
||
outlives any one `AndroidRenderer` -- so both kept pointing at the
|
||
*old*, now-destroyed device's textures across the switch, the
|
||
exact "rectangles stay, glyphs disappear" shape, just triggered by
|
||
backgrounding instead of the keyboard. **Fix**: new
|
||
`GlyphAtlas::clear()` and `Textures::reset()` (`iris/core/src/
|
||
render/atlas.rs`, `iris/core/src/primitive/texture.rs`), called
|
||
together from `surface_changed`'s "genuinely new renderer" branch
|
||
only -- the same `already_live` check that already decides
|
||
reuse-vs-new, so this is one mechanism gated on the one condition
|
||
that needs it, not a second ad hoc check. **Verified on the
|
||
emulator**: backgrounded via `KEYCODE_HOME`, reopened via
|
||
`am start`, screenshotted -- every pre-existing glyph (headings,
|
||
body text, the whole diagnostics report) is intact, `frame_count`
|
||
resets to 1 confirming a genuinely new renderer was built, no
|
||
crash.
|
||
|
||
5. **Swipe-vs-tap focus, fixed and verified** (Iris's follow-up the
|
||
same day: "if I swipe over the input bar it brings up the
|
||
keyboard... scrolling should be pinned"). `attr.rs`'s `Selector`/
|
||
`Selectable` registered `CursorSense::click_or_drag()`, which
|
||
calls `select()` -- and so grants focus and requests the IME --
|
||
on the *first* frame of any press, before it is known whether the
|
||
gesture will end up a tap or a drag. Rewritten around a shared
|
||
`on_press` dispatcher over `PressStart`/`Pressing`/`PressEnd`: a
|
||
field that is **already** focused behaves exactly as before
|
||
(every frame updates the selection, so dragging inside a focused
|
||
field to select text still works); a field that is **not**
|
||
focused records where the press began (`TextEdit::press_origin`,
|
||
new field) and only grants focus on `PressEnd` if no intervening
|
||
frame crossed `sense::DRAG_SLOP` -- a drag recognised early simply
|
||
clears the pending tap and does nothing further, so it is never
|
||
consumed and whatever is behind the field still sees every frame
|
||
of it. New `FocusHost::is_focused` (both platform impls) is what
|
||
lets `on_press` tell the two cases apart. **Verified on the
|
||
emulator**: `dumpsys input_method`'s `mInputShown` reads `false`
|
||
after a `swipe` gesture starting on the composer bar (`ui-trace`
|
||
confirms the field's own box never moved, i.e. no keyboard-driven
|
||
resize happened), and reads `true` after an ordinary `tap` on the
|
||
same field. **Coordination note**: a concurrent pass is moving
|
||
drag arbitration into `sense.rs` behind a new `Drop` event: this
|
||
fix touches only `attr.rs` (new `press_track`/`on_press`) and
|
||
`iris/src/widget/text/edit.rs` (the new `press_origin` field), not
|
||
`sense.rs` itself, so it should merge cleanly, but the next agent
|
||
through here should check whether `Selector`/`Selectable`'s
|
||
`Pressing`-frame delivery still arrives the way this code assumes
|
||
once that lands.
|
||
|
||
**Checks this pass**: `cargo fmt --all` clean, `cargo clippy
|
||
--workspace --all-targets` and `cargo ndk -t x86_64 -P 26 clippy
|
||
--features "transcript-screen bench force-gles"` both zero warnings
|
||
beyond the pre-existing `tabs-ui` unused-dependency notice, `cargo
|
||
test --workspace` all passing (new tests: four in `edit.rs`, one in
|
||
`layout_tests.rs`). **Not done**: item 2's root cause; a real
|
||
before/after screenshot pair for item 3 (blocked on item 2); anything
|
||
on Vulkan or the real phone.
|
||
|
||
- [ ] **P1 — session screen parity.** **Started 2026-09-06, on Iris's
|
||
word**: "just continue with the plan for now; try to move towards
|
||
feature parity for the transcript screen so that the test can be
|
||
more fair." So P0's "must pass before P1 starts" is lifted — the
|
||
phone bench continues alongside, and parity is what makes its
|
||
comparison fair. **Sub-order, decided by the design agent, by what
|
||
the bench fixture exercises and Compose already draws** (each is
|
||
one agent; tick and date in place):
|
||
- [x] **P1a — markdown block rendering parity.** Done 2026-09-06.
|
||
Each top-level block is drawn in one of **three frames**
|
||
(`transcript-ui::markdown::BlockFrame`, mapped from
|
||
`BlockKind` by the pure `frame_of`): `Plain` (a paragraph,
|
||
heading, list or rule -- text and spans, no extra widget),
|
||
`Verbatim { fill }` (a fence or a table -- a rounded panel
|
||
that does not wrap and pans sideways, `CodeFence.kt`'s
|
||
`horizontalScroll`), and `Quote` (a bar behind text padded
|
||
past it). Everything else markdown can say is expressed in
|
||
`SpanStyle`s, which cost no widgets.
|
||
**What each block looks like now, against `Markdown.kt`:**
|
||
- *Headings* -- Material's own ladder, the six sizes
|
||
`markdownTypography` picks (24/22/16/14/12/11 at a 16pt
|
||
body), bold. Was a three-step 28/24/21/19.
|
||
- *Fences* -- monospace on Mocha Crust, rounded, with
|
||
`client_core::highlight`'s spans by language in the same
|
||
Catppuccin palette `Theme.kt`'s `catppuccinSyntax()` uses.
|
||
An unknown language is plain rather than coloured by the
|
||
nearest one. A fence being streamed into re-renders only
|
||
the last block (`RowBlocks::apply_delta`), so earlier
|
||
fences are never re-scanned.
|
||
- *Lists* -- the bullet ladder `MarkdownPieces.kt` draws
|
||
(disc/ring/square by depth) and ordered lists counting from
|
||
the number written, markers in Lavender.
|
||
- *Tables* -- padded monospace columns measured from the
|
||
cells, header bold, a rule under it, on Surface 0. A real
|
||
grid was rejected; docs/DECISIONS.md, 2026-09-06, has why.
|
||
- *Quotes* -- a Surface 2 bar down the left, text one shade
|
||
back from body.
|
||
- *Links* -- coloured and underlined as before, and now
|
||
**tappable**: `GestureOutcome::Tapped` (a press that
|
||
committed to neither a pan nor a selection),
|
||
`TextEditCtx::byte_at` for which byte, and
|
||
`iris::platform::OpenUrl` for the platform (`xdg-open`/
|
||
`open`/`start`; on Android an `ACTION_VIEW` intent deferred
|
||
to `after_input`, the shape `pending_show_keyboard` uses).
|
||
**Screenshots: `docs/bench/p1a-2026-09-06/`.**
|
||
`compose-heading-fence-table.png` and
|
||
`compose-fence-table.png` are the Compose `bench` build on
|
||
this checkout's AVD against `app/bench-fixture/`;
|
||
`iris-blocks.png` is iris rendering the same heading,
|
||
paragraph, link, fence and table source (plus a list and a
|
||
quote, which the fixture has neither of) from
|
||
`transcript-ui`'s own `transcript` example.
|
||
**Why the iris half is not from the emulator**, which the
|
||
pass condition asked for: **the emulator cannot draw iris's
|
||
glyphs at all.** Every character comes out as a solid filled
|
||
box of the right width -- `iris-emulator-gles-glyphs.png`.
|
||
Established as *not* this change's doing and not the app's:
|
||
the previous commit (`20303e0`) draws the same boxes, and the
|
||
Compose bench build on the same AVD in the same minute draws
|
||
text perfectly. The atlas sample's alpha reads as 1 under
|
||
`-gpu host` + `-feature -Vulkan` (Mesa 26.2.2 / virgl), which
|
||
is what an *incomplete* GL texture returns (0,0,0,1). Both
|
||
ways out were tried and both fail: `GPU_HOST_FEATURES=" "`
|
||
still dies at boot with gfxstream's documented "Format
|
||
VK_FORMAT_R8G8B8A8_UNORM is not supported ... Failed to find
|
||
memory type for ColorBuffers", and `EMU_GPU=software` does
|
||
give the guest SwiftShader Vulkan but iris **SIGSEGVs inside
|
||
`surface_changed`** on it. So the appearance half of this box
|
||
is taken on the desktop/winit backend, which renders on the
|
||
host's real GPU through `iris/run-headless.sh`.
|
||
**What still differs, pair by pair:**
|
||
1. *Colour, on the desktop shot only.* The winit surface is
|
||
sRGB and the shader writes the palette's bytes as linear,
|
||
so every fill reads ~4x lighter: Crust (17,17,27) comes out
|
||
(73,73,91), measured. Not a palette error and not present
|
||
on Android, where the previous pass measured the composer
|
||
bar at rgb(41,40,49) for a declared (40,40,46). Worth its
|
||
own item; it makes the desktop build a poor colour
|
||
reference until fixed.
|
||
2. *A list's wrapped line.* Compose lays an item out as a
|
||
marker column beside a text column, so a second line stays
|
||
indented; iris writes the marker into the same buffer, so
|
||
a wrapped line returns to the left margin. Needs per-line
|
||
indent in `TextAttrs`.
|
||
3. *A table.* Compose draws a real grid, cells wrapping at a
|
||
136dp floor; iris draws padded monospace columns. Same
|
||
information, different picture.
|
||
4. *Inline code.* Compose draws a chip behind it; iris gives
|
||
the range a monospace face and the code colour. Unchanged
|
||
by this box -- still blocked on per-range glyph geometry
|
||
(IRIS_TODO).
|
||
5. *A user message.* Compose draws it in a rounded card;
|
||
iris draws a sender label above plain text. That is the
|
||
row's own styling, P1's rather than P1a's.
|
||
**One real defect found and fixed on the way**, and it is
|
||
not a small one: **`Rect::is_size_independent()` answered
|
||
`true`.** A `Rect` fills whatever region it is given, so its
|
||
content *is* the region -- and `draw_inner`'s
|
||
size-independent fast path, which rewrites a widget's
|
||
primitives with `r.outside(&from).within(®ion)` instead of
|
||
redrawing, cannot reproduce that remap once a region carries
|
||
both `rel` and `abs`. The visible result: a fenced block's
|
||
background kept the height of the **provisional full-region
|
||
draw** `Span` does in its first phase, so one fence's panel
|
||
covered every block below it *and every row below that*,
|
||
while the text underneath was laid out correctly. It answers
|
||
`false` now (`iris/src/widget/rect.rs`, with the account at
|
||
the definition). This is very likely the same family as this
|
||
file's older "the composer bar's grey background is not
|
||
drawn" note and any other `.background(rect(..))` tint.
|
||
**One defect found and left open**, with its repro:
|
||
`UiRenderState::reposition`'s debug assert -- *"widget ... is
|
||
both moved by its parent's own layout (`mov`) and
|
||
repositioned within it"* -- fires from `List::place` when a
|
||
transcript row's blocks **wrap**. Reproduce in one line:
|
||
change `.wrap(!verbatim)` to `.wrap(true)` in
|
||
`transcript-ui/src/row.rs`'s `build_block` and run
|
||
`iris/run-headless.sh transcript --shot /tmp/x.png -- -p
|
||
transcript-ui`. It is *not* caused by the `Rect` fix above
|
||
(it survives it) and not by any one block kind (bisected: it
|
||
appears once the row is tall enough). The shipping
|
||
configuration does not reach it -- verbatim blocks do not
|
||
wrap -- and neither does the Android bench, which ran clean
|
||
with the assertions live. It should be the next thing looked
|
||
at under P1, because it is a real inconsistency about who
|
||
owns a widget's move slot, not a false alarm.
|
||
**Bench, stream phase, this checkout's AVD, debug x86_64
|
||
`force-gles`, assertions live, no abort:**
|
||
`stream: 294 frames over 21.0s, late 283 (96.3%), p50 53.0ms
|
||
p90 108.6ms p99 132.0ms` against the pre-P1a
|
||
`p50 52.8ms p90 108.1ms p99 137.3ms` -- unchanged, which is
|
||
the point: block styling is span work, not layout work. The
|
||
`worst` figure is the one number that moved and it does not
|
||
reproduce: 567.3ms, 140.1ms and 664.5ms across three runs of
|
||
the same build, against 148.9ms before. Unexplained; it is a
|
||
single frame in 294 and the percentiles are flat, so it reads
|
||
as an emulator hiccup rather than a cost, but it is written
|
||
down rather than rounded off.
|
||
**Checks**: `cargo fmt --all --check` clean in both
|
||
workspaces; `cargo clippy -p iris -p iris-core -p
|
||
transcript-ui -p desktop-app -p tabs-ui --all-targets`
|
||
warning-free; `cargo test` 85 (iris, +4) + 13 (iris-core) +
|
||
31 (transcript-ui, +11) + 123 (client-core).
|
||
**2026-09-06, after P1a: "the emulator cannot draw iris's glyphs"
|
||
was iris's bug, not the emulator's.** The finding recorded in the
|
||
box above -- that every glyph is a solid filled box under `-gpu
|
||
host` GLES and that this is what an incomplete GL texture returns
|
||
-- had the mechanism right and the attribution wrong. It is a real
|
||
defect on **any** adapter that is GL rather than Vulkan.
|
||
- **Reproduced off the emulator entirely**, which is what made it
|
||
cheap: `default/render.rs` now honours the same `force-gles`
|
||
feature `android/render.rs` did, so
|
||
`./run-headless.sh transcript --shot /tmp/x.png -- -p
|
||
transcript-ui --features iris/force-gles` draws the boxes on this
|
||
machine's own GPU in seconds. Two shader probes then said what
|
||
the sample was: `return vec4(texel.rgb, 1.0)` drew black boxes and
|
||
`return vec4(texel.a, texel.a, texel.a, 1.0)` drew white ones, so
|
||
the atlas sample was exactly (0, 0, 0, 1) -- GL's answer for an
|
||
**incomplete texture unit**, and not the null texture (which is
|
||
zeroed, alpha 0).
|
||
- **Root cause: the glyph atlas array was created with one layer.**
|
||
`GpuTextures::new` started `array_capacity` at 1 and `grow_array`
|
||
only doubles once a page needs a layer past it, so the ordinary
|
||
case -- one atlas page -- is a one-layer array. wgpu-hal picks the
|
||
GL texture target from the descriptor alone
|
||
(`gles::Texture::get_info_from_desc`: `(false, 1) => TEXTURE_2D`),
|
||
so that array is created as a `GL_TEXTURE_2D` and then bound to
|
||
the shader's `sampler2DArray`. wgpu has a name for this
|
||
(`log_failing_target_heuristics`, its issues #1614/#1574); the
|
||
result is an incomplete unit, `texel.a == 1`, and `draw_glyph`'s
|
||
`color.a *= texel.a` paints the whole glyph quad.
|
||
- **Fix**: `MIN_ARRAY_LAYERS = 2` in
|
||
`iris/core/src/render/texture.rs` -- the array is never created
|
||
with fewer, with the account at `create_array_texture` and a
|
||
`debug_assert!` there so a future capacity arithmetic change fails
|
||
at the mistake rather than as boxes on a screen. Cost: one page of
|
||
texture memory, which the next atlas page uses anyway.
|
||
- **Not a regression from `3e72a4e..20303e0`, and the bisect was not
|
||
run.** The defect is a function of the layer count, not of any
|
||
commit in that range: it has been there since the atlas became a
|
||
`texture_2d_array` (TEXTURES.md, 2026-09-04) and it reproduces at
|
||
HEAD and disappears at HEAD with the one-line capacity change. The
|
||
claimed "visible text at `3e72a4e`" is a misreading of its own
|
||
evidence -- `/tmp/final-typing.png`, the screenshot that entry
|
||
cites, is boxes; what the agent read was the `iris text render:
|
||
chars=5 glyphs=5` log line, which reports what **parley shaped**,
|
||
not what reached the screen. The earlier genuinely-good emulator
|
||
shots (`/tmp/after3.png`, 2026-09-05 19:56) predate the APK being
|
||
built with `force-gles` at all, so they were the Vulkan path.
|
||
- **The phone build is not affected and does not need withdrawing.**
|
||
`android-app/build-apk.sh`'s default features are deliberately
|
||
without `force-gles` (`d73db97`'s comment), so a phone build takes
|
||
`Backends::PRIMARY` -> Vulkan, where a one-layer array is an
|
||
ordinary one-layer array and glyphs draw correctly -- which is
|
||
also what Iris's phone reports have shown all along. The fix
|
||
matters for any device that falls back to GLES, which is why it is
|
||
not just an emulator convenience.
|
||
- **Appearance testing on Android is back.** `build-apk.sh debug
|
||
--abi x86_64 --features "transcript-screen bench force-gles"` on
|
||
this checkout's AVD draws the transcript legibly --
|
||
`docs/bench/p1a-2026-09-06/iris-emulator-gles-fixed.png` -- so
|
||
P1b onwards can be checked here rather than only on the desktop
|
||
backend or Iris's phone.
|
||
**2026-09-06: the move slot has one owner.** IRIS_TODO's open
|
||
"a wrapped transcript row trips `reposition`'s debug assert" is
|
||
fixed rather than suppressed. `mov` accumulates a delta on a
|
||
widget's move slot and `reposition` overwrote 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`) -- measured with a probe on the `.wrap(true)` repro:
|
||
`h=1604.7 height=548.7`, the row's own draw having updated
|
||
`active.size` without the list's height cache. The slot now means
|
||
`move_applied + repositioned` (both on `ActiveData`), so
|
||
`reposition` adds the move instead of dropping it and stays
|
||
idempotent, and the old assert is replaced by a `debug_assert_eq!`
|
||
that nothing *but* those two ever wrote the slot. Test:
|
||
`a_widget_moved_by_its_parent_and_then_placed_inside_it_lands_at_the_placement`
|
||
(`layout_tests.rs`), which draws the child at the offered position
|
||
rather than the placement without the fix. Checks: `cargo fmt --all
|
||
--check` clean, `cargo clippy --workspace --all-targets`
|
||
warning-free, `cargo test --workspace` 86 (iris, +1) + 13
|
||
(iris-core) + 31 (transcript-ui), the `.wrap(true)` repro drawing
|
||
correctly, and an emulator bench run with assertions live and no
|
||
abort (`2438 frames over 147.7s, p50 27.2ms`).
|
||
|
||
- [x] **P1b — tool-call cards and grouping.** Done 2026-09-06.
|
||
`ToolRows.kt`/`ToolInput.kt` ported to
|
||
`iris/transcript-ui/src/tool.rs` plus two new pure modules in
|
||
`client-core`. **Screenshots:
|
||
`docs/bench/p1b-2026-09-06/iris-tools-collapsed.png` and
|
||
`iris-tools-expanded.png`**, both from
|
||
`iris/run-headless.sh transcript -- -p transcript-ui` on the
|
||
desktop/winit backend (the emulator was not touched this pass
|
||
-- another agent held this checkout's AVD). The expanded one
|
||
is taken with `IRIS_TOOLS_EXPANDED=1`, which the example reads
|
||
to call `TranscriptScreen::expand_tail_tools` -- the expanded
|
||
appearance is otherwise unreachable on a machine with no
|
||
display and no finger.
|
||
**What the cards look like, against `ToolRows.kt`:**
|
||
- *A collapsed card* -- a mark, the tool's name (14pt), the
|
||
one-line summary `parse_tool_input` derives (12pt, Subtext
|
||
0, one line, clipped), and the state word at the far right.
|
||
Same as Compose, except that Compose ellipsises the summary
|
||
and iris clips it: there is no overflow-ellipsis in
|
||
`TextAttrs` yet (IRIS_TODO).
|
||
- *An open card* -- the timeout at the top right, the tool's
|
||
own description, the subject in a `Verbatim` panel with
|
||
`client_core::highlight`'s spans, the leftover input fields
|
||
under it, then the output. Same order as Compose.
|
||
- *A group* -- "Called N tools" (Compose's exact wording, and
|
||
so the name a `ui-trace` script taps), the cards on a Mantle
|
||
surface, and a chevron bar at the foot that closes it from
|
||
the end the reader is looking at.
|
||
- *States* -- `client_core::transcript_fold::ToolState`, five
|
||
of them, each with its own word and colour: nothing for
|
||
`Succeeded`, "running" (Subtext 0), "your turn" (Peach, the
|
||
Compose card's own wording and colour), "failed" (Red) and
|
||
**"no result" (Yellow)**. The last two are new -- Compose
|
||
can say neither.
|
||
**Two things the port had to add to be able to say "it
|
||
broke".** `event_model::Event::ToolEnd` gained `is_error`
|
||
(`#[serde(default)]`), read from the CLI's own `tool_result`
|
||
by one function used by both the live translator and the
|
||
import replay (`import::tool_result_is_error`); without it a
|
||
result was all a card had and a failed call drew exactly as
|
||
confidently as one that worked. And `ToolState` separates
|
||
`Succeeded`-with-empty-output from `NoResult`: both leave the
|
||
same empty string, and only the session's own status tells
|
||
them apart, which is why `TranscriptScreen::
|
||
set_session_working` exists and why only the *newest* row can
|
||
be "running" (every row behind it belongs to a turn that has
|
||
ended).
|
||
**Pass condition, met**:
|
||
`collapsed_cards_shape_only_their_summary_lines`
|
||
(`transcript-ui/src/lib.rs`) opens a group of three cards
|
||
whose calls carry 88 kB of output each and asserts the
|
||
text-shape count equals the same group's over three bytes.
|
||
**17 either way.** Confirmed to be a real test, not a
|
||
tautology, by pushing the output block into the collapsed
|
||
branch: **17 against 20**.
|
||
`a_result_arriving_redraws_one_card_whatever_the_run_holds` is
|
||
the second: one `ToolEnd` costs the same number of
|
||
`Widget::draw` calls in a twelve-call run as in a three-call
|
||
one.
|
||
**Three defects found on the way, all by looking at the
|
||
render rather than at the diff:**
|
||
1. **A `Span` of `Pad`ded children inside another `Span`
|
||
places those children a slot out of step.** Every card drew
|
||
its content one card's height below its own box, so the
|
||
group read as empty bars with somebody else's summary in
|
||
them. Bisected against
|
||
`IRIS_TOOLS_EXPANDED=1 iris/run-headless.sh transcript`:
|
||
removing the inner `Span` fixes it, and so does removing
|
||
the cards' own `Pad`; the card background, the `Sized`
|
||
wrappers and the per-card `WidgetPtr` all make no
|
||
difference. Worked around by building the group as **one**
|
||
`Span` (header, cards, collapse bar), which costs the 4dp
|
||
inset Compose holds its cards off the group's edge by. The
|
||
framework defect is still open -- IRIS_TODO has it, and it
|
||
is not the `mov`/`reposition` one f5b8893 fixed (it
|
||
survives that commit).
|
||
2. **`scrollable_on(Axis::X)` on a non-editable `Text` draws
|
||
nothing at all** -- an empty panel where the command should
|
||
be. A markdown fence does the same thing to a `TextEdit`
|
||
and is fine. So a card's verbatim block is `masked()` and
|
||
clips rather than panning; when this is fixed the pan
|
||
belongs there too, because the long command is the one
|
||
being read closely.
|
||
3. **`NotoSans-Regular.ttf` has no U+25B8/25BE/25B4** (read
|
||
out of the bundled `cmap`s) while `NotoSansMono-Regular`
|
||
does, so the expander mark is set in the monospace face at
|
||
the one place the character is written. The old
|
||
`build_tools` summary drew that codepoint in the sans face,
|
||
which was a missing glyph nobody had looked closely enough
|
||
to see.
|
||
**Checks**: `cargo fmt --all --check` clean in both
|
||
workspaces; `cargo clippy -p iris -p iris-core -p
|
||
transcript-ui -p desktop-app -p tabs-ui --all-targets` and
|
||
`cargo clippy --all-targets` in `client-core`/`server`/
|
||
`event-model` warning-free; tests 86 (iris) + 13 (iris-core) +
|
||
36 (transcript-ui, +5) + 137 (client-core, +11) + 160
|
||
(server, +1).
|
||
**Not done**: nothing on the emulator or the phone (the AVD
|
||
was another agent's this pass, so no frame times were taken);
|
||
a card's text is not selectable, unlike Compose's, since
|
||
`Selection` is keyed per markdown block and a card has none
|
||
(IRIS_TODO); no per-corner radius, so the "connected stack"
|
||
shape `connectedShape` draws is a 2dp gap instead;
|
||
`AskUserQuestionBody`/`PermissionAsk`'s answer buttons are not
|
||
ported -- an unanswered ask forces its card open and says
|
||
"your turn", but there is nothing to press yet, which is P1d's
|
||
modal/controls work.
|
||
- [ ] **P1c — history paging and jump-to-latest.** Wire
|
||
`client-core::transcript_source` into `transcript-ui`:
|
||
the opening page, paging back on scroll with the cushion
|
||
measured in on-screen viewports (`HISTORY_SCREENS`, IRIS_TODO
|
||
"Build (for the port)"), the `NothingLoaded`/empty/error
|
||
states drawn distinctly (UI_RULES: design the unknown state
|
||
first), `join_pages` at each seam, and a jump-to-latest
|
||
control that pins to the newest end. Pass condition: the
|
||
P1 pass condition below, against `ui-sandbox.sh` with
|
||
`AI_SANDBOX_BIG_MB` and `--delay`.
|
||
- [ ] **P1d — images, the session settings dialog, attachments,
|
||
usage bar.** `SessionImage` thumbnails (the scaled image
|
||
widget), the modal primitive and `SessionSettingsDialog`/
|
||
`UsageDialog`, `PendingAttachments` over the attachments
|
||
route (`api.rs` gap), `SessionUsageBar` (the gauge widget).
|
||
- [ ] **P1e — keyboard and insets behaviours** from AGENTS.md's
|
||
"Things that have bitten", re-verified on the phone build:
|
||
composer never left floating after the keyboard closes
|
||
mid-stream, `adjustResize` + edge-to-edge together, one
|
||
recomposition-equivalent per keyboard toggle (the
|
||
`iris insets:` log line count).
|
||
|
||
History paging backward (with the
|
||
page-boundary healing `client-core` does not have yet, below),
|
||
`TranscriptSource`-backed cache/server stitching, jump-to-latest,
|
||
tool-call cards and grouping, the session settings dialog, composer
|
||
attachments, and the keyboard/insets behaviours AGENTS.md's "Things
|
||
that have bitten" names (the floating-composer bug, `adjustResize`,
|
||
the `imePadding`-vs-raw-inset rule). This is the highest-risk step:
|
||
it is the screen the app is used for, every hour of the day.
|
||
|
||
**Kotlin it replaces**: `SessionScreen.kt`, `TranscriptList.kt`,
|
||
`SessionSettingsDialog.kt`, `ToolInput.kt`, `ToolRows.kt`,
|
||
`AskQuestion.kt`, `Compaction.kt`, `SessionUsageBar.kt`,
|
||
`PendingAttachments.kt`, `Attachment.kt`, `Attachments.kt`,
|
||
`SessionImage.kt`, `MemoryNote.kt`, `PeerMessage.kt`, `RawBlock.kt`,
|
||
`CodeFence.kt`, `MarkdownLinks.kt`, `MarkdownPieces.kt`,
|
||
`Markdown.kt`, `Bubble.kt`, `ScrollAnchor.kt`, `Drafts.kt`,
|
||
`UsageDialog.kt`, `Chevron.kt`, `Dividers.kt`. (`transcript-ui`
|
||
already covers the row/markdown/selection/composer core these sit
|
||
on top of or beside.)
|
||
|
||
**`client-core` needed, and what is not yet covered and must be
|
||
ported first** (`CLIENT_CORE.md`): `TranscriptSource.kt` (deciding
|
||
cache vs. server per page and stitching them — "not started"),
|
||
`TranscriptItems.kt`'s `joinPages`/`healSplitMessage`/`adoptRun`
|
||
(page-boundary healing — "not ported," and paging backward is
|
||
exactly what exercises it), the markdown *block* model beyond
|
||
syntax spans (headings/lists/tables/fences as distinct nodes —
|
||
"not started," needed for `CodeFence`/`MarkdownPieces`' equivalents),
|
||
and the attachments route (`/sessions/{id}/attachments` — "not
|
||
covered" in `api.rs`, needed for `PendingAttachments`/`Attachment`).
|
||
|
||
**iris widgets missing, → `IRIS_TODO.md`'s new "Build (for the
|
||
port)" section**: row-level accessibility names and the tappable
|
||
link / background-chip primitive (both already listed under I5's
|
||
leftovers — this step is what needs them, not a new ask); a
|
||
history-paging cushion measured in on-screen viewports rather than
|
||
a row count (the `HISTORY_SCREENS` lesson in "Things that have
|
||
bitten," which iris's `List` has no equivalent of yet); a scaled
|
||
thumbnail/image widget for `SessionImage`'s in-transcript images; a
|
||
modal/dialog primitive for the session settings dialog and
|
||
`UsageDialog` (iris has none today — check before building a second
|
||
one for P3/P5); a horizontal gauge/bar widget for
|
||
`SessionUsageBar`.
|
||
|
||
**Pass condition**: `app/ui-sandbox.sh`'s fixtures driven by
|
||
`ui-trace record --do "tap '<label>'"` — a session with the big
|
||
transcript (`AI_SANDBOX_BIG_MB`), a paused/slow-spawning one
|
||
(`AI_SANDBOX_SPAWN_DELAY`), and `--delay` on the server — exercising
|
||
the four states UI_RULES.md says to design first: unknown (a page
|
||
that hasn't loaded), empty (a session with no messages yet), error
|
||
(a failed send/interrupt), and too-long (the big transcript,
|
||
paged). Re-take the I5 `FrameReport` (`iris frame report` in
|
||
logcat, same as I5's box) once this screen has real paging and
|
||
compare it against I5's own numbers, not against Compose's — the
|
||
three measurement sources still are not comparable per
|
||
`DECISIONS.md`'s DEFERRED item.
|
||
|
||
- [ ] **P2 — the shell merge and a real phone install.** Merge this
|
||
screen's cdylib into the E3/E5 shell (`android-shell` +
|
||
`app/shellApp`) behind the same feature-flag pattern I5 used to
|
||
extend `iris-android-app` (`DECISIONS.md`, 2026-09-05), so there is
|
||
one app — notification service, share target and the real screen —
|
||
rather than a demo shell and a service shell side by side. Package
|
||
with `cargo xtask apk` (E5) and get it onto the real GrapheneOS
|
||
phone, not just the emulator: `arm64-v8a` is the ABI that matters
|
||
there (the emulator here is x86_64), and the `this-machine-android`
|
||
skill's facts apply for the first time in this port — no System
|
||
Tracing on that phone (frame numbers have to come from `FrameReport`
|
||
itself), the local-network permission is required there even though
|
||
AOSP's docs say VPN traffic is excluded, and `ui-trace`/`adb`
|
||
target this checkout's own emulator by default so a real-device
|
||
command needs `-s <serial>` explicitly.
|
||
|
||
**Kotlin it replaces**: nothing further than E3 already did
|
||
(`Notifications.kt` → `notifications.rs`, `Share.kt` → `share.rs`,
|
||
`ServerConfig.kt`'s Keystore half → JNI calls into `wg-app-link`) —
|
||
this step is wiring P1's screen in as the shell's real content
|
||
instead of E3's placeholder, plus getting a signed APK onto a
|
||
physical device for the first time in this port.
|
||
|
||
**`client-core` needed**: none new; E3 already covers what the
|
||
shell itself needs. Attachments (P1's gap) matter here too if a
|
||
real photo share is exercised.
|
||
|
||
**iris widgets missing**: none — this step is integration, not new
|
||
widgets.
|
||
|
||
**Pass condition**: `cargo xtask apk`, install on the real phone
|
||
over adb, enroll via the deep link, background the app and get a
|
||
real notification, share a text snippet into a session, and
|
||
confirm `ui-trace` can still find controls by name on real
|
||
hardware (accessibility names are not guaranteed to survive a real
|
||
device's TalkBack/AccessKit wiring the way they do in the
|
||
emulator — this is the first time that gets checked for real).
|
||
|
||
- [ ] **P3 — root tabs.** `Screen`/`MainTab` in `app-ui`: the sessions
|
||
list, import, models and setups tabs, plus spawn and the app's one
|
||
level of back-stack navigation (`AppRoot.kt`'s `when`).
|
||
|
||
**Kotlin it replaces**: `AppRoot.kt`, `MainScreen.kt`,
|
||
`SessionListScreen.kt`, `ImportScreen.kt`, `ModelsScreen.kt`,
|
||
`SetupsScreen.kt`, `SpawnScreen.kt`, `BusyItem.kt`,
|
||
`UniqueItems.kt`, `SessionAlerts.kt`.
|
||
|
||
**`client-core` needed, not yet covered**: setups/machine/provider
|
||
discovery, the models routes (`/models*`, HuggingFace browsing and
|
||
downloads), and importing (`/setups/{id}/importable*`) — all three
|
||
listed "not covered" in `api.rs`'s table and none started; each is
|
||
real work, not a stub, per `CLIENT_CORE.md`'s own caveat.
|
||
|
||
**iris widgets missing**: a `BusyItem` equivalent — a row dimmed,
|
||
drained of colour, labelled with the operation in progress, that
|
||
does **not** block the list's own scroll/drag the way an overlay
|
||
did on the Compose side (AGENTS.md's "Shared appearance"); a
|
||
`uniqueItems` equivalent is logic, not a widget, and ports directly
|
||
into `app-ui` itself; a confirmation dialog with a toggle switch,
|
||
for the delete-with-`deleteForeign` flow, needs the same modal
|
||
primitive P1 flagged — build it once, here or in P1, whichever
|
||
lands first.
|
||
|
||
**Pass condition**: `ui-trace` tap-by-name on all four tabs against
|
||
`ui-sandbox.sh`'s fixtures; the two-copies-of-one-session-id
|
||
fixture (AGENTS.md's "Importing") does not crash the list — this is
|
||
the regression `uniqueItems` exists for and it must be exercised
|
||
here, not assumed; the delete dialog's paragraph reads correctly
|
||
both with and without `deleteForeign` toggled (its own text, not
|
||
appended, per AGENTS.md).
|
||
|
||
- [ ] **P4 — file explorer.** The viewer, the editor with its
|
||
`EDIT_LIMIT`, and the 409 conflict.
|
||
|
||
**Kotlin it replaces**: `FilesScreen.kt`, `FileViewer.kt`,
|
||
`FileEditor.kt`, `FileLines.kt`.
|
||
|
||
**`client-core` needed, not yet covered**: `/setups/{id}/dir|file`
|
||
— not in `api.rs`'s covered list, real work, port first.
|
||
|
||
**iris widgets missing**: nothing beyond what P1 needs (a
|
||
virtualised line-numbered text view is `iris::widget::List` reused,
|
||
per I3's box) — the open question is whether the editor's
|
||
`BasicTextField`-equivalent cost (`docs/EXPLORER.md`'s "what the
|
||
measurements said") reproduces in iris's `TextEdit` at the same
|
||
`EDIT_LIMIT`, which this step has to re-measure rather than assume.
|
||
|
||
**Pass condition**: `app/ui-sandbox.sh`'s `~/files` fixture tree
|
||
(empty dir, tab/apostrophe names, binary, over `FILE_LIMIT`,
|
||
`chmod 000`, symlinks good and broken, one source file per
|
||
language, `edit-32k.rs`/`edit-128k.rs`/`big-source.rs`) driven by
|
||
name; the 409 reproduced by editing the file on the machine between
|
||
opening it and saving, per AGENTS.md's own recipe.
|
||
|
||
- [ ] **P5 — settings, enrollment, notifications permission.**
|
||
|
||
**Kotlin it replaces**: `SettingsScreen.kt`, `ServerConfig.kt`'s
|
||
remaining non-Keystore parts, `DebugStats.kt`, `FrameStats.kt`,
|
||
`CrashLog.kt`. The QR scanner (`EnrollmentScanActivity`, in
|
||
`wg-app-link`) is platform-only and is **not** replaced — it stays
|
||
a Java/Kotlin activity per decision 1 above, called into from
|
||
`app-ui` the way it is called into from Compose today.
|
||
|
||
**`client-core` needed**: none new — `config.rs`'s
|
||
`EnrolledServer`/`parse_link` already cover the deep-link half; the
|
||
Keystore half stays the JNI call E3 already wired.
|
||
|
||
**iris widgets missing**: none identified yet — a plain form screen.
|
||
|
||
**Pass condition**: enroll via the same `aiappshell://enroll?...`
|
||
link `ui-sandbox.sh`'s banner prints; the local-network-not-allowed
|
||
banner (AGENTS.md's standing-condition text) reads by name when the
|
||
permission is off; `POST_NOTIFICATIONS` request flow checked on the
|
||
real phone from P2, not just the emulator.
|
||
|
||
- [ ] **P6 — desktop parity.** Root tabs, explorer and settings on
|
||
`iris/desktop-app`, matching P3–P5 there. Not a Kotlin replacement
|
||
(the desktop app has no Compose original) — this is closing the
|
||
gap `E4` deliberately left (session list + transcript only).
|
||
|
||
**`client-core` needed**: the same P3/P4 gaps, once closed there.
|
||
|
||
**Pass condition**: `run-headless.sh` screenshots of each tab and
|
||
the explorer against `app/ui-sandbox.sh`, the same way E4's did.
|
||
|
||
- [ ] **P7 — the switch of `ai-app`'s main.** Point `ai-app`'s production
|
||
Android build at `app-ui`/`android-shell` instead of
|
||
`app/androidApp`; decide then whether `app/androidApp` stays as a
|
||
reference or is retired — a load-bearing decision (AGENTS.md's
|
||
"ask before changing load-bearing decisions") to bring to Iris
|
||
rather than make here.
|
||
|
||
**Pass condition**: the full set of pass conditions above, re-run
|
||
once more against a real `ai-server` (not the sandbox) on a real
|
||
phone, side by side with the Compose build until it holds.
|
||
|
||
## For the next agent
|
||
|
||
What to do when you pick this up, in order, so nothing here has to be
|
||
re-derived:
|
||
|
||
1. Read this file, then `AGENTS.md` and `PLAN.md`. The rules there
|
||
(measure, do not read; fix the rig before accepting its limits; the
|
||
emulator is this checkout's own) all apply.
|
||
2. Work on the **`rustify`** branch of this clone (`ai-app-2`), not on
|
||
`main` and not in `ai-app`. Nothing on this branch is production until
|
||
Iris says so. Commit and push as you go.
|
||
3. The E- and I-steps (the framework decision) are done — iris won,
|
||
`DECISIONS.md` 2026-09-05. Take the next unchecked P-box in "## The
|
||
port, in order (decided 2026-09-05)"; **P1 — session screen parity —
|
||
is next.**
|
||
4. Every step ends with its measurement written into this file beside the
|
||
box, and the box ticked or the reason it could not be written in its
|
||
place. A step that is blocked says by what, not "later". Write it as you
|
||
go rather than at the end — see "Keep this file current as you work".
|
||
5. Run the existing rigs rather than inventing new ones: `ui-sandbox.sh`
|
||
for a server with fixtures, `transcript-bench.sh` for the scroll
|
||
baseline, `ui-trace` for anything positional, `emu up` for the
|
||
emulator, `iris/run-headless.sh EXAMPLE --shot PNG` for an iris
|
||
example on this displayless machine, and `rigs/gpu-probe` to ask a
|
||
device (this VM, the emulator, or a real phone over `adb push`) what
|
||
`wgpu` features and limits it actually has before building anything on
|
||
the assumption it does. The Vulkan section below says how to get a
|
||
Vulkan path in the emulator when a `wgpu` backend needs one.
|
||
6. **Bound anything heavy at the moment you start it.** An emulator or a
|
||
long build gets a deadline — `timeout`, or a watchdog scoped to the pid
|
||
you just started — rather than a plan to stop it later. Scope it to
|
||
that pid: a watchdog written as `sleep N; emu down` fired into a later
|
||
experiment here and made a working Vulkan build look like a crash. And
|
||
stop the emulator when the work needing it is done rather than between
|
||
tasks.
|
||
7. Decisions belong here with a date and what was rejected, the way
|
||
`PLAN.md` does it. Do not put design into commit messages alone.
|
||
|
||
### Vulkan in the emulator (measured 2026-09-04)
|
||
|
||
**Settled 2026-09-04: the guest gets Vulkan from SwiftShader, and the
|
||
missing step was a cold boot.** `-feature Vulkan` plus
|
||
`VK_DRIVER_FILES=$HOME/Android/Sdk/emulator/lib64/vulkan/vk_swiftshader_icd.json`
|
||
gets the *host* side to select SwiftShader, but the guest keeps reporting
|
||
zero devices until `-no-snapshot-load` is added, because it boots from a
|
||
snapshot saved under the previous GPU config — `-no-snapshot-save` is
|
||
worth adding too, so the Vulkan-configured snapshot does not then break
|
||
the next ordinary boot. With that, `cmd gpu vkjson` reports SwiftShader
|
||
Subzero and wgpu takes its Vulkan path (E1). `EMU_GPU=software` in
|
||
`emulator-tools` gets the same guest Vulkan with no GPU use at all, for
|
||
work where the emulator's frame rate is not what is being measured.
|
||
|
||
The rest of this section stands as the record of why host Vulkan is not
|
||
available. A `wgpu` app in this emulator was going to get GLES only,
|
||
because host Vulkan is switched off in `emulator-tools`. Retried on Mesa
|
||
26.1.7: **Venus still fails the same way** — gfxstream picks
|
||
`externalMemoryMode: OpaqueFd`, probes `VK_FORMAT_R8G8B8A8_UNORM` for an
|
||
exportable colour buffer, and Venus says the format is unsupported
|
||
(`Failed to find memory type for ColorBuffers`, fatal before adb sees the
|
||
device). Venus does advertise `VK_KHR_external_memory_fd` and
|
||
`VK_EXT_external_memory_dma_buf`, so the gap is specifically opaque-fd
|
||
image export. gfxstream has a string-valued `VulkanExternalMemoryMode`
|
||
setting ("overrides what would otherwise be determined automatically"),
|
||
but `-feature Name=Value` is rejected as a bad feature name, and the only
|
||
mode words compiled into this emulator's `libgfxstream_backend.so`
|
||
(37.1.11) are `OpaqueFd`, `Metal` and `none` — there is no dma-buf mode
|
||
in this build to switch to. So Venus is blocked by the emulator, not by
|
||
Mesa; retry when the emulator package updates, since upstream gfxstream
|
||
does have dma-buf external memory.
|
||
|
||
What **does** work: pointing the emulator's Vulkan loader at the software
|
||
ICDs the emulator ships itself, with the feature enabled:
|
||
|
||
VK_DRIVER_FILES=$HOME/Android/Sdk/emulator/lib64/vulkan/vk_swiftshader_icd.json \
|
||
GPU_HOST_FEATURES="-feature Vulkan" emu up
|
||
|
||
The guest then reports Vulkan 1.3 (`cmd gpu vkjson`, SwiftShader
|
||
Subzero) while GLES still runs on the real GPU through virgl — so a
|
||
Vello/wgpu app can take its real Vulkan path here, with compute shaders,
|
||
CPU-rasterised. That is enough to test *correctness* of the Vulkan path
|
||
in the emulator; GPU *performance* of it is a phone measurement either
|
||
way, exactly as `MACHINE.md` already says about frame times. **lavapipe**
|
||
(`lvp_icd.json`, the other ICD the emulator ships) selected llvmpipe and
|
||
booted, then the emulator died right after loading the `default_boot`
|
||
snapshot with nothing in the log; a snapshot saved under a different
|
||
Vulkan device is the suspect, and `-no-snapshot-load` is the untested
|
||
next step. SwiftShader is the one that works today.
|
||
`EMU_GPU=software` is now in `emulator-tools` (agreed with the ai-app
|
||
session and with Iris, default unchanged, since `-gpu host` was measured
|
||
and the Compose scroll benchmarks depend on it). The cold-boot flags are
|
||
not a knob there: that wants snapshot invalidation as well, which is a
|
||
bigger design question in shared tooling.
|
||
|
||
## Things a Rust app changes elsewhere
|
||
|
||
- **`wg-app-link`'s `:link`** (pinned TLS, enrollment store, QR activity)
|
||
is Kotlin shared with Dev Updater. The certificate code already exists on
|
||
the Rust side of the submodule; the pinned-CA build step
|
||
(`generatePinnedCert`) becomes a `build.rs` reading the same path. The QR
|
||
scanner stays a Kotlin activity, since the camera is a platform feature.
|
||
- **Tooling** becomes `cargo` for everything but packaging: `cargo test`,
|
||
`clippy`, `fmt` cover the whole client, which is the motivation. Gradle
|
||
remains for the APK, signing (`~/.config/ai-app/release.jks`) and Dev
|
||
Updater's build modes; `build-apk.sh` would call `cargo ndk` first.
|
||
- **The bench scripts** (`ui-trace` by accessibility label) keep working
|
||
only if the framework exposes names through AccessKit on Android; that is
|
||
part of E2's pass condition, not a nicety.
|
||
- **Icons** stay Nerd Font glyphs from the committed subset; Parley/Fontique
|
||
loads a font file directly, so `build-icon-font.sh` is unchanged.
|
||
|
||
## Sources
|
||
|
||
- iced: [repo](https://github.com/iced-rs/iced), [0.14 release](https://github.com/iced-rs/iced/releases/tag/0.14.0), [Android thread](https://news.ycombinator.com/item?id=46350641), [markdown selection request](https://discourse.iced.rs/t/markdown-widgets-text-should-be-selectable/1107)
|
||
- Linebender: [2026 Q1 report](https://linebender.org/blog/tmil-25/), [xilem](https://github.com/linebender/xilem), [parley](https://github.com/linebender/parley), [vello](https://github.com/linebender/vello), [vello_hybrid](https://docs.rs/vello_hybrid/latest/vello_hybrid/)
|
||
- android-view: [repo](https://github.com/rust-mobile/android-view); android-activity [PR #214](https://github.com/rust-mobile/android-activity/pull/214)
|
||
- winit Android IME: [#1823](https://github.com/rust-windowing/winit/issues/1823), [#2766](https://github.com/rust-windowing/winit/issues/2766), [#2305](https://github.com/rust-windowing/winit/issues/2305)
|
||
- egui on Android: [discussion #2053](https://github.com/emilk/egui/discussions/2053)
|
||
- Slint: [Android guide](https://docs.slint.dev/latest/docs/slint/guide/platforms/mobile/android/), [1.15 release](https://slint.dev/blog/slint-1.15-released), [licensing](https://slint.dev/faqs), rich text [#1325](https://github.com/slint-ui/slint/issues/1325), markdown [#6684](https://github.com/slint-ui/slint/issues/6684)
|
||
- Makepad: [repo](https://github.com/makepad/makepad), [makepad-widgets](https://docs.rs/makepad-widgets), [Robrix](https://github.com/project-robius/robrix), [Robrix releases](https://github.com/project-robius/robrix/releases)
|
||
- AccessKit: [releases](https://github.com/AccessKit/accesskit/releases)
|
||
- Build tools: [cargo-ndk](https://github.com/bbqsrc/cargo-ndk), [cargo-apk](https://github.com/rust-mobile/cargo-apk), [rust-mobile](https://github.com/rust-mobile)
|
||
- uniffi: [repo](https://github.com/mozilla/uniffi-rs), [KMP bindings fork](https://github.com/UbiqueInnovation/uniffi-kotlin-multiplatform-bindings)
|
||
- GPUI mobile: [gpui-mobile](https://github.com/itsbalamurali/gpui-mobile)
|
||
- The earlier Dioxus spike's findings on `wgpu`/Vulkan in this emulator: `~/repos/tdep-survey/app-dioxus/README.md`
|