Prune commentary and stale Rust port notes

This commit is contained in:
iris committed 2026-09-10 00:44:13 -04:00
1 parent 5428cd75c9
commit 25370731d0
193 files changed
+693 -16219

No files matched your search

+55 -202
View File
@@ -1,221 +1,74 @@
# `app-rust`'s `client` module
**Renamed 2026-09-08.** This was the `client-core` crate; it is now
`app-rust/src/client/`, a module of the one app crate rather than a crate
of its own (docs/RUST.md's "One app crate"). Nothing about what it *is*
changed: it is still the app's pure logic held once instead of twice, per
RUST.md's recommendation item 1, and still has **no UI framework
dependency of any kind** -- the `iris` dependency sits behind the `screens`
feature and nothing under `src/client` may reach it. That independence is
what lets it outlive whichever framework the app draws with, and it is now
an invariant of a module rather than of a manifest, so it is worth stating
plainly: a `use iris::` under `src/client/` is a defect.
`app-rust/src/client` contains platform- and UI-independent client logic. It
must not depend on iris; a `use iris::` below this directory is a layering
defect. `event-model` remains a separate crate because the server and client
both depend on that wire contract.
`event-model/` stayed a crate, and is the one split in the port that was
never optional: it is the wire shape both this app and `server/` depend on,
extracted from `server/src/session/driver.rs` and `session/transcript.rs`
on 2026-09-04, so a crate is what makes the two agree by construction.
## Contents
Paths below are written as `src/client/…`, relative to `app-rust/`.
- `api.rs`: REST client over the injectable `Transport` trait.
- `sse.rs` and `event_stream.rs`: SSE framing and session-event following.
- `transcript_cache.rs`: bounded, persistent transcript chunks.
- `transcript_source.rs`: cache/server selection and live cache updates.
- `transcript_fold.rs`: event folding, tool grouping, and page healing.
- `markdown_blocks.rs`, `ansi.rs`, and `highlight/`: display-independent text
parsing and spans.
- `config.rs`: enrollment-link parsing and the shared `EnrolledServer` value.
- `log_ring.rs`: bounded process-local diagnostics.
## What's here, and what Kotlin file it replaces
## API coverage
| `src/client/…` | Kotlin original | Status |
|------------------------------------------|-------------------------------------------|--------|
| `event-model/src/lib.rs` (shared crate) | `Events.kt` (the enum mirror) | Done |
| `ansi.rs` | `Ansi.kt` | Done, ported test-for-test |
| `highlight/mod.rs`, `languages.rs` | `Highlighter.kt`, `Languages.kt` | Done, ported test-for-test |
| `highlight/markdown.rs` | `MarkdownSyntax.kt` | Done, ported test-for-test |
| `transcript_cache.rs` | `TranscriptCache.kt` | Done, ported test-for-test |
| `sse.rs` | `Sse.kt` (the framing half) | Done, new tests (Kotlin had none of its own beyond integration) |
| `api.rs` | `Api.kt` | Partial -- see below |
| `event_stream.rs` | `EventStream.kt` | Done |
| `transcript_fold.rs` | `TranscriptItems.kt`, `ToolRows.kt` | Done -- see below |
| `config.rs` | `ServerConfig.kt`'s `handleEnrollment` | New, desktop-only so far -- see below |
| `transcript_source.rs` | `TranscriptSource.kt` | Done -- see below |
| *(not ported, and may never be)* | `TranscriptUnits.kt` | Out of scope -- see below |
`ApiClient` covers session list/read, messages, unqueue, answers, interrupt,
stop/start, rename, working directory, model, permission mode, notification
setting, commands, compaction, deletion, and transcript pages.
Every file above whose Kotlin counterpart had a JVM unit test (`AnsiTest`,
`HighlighterTest`, `TranscriptCacheTest`) has had every one of those test
cases ported alongside it, plus new tests for the pieces that had none
(`sse.rs`, `api.rs`, `event_stream.rs`, `transcript_fold.rs`,
`transcript_source.rs` -- the Kotlin `TranscriptSource.kt`/`TranscriptItems.kt`
had no JVM unit tests of their own, so these were written fresh against the
Kotlin source and AGENTS.md's paging incidents as the spec). Test count by
crate as of this writing: **109 in `client-core`**, 0 in `event-model` (its
types carry no logic of their own to test -- `server/`'s own tests exercise
them via `session::transcript`'s round-trip coverage).
Still missing are setups and discovery, file operations, usage, models and
downloads, attachments, imports, and the global notifications stream.
`server/src/routes.rs` is the authoritative route table.
## Correspondence notes worth knowing before touching either side
## Transcript invariants
- **`ansi.rs`'s `StyledText`/`Style`/`Rgb`** stand in for Compose's
`AnnotatedString`/`SpanStyle`/`Color`, since this crate has no Compose.
`StyledText` is plain text plus a `Vec<(Range<usize>, Style)>` of
non-overlapping spans. Whatever UI framework ends up consuming this
crate maps `Style` onto its own text-styling type; nothing here should
change to accommodate a particular one.
- **`highlight`'s `Span`/`Kind`** use **char indices, not byte offsets**
(`Vec<char>` internally), mirroring the Kotlin original's `Char`-indexed
strings. `highlight::span_text` turns a `Span` back into text for a
caller working the same way; a caller that wants byte offsets into a
`&str` has to convert.
- **`transcript_cache.rs`'s `SessionCache::guard`** found a real
translation bug while it was being written: an early draft let a
*damaged* chunk (one file unreadable, discard just this session) and a
genuine I/O failure (disk gone, disable the whole cache) both surface as
the same `Err` from one closure, which would have disabled every
session's cache over a single corrupt chunk. Fixed by checking a
thread-local "was this damage" flag before deciding which failure mode
it was -- see the comment on `guard` and the commit message for
`transcript_cache.rs`.
`join_pages` heals messages and tool runs split across page boundaries. It
asserts that a tool id does not survive in both halves. It must not assert
sequence ordering across the seam: a peer note carries the sequence of the
turn it belongs above and can legitimately interleave with the page where it
arrived.
## What `api.rs` covers, and what it does not yet
`Event` has no catch-all variant. A newer server adding an event type will
make an older client reject that line rather than draw a placeholder. Fixing
that requires a shared wire-model decision, not a client-only workaround.
`ApiClient` wraps a `Transport` trait (network I/O kept out from behind, so
`ApiClient` and `event_stream::follow_session_events` are tested with a
fake transport and no server). `UreqTransport` is the only real
implementation, backed by `ureq` -- see its Cargo.toml comment for why
(blocking, already a project dependency, no extra TLS crate needed since
`ureq::tls::Certificate::from_pem` reads the pinned CA directly).
`TranscriptSource::page(0, ..)` returns `OlderPage::NothingLoaded` without
touching cache or network. This is deliberately distinct from
`OlderPage::Events(vec![])`, which means the start of the conversation was
actually reached. Network and cache parse failures are errors for the same
reason: none of these states may latch a caller's “no more history” flag.
Covered: session list/read, message send, unqueue, answer, interrupt,
stop, start, rename, cwd, model, permission-mode, notify, command,
compact, delete, and one transcript page.
Fetched transcript lines retain the server's exact JSON bytes through
`RawValue`. Re-serializing parsed JSON can change floating-point text, causing
the cached and streamed forms of one event to disagree byte-for-byte.
**Not covered, and each is real work rather than a stub to fill in:**
setups (`/setups*`, machine and provider discovery), the file explorer
(`/setups/{id}/dir|file`), usage (`/usage`), models
(`/models*`, HuggingFace browsing and downloads), attachments
(`/sessions/{id}/attachments`), importing (`/setups/{id}/importable*`),
and the `/notifications` stream. `server/src/routes.rs`'s module doc is
the full table to work from when one of these is next.
The reconnect/backoff loop and cancellation of a live stream belong to the
embedding runtime. `TranscriptSource::follow` only guarantees that each frame
is cached before the caller receives it.
## What `transcript_fold.rs` covers, and what it does not yet
## Enrollment
`fold_event` covers every `Event` variant server/ can produce today,
including tool-call/question/image attachment and peer-message placement.
`group_tool_runs` groups adjacent calls into `TranscriptRow::Tools`.
`EnrolledServer` and `parse_link` understand the same
`aiapp://enroll?host=H&port=P&token=T[&ca=B]` value used by Android. Storage
is caller-specific: Android uses its platform storage and the desktop writes a
0600 file under its XDG config directory.
`join_pages` (with `heal_split_message` and `adopt_run`, both private) is
now ported too, 2026-09-06 -- the page-boundary healing that merges a tool
call split across two fetched pages, rejoins a message a boundary cut
through, and renames a run of tool calls onto whichever name is already on
screen. Ported with AGENTS.md's "things that have bitten" incidents as the
spec rather than a JVM test file (`TranscriptItems.kt` had none of its
own): `a_clean_boundary_between_two_finished_runs_is_still_healed_into_one_run`
is the regression test for the bug that shipped -- `adopt_run` must run on
*every* join, not only the one where a split call was found, or a boundary
landing cleanly between two already-finished calls (most of them) leaves
one run drawn as two. `a_call_split_across_the_boundary_merges_into_one_row`,
`a_message_split_across_the_boundary_is_rejoined_with_the_newer_halfs_identity`,
and `adopt_run_never_renames_into_a_question_row` cover the other three
edges the Kotlin doc calls out. `join_pages` ends in a `debug_assert!`
that no tool id survives in both halves -- the duplicate row it exists to
prevent, checked rather than assumed. What it deliberately does *not*
assert is seq ordering across the boundary: a peer note carries the seq
its turn began at (`place_peer_note`), which can be older than the page
it arrived in, so the two pages' seqs legitimately interleave there. An
earlier draft asserted it and would have panicked in debug builds on an
ordinary transcript.
## Markdown scope
**Known gap, and a decision for whoever closes it:** `event_model::Event`
has no `Unknown`/catch-all variant, unlike `Events.kt`'s hand-kept mirror.
A server newer than this build that adds an event type will fail to parse
that line rather than degrading to a placeholder row. Closing this means
deciding how `event_model` itself represents "a shape I don't recognise"
-- a shared-model decision affecting `server/` too, not a `client`-only
fix, so it is recorded here rather than silently worked around.
`markdown_blocks` splits top-level headings, paragraphs, fences, lists,
tables, and quotes. It intentionally does not build a full nested CommonMark
AST; inline styling and nested presentation remain renderer concerns until a
shared non-UI consumer needs them.
## `config.rs`: `EnrolledServer`
## Verification
`EnrolledServer` (host, port, bearer token) plus `parse_link`, which reads
the exact `aiapp://enroll?host=H&port=P&token=T` deep link
`wg-app-link`'s `enroll` mints and `ServerConfig.kt`'s `handleEnrollment`
parses on the phone -- so any Rust client enrols from the same text a
phone would scan as a QR, with no second format invented for it (RUST.md's
E4, decided 2026-09-05). Deliberately does not decide where it is
persisted or under what file permissions -- a phone seals its token in the
Android Keystore, `src/desktop/config.rs` writes it to
`$XDG_CONFIG_HOME/ai-app-desktop/enrollment.json` at 0600 -- since that is
caller-specific (the code rules' "ask for the least you need"). Its only
caller today is `src/desktop`; the Android entry point is a second one, not
a reason to move the type.
## What `transcript_source.rs` covers, and what it does not
`TranscriptSource<T: Transport>` is the seam a session screen asks for a
page, ported test-for-test against the Kotlin doc rather than a JVM test
file (there wasn't one): `cached_opening`, `probe`, `fetch_opening`,
`page` and `follow`, each matching its Kotlin namesake's contract --
including `probe`'s three-way outcome (matches / cache purged /
unreachable, told apart so a caller never treats "couldn't ask" as "was
wrong") and `page`'s cache-vs-server split bounded by `covered_up_to`.
Two additions beyond a literal port, both load-bearing:
- **`page(before, ..)` refuses `before == 0` before touching the cache or
the network**, answering `OlderPage::NothingLoaded`. This is AGENTS.md's
`loadOlderPage` incident (`before = 0` is "no event before the first
one," indistinguishable from "reached the start of history" if a caller
ever asks it) moved out of the Kotlin screen and into this layer, so
every future caller gets the guard rather than having to remember it.
**The return type is `OlderPage`, not a `Vec`, and that is the guard.**
The Kotlin's two falses are different answers -- `oldestSeq == 0`
returns without touching `moreHistory`, an empty page latches it false
-- so a port that answered both with an empty list would have moved the
bug rather than fixed it, one layer down and out of sight of the screen
that used to hold the check. `OlderPage::Events(vec![])` means the start
of the conversation; `OlderPage::NothingLoaded` is not an answer about
the conversation at all. Reviewed 2026-09-06.
`paging_before_the_first_event_makes_no_request_at_all` asserts zero
transport calls, not just the variant, since a request that happens to
answer empty is exactly what caused the original bug, and
`a_failing_server_page_is_an_error_rather_than_an_empty_one` plus
`an_unreadable_cached_page_is_a_parse_error_rather_than_an_empty_one`
are the same rule for the two ways a page can fail.
- **`fetch_transcript_lines`** (new in `api.rs`) hands back each line
paired with the exact server bytes it came from, via
`serde_json::value::RawValue` rather than re-serializing a parsed
`Value` -- the cache and a live SSE frame for the same event have to
agree byte-for-byte, which is exactly what the `serde_json`
float-rounding bug (AGENTS.md) was about. The existing
`fetch_transcript_page` is untouched (other callers under `iris/`
depend on its signature); the two share a `transcript_path` helper so
the query string is written in one place.
**Not ported:** `EventStream.kt`'s reconnect-with-backoff loop, and
`TranscriptSource.close`'s ability to cancel a live stream from another
thread. Both are wall-clock/thread-lifetime policy that belongs to
whichever runtime embeds this crate (iris's own timers, a Tokio task, a
Kotlin coroutine scope), not to this pure logic -- `follow` is the same
"write to the cache, then hand the frame to the caller" decorator
`src/desktop/app.rs` and `src/android/transcript_client.rs`
already hand-wrote around `event_stream::follow_session_events` before this
existed; the cache write moved into one shared place so a third caller
does not repeat it again by hand.
## What is not started at all
- **A full markdown AST.** `markdown_blocks` (2026-09-06) splits a message
into its *top-level* blocks -- heading, paragraph, fence, list, table,
quote -- with each block's own source, which is what a renderer needs to
lay out prose versus code and what lets a streamed delta re-lay out one
block instead of the message (docs/RUST.md's Task B). What it
deliberately does **not** build is the tree below that: nested list
items, table cells, inline spans. Inline styling is still the renderer's
own job per block (`src/ui/markdown.rs`), and nothing
has needed the rest yet. `CodeFence.kt`'s use of `org.intellij.markdown`
for a full CommonMark AST is Compose rendering plumbing, not something
to port as-is.
- **`TranscriptUnits.kt`** (see above) -- deliberately out of scope, since
it flattens a row into bounded units for a *specific* lazy-list
framework's composition cost, which is a fact about that framework
rather than about the transcript.
## Verifying
`./scripts/run-tests.sh` from the repo root runs `event-model`, `server` and
`app-rust` in that order (each `cargo test`, forwarding arguments the same
way it always has). From `app-rust/` directly: `cargo test`, `cargo clippy
--all-targets`, `cargo fmt` -- all clean as of 2026-09-08, 229 tests across
the crate and its headless harness suites.
Run `./scripts/run-tests.sh` from the repository root. For this crate alone,
run `cargo test`, `cargo clippy --all-targets`, and `cargo fmt --check` from
`app-rust/`.
+10 -28
View File
@@ -1,14 +1,6 @@
# iris: known problems and things still to build
Iris's own list for the library, recorded 2026-09-04 in her words where it
matters, so the work in RUST.md picks these up in a sensible order rather
than rediscovering them. Each item says where it sits in the order and
what "done" looks like.
**Only open items live here.** An item is deleted when it lands, not
ticked: a list of finished work is context every future session pays for,
and what a change did belongs at the code it changed. Fifty closed items
and six phone-report sections went on 2026-09-08 for that reason.
Only open Iris framework work lives here. Delete an item when it lands.
## Fix
@@ -20,12 +12,8 @@ and six phone-report sections went on 2026-09-08 for that reason.
(17,17,27) became (73,73,91) in a desktop screenshot measured on
2026-09-06.
The earlier entry called this desktop-only because the Android picture
looked right. That was not a measurement: neither the startup line nor
the diagnostics report records the selected surface format, and the
Android backend contains the identical preference and shader path. A
device exposing only a non-sRGB surface can happen to hide the bug; it
does not make the pipeline correct.
Neither diagnostics nor startup logging records Android's selected surface
format. A device exposing only a non-sRGB surface can hide the shared bug.
Done means defining one convention for palette bytes, decoded images,
colour emoji and the clear colour, then converting exactly once for the
@@ -33,12 +21,10 @@ and six phone-report sections went on 2026-09-08 for that reason.
test that draws known non-black, non-white pixels into an sRGB target and
reads the stored bytes back; screenshots from desktop and Android then
confirm the same Catppuccin values rather than serving as the definition.
## Build (for the port)
Widgets `RUST.md`'s "The port, in order (decided 2026-09-05)" needs and
iris does not have yet, one entry per gap, named against the P-step that
first needs it. Move an entry up to "Fix" if it becomes a current defect;
delete it once built rather than duplicating it there.
Framework capabilities needed by `RUST.md`'s port plan:
- [ ] **Selectable, read-only text.** P0's report and P1's transcript rows
use `TextEdit` because `Selectable` is implemented only for it. That
@@ -88,18 +74,14 @@ delete it once built rather than duplicating it there.
`Widget::tick` and `UiData::animate`. A widget that does not opt in must
pay nothing and import nothing for them.
- [ ] **Remove `WidgetView` unless a real composite adopts it first.** The
layout change this decision was waiting for has landed. Every composite
in `app-rust/src/ui` now uses ordinary child handles plus a returned root;
- [ ] **Remove `WidgetView` unless a real composite adopts it.** Every
composite in `app-rust/src/ui` uses ordinary child handles plus a root;
`WidgetView` and its derive are used only by `iris/examples/view.rs`.
Today it demonstrates itself rather than shortening production code, so
deletion is the concrete default—not another parallel composition style.
It currently demonstrates itself rather than shortening production code.
- [ ] **A `Stack` that chooses its mask the way it chooses its size
(Iris, 2026-09-08).** She asked whether `masked_by` deserves to exist:
"a method that just does 2 separate things you can already easily do
does not deserve to exist." For a square-cornered surface it is indeed
redundant -- `.background(rect(BAR_FILL)).masked()` was measured
should replace `masked_by`.** For a square-cornered surface,
`.background(rect(BAR_FILL)).masked()` was measured
against `.masked_by(rect(BAR_FILL))` on the composer at the phone's own
size and density and the two are identical to the pixel. What the pair
cannot express is a clip that is not a box: `Painter::set_mask` writes
+50 -447
View File
@@ -1,27 +1,8 @@
# iris: one `draw` that records a size
Iris, 2026-09-04:
> I don't like that widgets need both a draw and size functions. I'd much
> rather them have a single draw that reports a size, and if it needs to be
> moved then that can be done after the fact efficiently, or resized just
> done after as well. This should be done efficiently like everything else
> tries to do right now.
**Implemented 2026-09-04; size dependencies made explicit 2026-09-09.**
Every widget was migrated in one change; none kept
`desired_width`/`desired_height`. `draw` no longer returns its size directly:
it records it once on its `Painter`, and a parent that reads a child draw's
`DrawResult::size()` records the retained dependency between them. What is
kept below is the design as it stands, the corrections implementation forced
(read those before
touching `Aligned`, `Sized`, `MaxSize`, `Scroll` or the move-slot lifecycle
in `render_state.rs` -- each is a real bug the first draft would have
reproduced), and the two later additions that build on it. The
pre-implementation framing -- what the old trait looked like, the checklist
the design had to answer, the migration list, the pass conditions and the
"copy this into the design log" note -- was deleted on 2026-09-08,
having been carried out.
A widget draws once and records its size on the `Painter`. Reading a child
`DrawResult::size()` records a retained size dependency; drawing the child
without reading that result does not make the parent's size depend on it.
## Design
@@ -63,187 +44,36 @@ set in the context, which makes this slow and not cool." Folding sizing into
an axis the widget declares without painter context or child access. A
lying hint fails a debug assertion when the widget is drawn.
### 2. Move: O(1) per moved subtree, via a per-widget offset chain
### 2. O(1) subtree movement
**What exists today, and why it is not O(1).** `UiRenderState::mov`
(`core/src/ui/render_state.rs:156-168`) fires when a widget's region keeps
its *size* but changes *position* (`draw_inner`, `:85-100`:
`active.region.size() == region.size()` after excluding the exact-match
case). It rewrites every primitive's `region` field via
`Primitives::region_mut` (`core/src/render/primitive.rs:176-179`) for the
widget's own primitives, then recurses into every child — O(primitives in
the subtree). Both call sites that trigger it today, `Scroll::draw`
(`iris/src/widget/position/scroll.rs:29-31`) and `Offset::draw`
(`iris/src/widget/position/offset.rs:9-11`), are "translate this subtree by
an abs pixel amount, `rel` framing unchanged" — a transcript scroll
re-touches every glyph in every visible row, every frame of the drag, and
I3's target is 800 rows on screen.
Every active widget owns a slot in `UiData::move_offsets`. A slot stores an
absolute-pixel delta and its parent slot; each primitive instance stores the
slot of the widget that drew it. The vertex shader walks this bounded chain
and adds the accumulated translation. Moving a subtree therefore writes one
slot instead of rewriting every descendant primitive.
**Recommendation: a per-widget offset slot forming a parent-linked chain,
resolved in the vertex shader.**
The parent chain is required for independently movable nested subtrees, such
as a swipeable row inside a scrolling list. A flat offset table would require
rewriting the row whenever an ancestor moved and would restore the very
O(subtree) work this design removes. Chain depth is bounded in both Rust and
WGSL.
- `UiData` (`core/src/ui/mod.rs:14-20`) gains
`pub move_offsets: TrackedArena<MoveOffset, u32>`, the same arena shape
already used for `masks: TrackedArena<Mask, u32>` on the line above it.
- `render/data.rs` gains `pub struct MoveOffset { pub delta: [f32; 2], pub
parent: u32 }` (`Pod`/`Zeroable`, `parent = u32::MAX` = "no ancestor,
add nothing more"). A pure abs-pixel translation, not a general
`UiRegion` remap — sufficient for every existing call site (above).
- `PrimitiveInstance` (`render/data.rs:11-18`) gains `pub move_idx: u32`,
a vertex attribute at `@location(7)` beside `mask_idx` at `6` — the same
kind of per-instance handle.
- `ActiveData` (`core/src/ui/active.rs`) gains `pub move_slot: MoveIdx`,
assigned **when the widget is first drawn** (`draw_inner`, beside
`active.insert`), with `parent` = the drawing widget's parent's slot.
`Painter` threads a `move_slot` field down exactly as it already threads
`mask` and `layer` (`painter.rs:9-20`), so a freshly-drawn descendant is
correct from its first frame — nothing is ever retrofitted onto an
already-active primitive. An unmoved widget's slot just stays `[0, 0]`.
- `Painter::primitive_at` (`painter.rs:23-38`) writes `move_idx:
self.move_slot`, matching how it already writes `mask_idx: self.mask`.
- `mov(id, delta)` becomes: look up `id`'s slot, write
`move_offsets[slot].delta += delta`. One write — no primitive touched, no
recursion, since descendants already reference this slot transitively.
- A container may also retain one optional **child-coordinate slot** between
its own slot and every direct child's slot. `Painter::set_child_offset`
creates that boundary before the first child is drawn and can update it
after measuring a child on later redraws. The container's own primitives,
hit region and mask stay fixed; its whole child subtree moves through one
write and every existing GPU, hit-test and accessibility chain sees the
same result. `LazySpan` uses this while still walking visible rows for
virtualisation: row boxes stay in stable local coordinates and the shared
boundary carries the changing screen translation.
- `shader.wgsl`'s vertex stage, after computing `top_left`/`bot_right` in
pixels (after `:106`, before the clip-space divide at `:113`), walks
`move_idx → move_offsets[i].parent` for a bounded number of steps (a
small constant, e.g. 16, with a CPU-side debug assertion that no chain
exceeds it), summing `delta` into both corners. Cost is O(chain depth),
paid every frame regardless of whether anything moved — negligible next
to the per-fragment texture sampling TEXTURES.md already measures this
GPU as not bound by.
`Painter::set_child_offset` inserts a retained coordinate slot between a
container and its direct children. `LazySpan` uses one so visible row boxes
remain stable while scrolling changes a single shared translation. Ordinary
window-relative positions remain `rel + abs`; move slots carry translation
only, not general remapping.
**Why the chain, not the flatter thing first proposed.** Iris's own
phrasing — "every instance carries an index into a small per-widget offset
buffer" — describes a flat table: one slot per subtree *declared* movable,
no parent link. It breaks the moment two such subtrees nest — a row inside
a scrolling list, itself later given its own animated offset (a
swipe-to-delete mid-scroll) — because the row's primitives would have to
pick one slot and lose the other's contribution. The chain costs one extra
field and a bounded shader loop in exchange for no such gap, and since
every `ActiveData` gets a slot unconditionally rather than lazily, it costs
no more at the common depth of one than the flat version would.
`UiRenderState::resolved_region` performs the same chain walk on the CPU for
hit-testing, accessibility, and public window-coordinate queries. Masks store
the move slot of their owning widget and resolve it independently in the
fragment shader, so a stationary viewport can clip moving content.
**Against `region_mut` as the steady-state mechanism**: rejected for being
O(primitives in the subtree) — the cost this section removes — but kept
for a resize that changes a region's `rel` component (a genuine reflow,
§3) and for a size-independent widget's resize (§3), where the content's
shape doesn't change and one field write already suffices.
Provisional layout can still write an instance at an intermediate position
and restore it before upload. `Primitives::set_instance` remembers the value
at the first write in a frame and clears the dirty bit when the final bytes
match it. The GPU therefore observes final layout state, not CPU-only
measurement work.
### 2b. Two more readers of "where is this widget," and masks
Moving the offset into the vertex shader means `ActiveData.region` is no
longer the on-screen truth once a widget has been moved — it is where the
widget was *drawn*, before any `move_offsets` delta. Two things read it as
if it still were, and both must move to a resolved query or they silently
answer with the pre-move position: a click landing on a scrolled row would
be routed to whatever used to be there, with nothing on screen to say so —
exactly the "wrong answer that looks like a right one" case the code rules
single out.
**Hit-testing.** `SensorUi::run_sensors` (`src/default/sense.rs:154-200`)
does the actual pointer routing, and line 170 is the read in question:
`let shape = self.active.get(id).unwrap().region;` (`self: &UiRenderState`),
immediately turned into pixels and tested against the cursor at `:171-172`.
Under this design that region must be resolved through the same chain the
GPU walks before it means anything. Add to `UiRenderState`:
```rust
/// `active[id].region`, corrected by every `move_offsets` delta between
/// `id` and the root — the CPU-side twin of the vertex shader's chain
/// walk, over the same arena, so the two cannot disagree about where a
/// widget is. O(chain depth), not O(primitives): a plain Rust loop over
/// `move_offsets`, bounded by the same constant the shader loop uses
/// (name it once, e.g. `render::MOVE_CHAIN_LIMIT`, and reference it from
/// the WGSL loop bound in a comment, since WGSL cannot `include!` a Rust
/// const across the language boundary).
pub fn resolved_region(&self, id: WidgetId) -> UiRegion;
```
`window_region` (`core/src/ui/render_state.rs:264-267`), the public
coordinate query already used outside hit-testing
(`src/default/attr.rs:15,17,70`, e.g. positioning one widget relative to
another's on-screen box), is reimplemented to call `resolved_region(id)`
before `.to_px(...)` instead of reading `.region` directly — one change
covers both call sites listed there. `sense.rs:170` changes to
`let shape = self.resolved_region(*id);`. Both are required the moment §2
lands, not an optional follow-up: an unmoved widget's chain is empty and
`resolved_region` costs one arena read to find that out, so there is no
version of this design where skipping the fix is a legitimate
optimization — it is a correctness gap, not a performance one.
**Masks.** `Painter::set_mask` (`core/src/ui/painter.rs:49-52`) bakes the
painter's *current* region into a `Mask` pushed onto
`masks: TrackedArena<Mask, u32>` (`core/src/ui/mod.rs:19`), and the
fragment shader clips every primitive against `masks[in.mask_idx]`'s raw
`rel`/`abs` fields, unaffected by any move (`shader.wgsl:147-157`). If the
widget that called `set_mask` — `Masked::draw`,
`iris/src/widget/mask.rs:7-11`, `painter.set_mask(painter.region()); ...` —
is itself later moved, its clip rectangle stays where it was drawn while
its content moves out from under it: a visibly wrong clip, immediately on
screen, not a latency question.
Fix: `Mask` (`core/src/render/data.rs:46-49`) gains `pub move_idx: u32`,
written from `Painter::set_mask` as `self.move_slot` — the identical slot
the mask-owning widget's own primitives already get (§2), not a second
mechanism. Resolution happens in the **fragment** shader, not the CPU, and
not the vertex shader either: `shader.wgsl`'s mask check (`:147-157`)
currently computes the mask's `top_left`/`bot_right` inline from
`masks[in.mask_idx]`; that computation is extended to walk the same
move-offset chain §2 added, via one shared function —
```wgsl
fn resolve_move(idx: u32) -> vec2<f32> { /* the bounded parent walk, used by both stages */ }
```
— called from `vs_main` for a primitive's own corners and from `fs_main`
for its mask's corners, so the walk is written once and the two stages
cannot drift apart (the sibling-rule from the code rules: one loop, not a
hand-copied second one in the other shader stage).
**Why the fragment shader, not a CPU-side mask rewrite at move time.** A
primitive's mask is frequently owned by a *different* widget than the
primitive itself — often several levels up a subtree, with its own,
independent move slot — so a primitive's resolved offset and its mask's
resolved offset are two different chain sums, both needed, and only the
fragment shader has both `in.move_idx` (this fragment's own chain) and
`in.mask_idx` (indirecting to a second, possibly unrelated chain) already
in hand per-fragment. Resolving mask regions on the CPU at move time would
mean, for every `mov()` call, walking forward to every mask instance the
moved widget's slot could affect and rewriting its raw region — exactly
the O(subtree) cost §2 exists to remove, just moved from primitives to
masks. The fragment shader already re-reads `masks[in.mask_idx]` every
frame (`:148`); one more arena read to resolve its chain costs nothing
extra in kind.
**The scroll-container case, checked rather than assumed.** A masked,
scrollable region is built as a `Masked` wrapping a `Scroll`
(`iris/src/widget/position/scroll.rs`, `iris/src/widget/mask.rs`) — the
viewport border is drawn (and `set_mask` called) by `Masked`, which is
never itself the target of `mov()`; only `Scroll`'s inner content is,
every frame the user drags. Because each widget's move slot is its own
(§2: assigned per `ActiveData`, not shared), `Masked`'s mask references
its own, stationary slot, while the scrolled content underneath references
a separate, deeper slot whose `parent` chain passes through — but does not
write to — the viewport's slot. Moving the content therefore never touches
the mask's resolved position, and the mask staying still while its content
slides past it is what this design already produces with no special case,
not an extra rule that had to be added for it.
Slots follow `ActiveData`'s lifecycle. Removing a widget recursively retires
its slot only after descendants are gone, and a reused arena slot is reset
before new primitives can reference it. `Primitives::set_instance` also
cancels a dirty mark when provisional layout restores the original bytes, so
CPU-only measurement positions are never uploaded.
### 3. Resize scope
@@ -366,91 +196,7 @@ widget observed during that same draw; the next draw replaces the list, so a
dependency disappears as soon as the widget stops reading it. Both fields
have `ActiveData`'s existing lifecycle through `remove`/`remove_rec`.
### 6. Before / after
**A leaf, `iris/src/widget/rect.rs`** — the size-independent case:
```rust
// before
impl Widget for Rect {
fn draw(&mut self, painter: &mut Painter) {
painter.primitive(RectPrimitive { color: self.color, radius: self.radius,
thickness: self.thickness, inner_radius: self.inner_radius });
}
fn desired_width(&mut self, _: &mut SizeCtx) -> Len { Len::rest(1) }
fn desired_height(&mut self, _: &mut SizeCtx) -> Len { Len::rest(1) }
}
```
```rust
// after
impl Widget for Rect {
fn draw(&mut self, painter: &mut Painter) {
painter.primitive(RectPrimitive { color: self.color, radius: self.radius,
thickness: self.thickness, inner_radius: self.inner_radius });
painter.set_size(Size::REST); // fills whatever it was given
}
fn is_size_independent(&self) -> bool { true } // content never depends on region size
}
```
**A container that needs the child's size before placing it,
`iris/src/widget/position/align.rs`**:
```rust
// before
impl Widget for Aligned {
fn draw(&mut self, painter: &mut Painter) {
let region = match self.align.tuple() {
(Some(x), Some(y)) => painter.size(&self.inner).to_uivec2().align(RegionAlign { x, y }),
(Some(x), None) => { let x = painter.size_ctx().width(&self.inner).apply_rest().align(x);
UiRegion::new(x, UiSpan::FULL) }
(None, Some(y)) => { let y = painter.size_ctx().height(&self.inner).apply_rest().align(y);
UiRegion::new(UiSpan::FULL, y) }
(None, None) => UiRegion::FULL,
};
painter.widget_within(&self.inner, region);
}
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { ctx.width(&self.inner) }
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { ctx.height(&self.inner) }
}
```
```rust
// after
impl Widget for Aligned {
fn draw(&mut self, painter: &mut Painter) {
let full = painter.region();
// Draw once at the full region to learn the child's real size --
// this placement is provisional and corrected below without a
// second draw.
let used = painter.widget_within(&self.inner, full).size();
let region = match self.align.tuple() {
(Some(x), Some(y)) => used.to_uivec2().align(RegionAlign { x, y }).within(&full),
(Some(x), None) => used.x.apply_rest().align(x).within(&full),
(None, Some(y)) => used.y.apply_rest().align(y).within(&full),
(None, None) => full,
};
painter.place(&self.inner, region);
painter.set_size(used);
}
}
```
`Painter::widget_within`/`widget`/`widget_at` (`painter.rs:55-76`) change
return type from `()` to `DrawResult`. Calling `.size()` reads the size the
child recorded on its painter and records the parent's dependency on that
answer; leaving it unread records no dependency.
`Painter::place` moves an already-drawn child when its used area fits the
target box, and redraws it when the target changes its size. `SizeCtx` and
`Painter::size_ctx`/`size`/`len_axis` (`painter.rs:141-150,
180-182`) are deleted — nothing calls `desired_len` any more, so there is
nothing left for `SizeCtx` to answer; `draw_text`/`label`/`px_size`/
`output_size` already exist redundantly on both `SizeCtx` and `Painter`
today (compare `size.rs:71-90` against `painter.rs:152-174`) and this
deletes the `SizeCtx` copies, keeping the `Painter` ones.
### 7. Rejected, and why
### 6. Rejected alternatives
- **A flat (non-chained) per-subtree offset table**, Iris's literal
phrasing — rejected in §2 for breaking under nested independent moves
@@ -477,7 +223,7 @@ deletes the `SizeCtx` copies, keeping the `Painter` ones.
but still not O(1), and the shader-side chain costs nothing extra to get
the better bound.
## Density: `Len::dp`, resolved at `apply_rest` time (2026-09-06)
## Density: `Len::dp`, resolved at `apply_rest` time
Iris asked for a third length kind beside `abs` (physical pixels) and
`rel`/`rest` (a fraction of the parent) — IRIS_TODO.md's "density-
@@ -529,171 +275,28 @@ resolution-independent, a fraction of the parent). `Span::gap` and
on them the same as any other size; a bare number is still `abs`,
physical pixels, unchanged.
## Masks with a shape (decided 2026-09-07, built 2026-09-08)
## Masks
Iris, on the code block's scrolling: "the code block scrolling currently
masks in an inner rectangle. Ideally masks should have a shape
associated with them, rounded rectangle being one of them, and/or
another widget you can select, so that the mask becomes the parent
container with rounded edges. Make sure alpha works properly with it,
eg. on the corners where alpha should be decreased / multiplied."
A `Mask` references a rectangle primitive and its parent mask. Nested masks
multiply coverage. Plain `.masked()` creates an undrawn rectangle at the
widget's region; `.masked_by(shape)` draws the shape behind the content and
clips to its first primitive. Keeping the shape in one primitive prevents a
rounded background and its clip from drifting apart.
**What exists.** `Mask` in `shader.wgsl`/`data.rs` is two `UiSpan`s and
a `move_idx`; `fs_main` resolves it and does `color *= 0.0` outside the
rectangle -- a hard cut on a pixel boundary. `Masked` (`widget/mask.rs`)
sets the painter's mask to its own region. Separately, `draw_rounded_rect`
already produces an anti-aliased rounded edge from
`distance_from_rect(pos, center, corner, radius)` with a half-pixel
`smoothstep`, and the border variant multiplies a second coverage in.
Masks are rect-only. Glyph masks would require a CPU-readable alpha plane for
hit-test agreement, and standalone image masks require a bind-group switch the
fragment stage cannot make. Rendering and hit-testing both traverse the full
mask chain and use the same rounded-rectangle coverage; `iris/tests/mask_sdf.rs`
checks the WGSL implementation against the CPU SDF.
**Design** (revised the same day on Iris's two corrections: hit-testing
applies the shape too, and a mask should reference a primitive rather
than carry a copy of its shape).
## Offered boxes
1. **A mask is a reference to a primitive already drawn, plus how to
use it.** `Mask { kind, idx, flags, parent }`: the primitive's
binding (`RECT`, `TEXTURE`, `GLYPH`) and slot, flags (today one:
*alpha only* -- take the primitive's coverage and ignore its colour,
which is the default and the only mode until a need for another
appears), and the enclosing mask's slot for nesting. The fragment
stage evaluates the referenced primitive *at the masked pixel* --
for a `Rect`, the same `draw_rounded_rect` coverage from the same
SDF; for a texture or glyph, the sampled alpha -- and does
`color.a *= coverage`. Nothing about the shape is copied: a rounded
container's corner and its children's clipped corner are the same
primitive's arithmetic, and a texture mask (an alpha image as the
clip) works with no new shader path.
What this needs from the data layout: evaluating a primitive at an
arbitrary pixel means its placement (its spans and `move_idx`, today
vertex attributes) has to be readable from a storage buffer in the
fragment stage. If it is not already there, put it there once, for
every primitive, rather than keeping a second copy for masks -- the
vertex stage can read the same buffer. Textures: the shader binds one
image at a time (see `masks_layout`'s comment on why an image's own
bind group must not name the masks buffer), so a texture mask is
limited to what the fragment can sample without a bind-group switch:
the atlas, and the primitive's own bound image when the masked
primitive is drawn in the same image's batch. Say so at the flag.
2. **Nested masks chain and multiply, like moves.** `parent` walks up
the chain, bounded like `resolve_move` (`MOVE_CHAIN_LIMIT`'s sibling;
debug-assert on overflow and print the chain); coverages multiply,
so a pixel inside two feathered corners is dimmed by both, which is
what a compositor does and what "alpha should be multiplied" asks.
3. **`.masked()` points the mask at the current widget's own
primitives.** `Masked` stops describing a region: it records which
primitive(s) the wrapping widget drew this frame (the painter knows
-- it just allocated the slots) and sets the mask to reference them.
So a rounded `Rect` widget's `.masked()` clips its children to
itself by pointing at the rect it already draws; an image widget's
`.masked()` clips to its alpha. No radius or shape argument exists to
fall out of sync. When a widget draws more than one primitive (a
bordered rect is one primitive; a card with a stripe is two), the
mask references the *first* and the doc says so; a widget that wants
another names it.
4. **Hit-testing applies the shape.** A press is inside a masked
subtree only if the mask's coverage at that point is above one half.
For a `Rect` that is the same rounded-rect SDF evaluated on the CPU
-- one function in the shared crate, with the WGSL a transliteration
of it and a test that compares the two at a grid of points
(`headless` renders to a buffer and reads back, or the Rust version
is checked against the values the shader produced once and recorded).
For a texture, the CPU needs the alpha: keep the alpha channel of an
image used as a mask readable on the CPU (it was uploaded from CPU
memory; keeping the alpha plane is a quarter of the image), and read
it at the point. A masked corner that cannot be tapped and a masked
corner that is not drawn are then the same corner.
`Pad` must work in every container: it offers an inset region to its child and
reports the child's used size plus padding. In a generous parent it behaves as
an inset; in a tight parent it grows the result outward.
**Rejected.** A stencil buffer (a second pass per mask level and no
anti-aliasing); the scissor rectangle (rectangles only, no alpha);
rendering a masked subtree to an offscreen texture and compositing
(a texture allocation per mask, every frame it scrolls, on the phone).
**Pass conditions.** A headless test draws a rounded container with a
masked child that overhangs all four sides and asserts the child's
coverage at a corner pixel equals the container's own coverage there
(same primitive evaluated, so exactly equal, not approximately); a
nested-mask test asserts the product at a pixel inside both feathers; a
texture-mask test clips a rect to an alpha image and asserts a
transparent texel masks fully; a hit-test asserts a press in a
container's clipped corner misses and one just inside the curve hits,
and that the CPU SDF and the shader agree at a grid of points; a
`run-headless.sh --phone` screenshot of a scrolled code block shows
rounded corners with no square pixels poking out at the top and bottom
of the scrolled content. Record the commands in RUST.md when it lands.
### What was built (2026-09-08), and where it differs
The commands and the screenshot are in docs/RUST.md's queue entry. Four
places the code is narrower than the design above, each deliberate:
- **No `kind` and no `flags` on `Mask`.** It is `{ primitive, parent }`.
The referenced instance already carries its own `binding`, so a copy
of it in the mask is a second thing to keep in step; *alpha only* is
the only mode there is, so there is nothing to select. Both are a
field away if a second mode appears.
- **A mask's shape must be a rect.** `Painter::set_mask_to` asserts it,
by name, rather than leaving the shader to read a `rects` entry that
is not there. A glyph would need a CPU-side alpha plane before the
hit test could agree with the shader, and a standalone image needs a
bind-group switch the fragment stage cannot make (`masks_layout`'s own
comment on why an image's bind group must not name the masks buffer).
So **the texture-mask pass condition is not met and no texture mask
exists** — the point of the reference design is that adding one is a
binding check and a sampled alpha, with no new shader path, and the
shader's `mask_coverage` already has the branch where it would go.
- **The shape is a primitive of its own, not always a drawn one.** A
plain `.masked()` writes an undrawn `RectPrimitive` at its region
(`Drawn::No`, `NOT_DRAWN`) and points the mask at that, so "clip to my
box" and "clip to that widget's rounded background" are one mechanism
and square-cornered clipping did not become a special case.
`.masked_by(shape)` draws `shape` behind the content — in its own
layer, the way `Stack` puts a background under its content — and
clips to the first primitive it drew.
- **The CPU/shader agreement is a GPU test**, `iris/tests/mask_sdf.rs`,
the only test in the workspace that needs an adapter. It lifts
`distance_from_rect` and `rounded_rect_coverage` out of
`iris_core::SHAPE_SHADER` *by name* and runs them in a compute pass,
so the thing under test is the shader itself rather than a copy of it
that would be edited alongside.
## What a widget's *offered* box may and may not be (2026-09-08)
Two rules that were each true in one place and missing from a sibling,
found together by Iris's 2026-09-08 phone report.
**Padding works in whatever container it is placed in, and is an inset or
an outset depending on how tight that container's region is.** Iris's
own words, 2026-09-08: "padding should work no matter what container a
widget is placed in, and acts as both inset and outset depending on how
tight the parent region is." `Pad` offers its child the region it was
handed, inset on each side, and reports `used + padding` — so given a
generous box it insets the child inside it, and given a box already the
size of the content it reports a larger size and the parent grows. What
this rules out is any container that offers a padded child a box and then
ignores what it reported, and any caller that reshapes its tree to avoid
a `Pad` (which `transcript-ui/src/tool.rs` did until 2026-09-08, at the
cost of a tool group's 4dp inset).
**A widget offered a box it does not fit is drawn again at the box its
own reported size implies, in the same frame.** Not next frame. The
temptation to defer is real — `LazySpan::place` offers a row its *cached*
height precisely so that an unchanged row hits `draw_inner`'s cheap
skip-or-move path, and `Scroll` sizes its child region from last frame's
content length for the same reason. But a `Rect` fills whatever region it
is given (`Size::REST`, and `rect.rs`'s `is_size_independent` doc says
why it must), and `.background(rect(..))` is the ordinary way to style
anything — so a one-frame-stale box is a background drawn at the wrong
size while the text inside it is already right. On screen that is a tool
card that looks closed while its text is there and open while it is not.
A move alone cannot fix a changed size; `Painter::place` redraws in that
case.
The cost is bounded and worth stating, because it is what makes the rule
safe to apply everywhere: the settling draw happens only on the frame a
widget's own size actually changes, which is a frame that was already
redrawing it. `Sized` also requires its final region before retaining its
children: its own reported size may be known exactly while a descendant was
drawn in the provisional box, so moving only the wrapper is insufficient. A
widget whose reported size is a function of the box it was *offered* would
disagree every frame and redraw every frame — which is why `LazySpan` requires
content-sized rows, and has since long before this.
When a widget does not fit its offered box, it is redrawn at the box implied by
its reported size in the same frame. Deferring would leave ordinary
`.background(rect(..))` surfaces one frame behind their content. The settling
draw occurs only when the widget's own size changes. Widgets whose size varies
with every offered box are therefore unsuitable as `LazySpan` rows.
+47 -334
View File
@@ -1,50 +1,18 @@
# Moving the app to Rust
Working document for the port Iris asked for on 2026-09-04: the phone app
in pure Rust, one UI framework shared with a desktop app, at full feature
parity and giving up nothing native -- performance especially. Her
constraints: no Dioxus and nothing that draws through a WebView; **no UI
DSL** (which ruled out Makepad and Slint); the result stays 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.
**The framework question is closed.** Iris chose her own library,
[iris](https://github.com/cat16/iris), over Masonry on 2026-09-05.
The bake-off that got there, and the twelve experiments
that proved it on a device, are summarised in "What the experiments
settled" below rather than kept at length. What is left in this file is
the plan for the rest of the app and the findings that outlive the tasks
that produced them.
Decisions get a date and a reason here, the way `PLAN.md` does.
Plan for a native Rust phone app with full feature parity and a shared desktop
UI. It uses [iris](https://github.com/cat16/iris); platform-specific entry
points are acceptable, but shared screens, widgets, and styling are not
duplicated. The result must stay lightweight and preserve native behavior and
performance.
## 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.
Keep open work, current design, measured constraints, and dead ends that would
otherwise be repeated. Delete completed plans and migration narratives. Name
the command and measured value when evidence matters.
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.
**And delete a plan once it has been carried out** (Iris, 2026-09-08:
*"remove everything that's already done and decided... many with checkboxes
already ticked off that just fill up context"*). A ticked box has done its
job; a finished experiment is worth one line saying what it settled, not
the log of settling it. Currency means this file says where things *are*,
not how they got here. What survives a prune is what cannot be cheaply
re-derived: measurements, dead ends, invariants and their reasons.
## Where things stand (2026-09-09)
## Current status
- **The framework is decided and built on.** iris draws the transcript
screen on the desktop, on this checkout's emulator and on Iris's phone.
@@ -52,14 +20,12 @@ re-derived: measurements, dead ends, invariants and their reasons.
phone and the reports are under `docs/bench/`.
- **P1 (session screen parity) is the current work**, and is where the
next session should start. Its box below has the state.
- **The repository was reorganised on 2026-09-08**: the port is one crate,
`app-rust/`, and `iris/` is the UI framework alone. See "One app crate"
at the end -- it is the layout everything else here assumes.
- The port is one crate under `app-rust/`; `iris/` is only the UI framework.
- **Open across the rest of the docs**: `docs/IRIS_TODO.md` is iris's own
list (colour-space correctness is the live one), `docs/TODO.md` is the
Compose app's.
## Desktop and phone share the code (Iris, 2026-09-07)
## Desktop and phone share the code
Iris plans to develop a desktop app as well, and asked that most code be
sharable between desktop and phone. The tree already has that
@@ -332,76 +298,9 @@ rather than what it happens to look like:
7. **Measurable frames**: the debug render report, and a way to attribute
a frame's cost to a widget on the real phone.
## What the experiments settled
## Measurements and constraints
Twelve boxes, all closed between 2026-09-04 and 2026-09-05, and all
deleted on 2026-09-08 now that their conclusions live in the code. One
line each for what a later session must not re-derive; where a decision
needs its reasoning, the reasoning is at the thing itself.
**The framework track (E0-E5), against Masonry:**
- **E0 -- toolchain.** NDK r29 (`29.0.14206865`) under `~/Android/Sdk`,
cargo-ndk 4.x. Its API-level flag is `-P`; `-p` now means `--package`.
- **E1 -- android-view's Masonry demo ran here**, on the GPU, with an
accessibility tree and the phone's real keyboard -- but no autocorrect
and no suggestions. The `android-view` rev this was measured against is
pinned in `app-rust/Cargo.toml` with that history at the pin;
`accesskit_android`'s detach-abort is mitigated in
`iris/src/android/view.rs`'s `raise_if_enabled`, and advancing the
version is not the fix.
- **E2 -- a transcript in Masonry** found the framework-wide gap that
blocked the comparison. It lived in `~/src/android-view/e2-transcript`
and was never committed here.
- **E3/E5 -- the Kotlin shell and the packaging xtask.** Both hold:
`app/shellApp` plus the JNI bridge (now `app-rust`'s `shell` feature)
posts a real notification and receives a real share, and `cargo xtask
apk` packages an installable APK with `javac`/`d8`/`aapt2`/`zipalign`/
`apksigner` and one disclosed Gradle call, documented at
`scripts/xtask/src/apk.rs`'s module doc.
- **E4 -- the same screen on the desktop**, which is now
`app-rust`'s `src/desktop` and the `ai-app-desktop` binary.
**The iris track (I0-I5):**
- **I0a -- iris is vendored at `iris/`**, history not carried, consumed by
path, from `iris/iris` on gitea at `7b54aaf`. It goes back to its own
repository once it has proved itself.
- **I0b -- the nightly pin is dated, not rolling** (`rust-toolchain.toml`,
one copy in `iris/` and one in `app-rust/`, because a pin applies per
directory). Dated because a rolling channel moved `impl const Trait` to
`const impl Trait` underneath the vendored tree and broke it unattended.
- **I1 -- parley, plus a glyph atlas.** Both Iris's call. Parley addresses
text by byte offset into one string, which is why the editing model
looks the way it does.
- **I2 -- iris runs on android-view**: the backend, the Gradle shell,
insets, the back gesture and the full `InputConnection` bridge, with
real Gboard suggestions.
- **I3 -- the virtualised list.** Since renamed `LazySpan`, and scrolling
has moved out of it into `ScrollController` -- `docs/SCROLL.md` is the
current design, not this box.
- **I4 -- accessibility names through AccessKit**, one flat tree with a
synthetic `Role::Window` root and every *named* widget a direct child.
Flat deliberately: nothing upstream of a named leaf needs a node. This
is what lets `ui-trace` tap by label.
- **I5 -- the transcript screen in iris**, with `FrameReport` for
frame timing. Its descendants are `app-rust/src/ui` and every
measurement rig in AGENTS.md.
**Two findings from that period that are still load-bearing, kept where
they belong rather than here:** iris's binding array does not survive real
Android hardware (the measurement and the fix are `docs/TEXTURES.md`'s
"Implemented, 2026-09-04"), and the emulator has no hardware Vulkan while
its GLES *is* the host's real GPU through virgl (moved to the
`this-machine-android` skill on 2026-09-08, with the `gpu-probe` output
that established it).
## Findings that outlive the task that produced them
Kept because the number or the constraint is what stops it being
re-derived; the tasks themselves are done and deleted.
### The Android release profile, and where the APK's size went (2026-09-07)
### The Android release profile and APK size
Iris asked why the iris bench APK was double the Compose one (20.6 MB vs
10.1 MB). It was almost all `libmain.so`, built with `panic = "abort"` and
@@ -423,7 +322,7 @@ vectorisation on a renderer. Everything else is
own rather than `release`, so the desktop build is not also optimised for
size.
### The fling stutter, and what a frame report could not say (2026-09-09)
### The fling stutter and what a frame report cannot say
Iris, from her phone: *"I'm noticing some stuttering when flinging in
particular. Harder to notice with my finger directly moving the scroll."*
@@ -515,7 +414,7 @@ The signature of the fixed loop, from that run: `build p50 0.4ms,
acquire p50 5.7ms, submit p50 1.7ms` -- four tenths of a millisecond of
work and the rest of the refresh period spent waiting its turn.
### Streaming is where the frame time is now (2026-09-09)
### Streaming frame time
Measured after the fling was fixed, and it is not where it looks.
`frame_profile.rs`'s stream run: folding an arriving event is 0.35ms and
@@ -530,7 +429,7 @@ every delta.
exact shape of the Compose lesson in AGENTS.md's "Things that have
bitten" -- and measuring it is what ruled it out.
### Incremental text: parley cannot, and it turns out not to matter (2026-09-09)
### Incremental text shaping
Iris asked to investigate incremental text rendering and hoped parley
supported it. **It does not, by design.** The crate's own docs: a
@@ -635,7 +534,7 @@ the reply into blocks was still right -- it is what makes the fixture
representative, and it halved the CPU half -- but it was never going to
move this, and it slightly increases the primitive count.
### The arenas upload deltas, and stopped being 11x too big (2026-09-09)
### Arena delta uploads
Done, and measured by `scripts/rigs/ui-profile`'s `arena_churn` -- see
AGENTS.md's entry for the rig and the numbers. The arithmetic above was
@@ -697,7 +596,7 @@ instance bytes per frame are **1,488**, from 176,496. This is framework
layout/rendering behaviour and the transcript screen contains no special
case for it.
### The Android release profile is `opt-level = 3`, not `"s"` (2026-09-09)
### The Android release profile uses `opt-level = 3`
The table above was measured in bytes only. `"s"` costs the loop
vectorisation and inlining a renderer runs on: over the same warm fling
@@ -708,7 +607,7 @@ refused for `"z"`, one level further up. Iris raised it herself
(*"I'd make sure it's in release mode"*); the build always was, and this
was the part of "release" that was not about speed.
### Platform fonts, not bundled ones (2026-09-07)
### Platform fonts
Iris: *"remove the font for now; just match what compose does."* The
Compose app takes body text from `FontFamily.Default` and code from
@@ -733,26 +632,9 @@ desktop cannot answer it. Before the next phone build, look at a bold run
and at `CLOSED_MARK`/`OPEN_MARK`/`UP_MARK` (U+25B8/BE/B4) on Iris's own
device; the emulator's font set is not evidence for hers.
### Hit-testing does not consult the mask chain (review R2, 2026-09-07)
## The port, in order
Masks are applied in the fragment shader
(`iris/core/src/render/shader.wgsl`); the CPU hit path
(`UiRenderState::resolved_region`) does not look at `masks` at all. So a
straddling row's clipped-away top is invisible and still tappable -- a tap
on "Run benchmark" can land on an invisible link in the row behind it.
Left deliberately: `docs/LAYOUT.md`'s mask redesign ("masks reference a
drawn primitive instead of copying a shape") is where hit-testing gets the
shape, and intersecting a chain in `resolved_region` now would be a second
mechanism to unpick.
## The port, in order (decided 2026-09-05)
The ordered plan for the rest of the app, decided here per Iris's standing
"decide technical questions yourself" instruction -- no serious
user-facing tradeoff is in play in the ordering itself.
**Where the screens live** was settled by the 2026-09-08 reorganisation
("One app crate", below): every screen is a module under
Every screen is a module under
`app-rust/src/ui`, which 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.
@@ -776,55 +658,15 @@ AVD, `ui-trace` by accessibility name, GrapheneOS phone quirks, the
running any pass condition below that touches an emulator or a real
device.
- [x] **P0 -- the phone benchmark gate. Passed.** Asked for 2026-09-05,
delivered and run on Iris's own phone; the reports are under
`docs/bench/`. Both halves are still in the tree and are how a
frame-time comparison is taken: the Compose `bench` build type
(`app/`, `BenchFixture.kt`/`BenchRun.kt`) and the Rust `bench`
feature (`app-rust`, `src/android/bench_client.rs`), opening the
same checked-in synthetic transcript
(`app/bench-fixture/assets/transcript.jsonl`, never a real one) with
no server, driving the same scroll loop and streaming phase, and
printing the same report fields. AGENTS.md's "The rigs" is the
current description; `app-rust/build-apk.sh` and `run-bench.sh` are
how it is run. The build source is this repository, but the Dev Updater
publication is the separate `~/repos/ai-app-bench` repository, whose
`iris-bench` component serves a committed APK with no build step. After
an arm64 release build, replace its
`iris/build/outputs/apk/release/iris-bench-arm64.apk` and push that
repository; adding the component here or pushing only `ai-app-2` is the
wrong delivery path.
**Bench-only cleanup still open**: the diagnostics report pane draws over
transcript rows. `REPORT_MAX_HEIGHT_DP` constrains its claimed height, but the
pane is neither masked nor scrollable despite its construction comment saying
it is both. This is an `app-rust` defect, not an iris framework item.
**Bench-only cleanup still open**: the diagnostics report pane draws
over transcript rows. Reproduced on the emulator on 2026-09-09 by
opening the named `Diagnostics` control. `REPORT_MAX_HEIGHT_DP`
constrains its claimed height, but the pane is neither masked nor
scrollable despite its construction comment saying it is both. This is
an `app-rust` bench-screen defect, not an iris framework item.
- [ ] **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, by what the bench fixture exercises
and Compose already draws** (tick and date each in place):
- [x] **P1a — markdown block rendering parity.** Done 2026-09-06.
Each top-level block is drawn in one of three frames
(`ui::markdown::BlockFrame`) — plain, verbatim, quote — with
fences and tables verbatim, headings scaled, and inline
styling per span. `app-rust/src/ui/markdown.rs` is the code
and its module doc the design.
- [x] **P1b — tool-call cards and grouping.** Done 2026-09-06.
`ToolRows.kt`/`ToolInput.kt` ported to
`app-rust/src/ui/tool.rs`: a run of calls is one collapsible
group, each card carries its state and summary, and the five
`ToolState` values each have their own appearance.
`tool.rs`'s module doc has what was chosen.
- [ ] **P1 — session screen parity.** Continue in this order:
- [ ] **Before the next parity slice — make iris's colour pipeline
correct.** Raised by Iris on 2026-09-09 as something to settle
sooner rather than later. Both backends currently prefer an
sRGB surface while the shader returns palette/image bytes as
correct.** Both backends currently prefer an sRGB surface while
the shader returns palette/image bytes as
linear values; `IRIS_TODO.md` has the measured mismatch and
pass condition. Do this before judging or centralising the
app's styling. It is correctness, not cosmetic polish.
@@ -1038,169 +880,40 @@ device.
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 session
## One app crate
What to do when you pick this up, in order, so nothing here has to be
re-derived. **The work is done inline, not handed to subagents** — Iris
said so on 2026-09-08 ("I'm no longer using subagents for this. Please do
the work yourself"), so read the code, make the change, run the tests and
push, in the session that picked the task up.
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,
decided 2026-09-05. **Fix iris's colour-space pipeline first**, as Iris
requested on 2026-09-09; then take P1c, history paging and
jump-to-latest. The client-side paging pieces it needs are already
ported.
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 `scripts/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.
## 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.
## One app crate, 2026-09-08 (the repository reorganised)
Iris, reading the tree: *"the organization of the rust rewrite is a mess
right now… there shouldn't be anything related to the app inside of iris.
Iris is supposed to be the UI framework alone."* Then, on the crate count:
*"I'm confused why the app only code needs more than one crate though."*
### What it was
Nine cargo workspaces, each with its own `Cargo.lock` and `target/`, and
the port's project code in five places — `iris/transcript-ui`,
`iris/transcript-fixture`, `iris/desktop-app`, `iris/android-app` (all
*inside* the framework), plus `client-core` and `android-shell` at the
root. Two root markdown files sat outside
`docs/`.
### What it is
**One crate, `ai-app`, in `app-rust/`.** Modules, not crates:
| was | is |
|----------------------------------------|-----------------------------------|
| `client-core` | `src/client` |
| `iris/transcript-ui` | `src/ui` |
| `iris/transcript-fixture` | `src/ui/fixture.rs` + `tests/`, `touch/` |
| `iris/desktop-app` | `src/desktop` + `src/bin_desktop.rs` |
| `iris/android-app` | `src/android` + `android-project/` |
| `android-shell` | `src/shell` |
The Rust client is one `ai-app` crate in `app-rust/`: platform-free code
is under `src/client` and `src/ui`, while `src/desktop`, `src/android`, and
`src/shell` contain the platform entry points. The fixture is behind its
own feature so its 1.9 MB `include_str!` does not enter ordinary phone
builds.
`iris/` now holds `core`, `macro`, the `iris` crate, `tabs-ui` and
`rig-input` — framework only, with no mention of a session, a transcript,
a setup or a server anywhere in it.
### Why one crate really is enough
`src/client` must not depend on iris. Features select the crate's face:
`screens` for UI builds, `shell` for the Compose shell bridge, and `bench`
for the fixture. The Android faces both produce `libai_app.so`.
Each split had a stated reason at the time; on inspection only two
survived, and one of those is not in `app-rust` at all.
`event-model` remains separate because both the server and client depend
on that wire contract. Iris remains a separate UI-framework workspace and
must contain no product concepts.
- **`client-core` separate from the UI** was "pure logic with no framework
dependency". That property is worth keeping and does not need a crate:
`iris` is behind the `screens` feature and `src/client/` may not reach
it. An invariant on a module instead of on a manifest, stated in
docs/CLIENT_CORE.md.
- **`transcript-fixture` separate from `transcript-ui`** was so the
headless harness and a desktop window opened the same bytes. Both are
now the same crate, so it is `src/ui/fixture.rs` behind a `fixture`
feature (1.9 MB of `include_str!` must not reach a phone build) with the
six harness suites in `tests/`.
- **Two Android `.so` names**, `libmain.so` for the iris app and
`libandroid_shell.so` for the Kotlin shell's JNI bridge, looked like the
one hard constraint: a package produces exactly one library artifact.
It dissolves because **P2 already plans to merge those two Android apps
into one**. So both faces come out of one package as `libai_app.so`,
picked apart by features (`--no-default-features --features shell` keeps
wgpu, parley and iris out of the Compose app's APK), which is the
direction of travel rather than a workaround. `xtask apk` and
`app/shellApp`'s `System.loadLibrary` were updated to match.
- **A desktop binary and an Android cdylib in one package** is not a
problem: `iris` itself already target-gates winit against android-view
in one manifest, and the same table does it here. `build-apk.sh` passes
`--lib` so `cargo ndk` never tries to build the desktop binary.
- **`event-model` stays a crate**, and is the one split that was never
optional: `server/` depends on it too, so a crate is what makes the
backend and the app agree by construction. Iris chose to leave it at the
repo root rather than inside `app-rust/`, since it is the contract
between the two rather than app code.
### Build constraints
So: three workspaces where there were nine — `event-model`, `server`,
`app-rust` — plus `iris` and `xtask`.
### Things that moved with it, worth knowing
- **The toolchain pin is per directory.** `app-rust/rust-toolchain.toml` is
a copy of `iris/`'s, because `client-core` used to build on stable and
now shares iris's dated nightly. Two consequences appeared immediately:
two `needless_range_loop` warnings in the markdown highlighter (fixed),
and four `AtomicBool::fetch_update` deprecations from inside `jni`
0.22's `native_method!` macro. The last are not ours to migrate — the
fix is a `jni` release — so `src/lib.rs` carries an `#[allow(deprecated)]`
scoped to `mod shell` with that reason written at it.
- **The rolling nightly setting is per directory.** The toolchain files in
`app-rust`, `iris`, and `scripts/rigs/ui-profile` must stay synchronized.
- **The Android release profile is `android-release`, not `release`.** The
aggressive settings `iris/android-app` had (`panic = "abort"`,
`opt-level = "s"`, fat LTO) would otherwise apply to the desktop build
too, which is a testing surface. `build-apk.sh` passes
`--profile android-release` / `--profile android-dev`.
- **`iris/run-headless.sh` grew `--dir DIR`**, defaulting to `iris/`. The
rig belongs to the framework; the examples it usually runs no longer do.
`replay-touch` is still built from `iris/`.
- **The log target changed** from `client_core` to `ai_app`
(`src/client/log_ring.rs`'s `is_own_target`).
- **`iris/run-headless.sh --dir DIR`** selects the workspace containing the
example; it defaults to `iris/`.
- **Not renamed, deliberately:** the Android application id and Java
package are still `dev.iris.android.demo` and the label is still "iris
android-view demo", both now misleading. Changing them changes the app's
identity on Iris's phone (a side-by-side install rather than an upgrade)
and the `DevLogProvider` authority Dev Updater reads, so it is hers to
decide rather than a tidy-up to make quietly.
### Verified
`./scripts/run-tests.sh` (event-model, server, app-rust) and `cd iris && cargo
test` green; `cargo clippy --all-targets` and `cargo fmt` clean in every
workspace. `cargo ndk -t x86_64` links `libai_app.so`; `./build-apk.sh
debug --abi x86_64` produces an installable APK; installed and launched on
this checkout's emulator, drawing through `Gl … virgl` as expected. The
phone-sized headless screenshot (`run-headless.sh phone --phone --dir
../app-rust --shot …`) renders the transcript unchanged.
and the `DevLogProvider` authority Dev Updater reads, so changing them
requires an explicit migration decision.
+19 -81
View File
@@ -1,8 +1,6 @@
# Scrolling in iris
How anything in iris scrolls, as of 2026-09-09. This is the current
design, not a history — the git log has the account of
how it got here, and `docs/IRIS_TODO.md` has what is still open.
This is the current scrolling design; `docs/IRIS_TODO.md` holds open work.
Read this before touching `iris/src/widget/position/scrollable.rs`,
`scroll_area.rs`, `lazy_span.rs`, or anything that pans, flings or lays
@@ -29,13 +27,8 @@ Two widgets have one, and they differ only in how they spend a delta:
`.scrollable()` registers the same two senses against the controller it
already has.
Do not give a widget its own fling, its own scroll amount, or a
`RequestRedraw` handle. And do not add a scrolling method to the `Widget`
trait: the three that used to be there (`scrolls_itself`, `apply_scroll`,
`scroll_offset`) existed only so a `Scroll` could drive a `LazySpan` it
had no business wrapping, and they are gone (Iris, 2026-09-08: "I don't
like adding methods to widget, it seems like we can structure things
better instead").
Do not give a widget its own fling, scroll amount, or `RequestRedraw`
handle, and do not add scrolling methods to the general `Widget` trait.
## One convention for a delta
@@ -45,25 +38,9 @@ positive delta — the finger's direction — and that is `Scroll::scroll`'s
sign, `Scroll::fling`'s, and `Widget::apply_scroll`'s, from the gesture
all the way down to a row's anchor.
**It is a screen direction, not a logical one** (Iris, 2026-09-08:
"positive should always scroll up / left, and negative down / right ...
that way it always works as the user would expect"). The earlier wording
— "positive brings *earlier* content into view" — is true only of a span
laid out forwards: a `Dir::UP` list's earlier content is *below*, so the
same delta panned it the opposite way from every other scrollable in
iris. `LazySpan::flip_delta` is the conversion into the walk's own
direction-relative space, the exact counterpart of `flip_pos` for
positions, and its `scroll` (private) is the only thing that speaks that
space.
There used to be two public conventions under the same name, and every
call site had to know which widget it was talking to. If you add a third
scrolling thing, it takes this one. Two tests pin it, and neither is
redundant: `a_negative_delta_moves_toward_the_end` follows the sign
across the whole handoff, and `a_delta_moves_both_directions_the_same_
way_on_screen` checks the two `dir`s against **where rows were drawn**
an assertion written in the walk's own space passes with the flip
deleted, because it checks the bookkeeping against itself.
It is a screen direction, not a logical content direction. A `Dir::UP`
span's earlier content is below, so `LazySpan::flip_delta` converts public
screen-space deltas into the walk's direction-relative space.
## The contract between a controller and its owner
@@ -98,8 +75,7 @@ on the clock its own velocity was measured on. Frames are presented on an
even cadence whatever clock they are computed on, so sampling the spline
at "whenever the callback got to run" moves the content unevenly between
frames that are shown evenly -- a shimmer that no frame-time percentile
can see, since no frame was late. Found 2026-09-09; docs/RUST.md's
"The fling stutter" has the rest.
can see, since no frame was late. `docs/RUST.md` records the measurements.
### Why a remainder was not enough
@@ -152,8 +128,6 @@ can say how far it may go, so nothing above it is in a position to.
### Why it is not a `Span` inside a `ScrollArea`
Measured 2026-09-08, and worth not re-deriving:
- A `Span` is skipped entirely in the steady state. When redrawn, it uses
exact hints first, draws unknown fixed children forward from the cursor,
and places retained drawings after flexible allocation. A child is
@@ -179,14 +153,9 @@ A transcript is `Dir::DOWN` (oldest message is item 0, at the top) with
`Pin::End` (the view sits at the bottom). Conflating the two would stand
it on its head.
**`Pin` says it either way round**, because there are two questions and
they are not the same one (Iris, 2026-09-08). `Start`/`End` are
content-relative — the first row or the newest one, wherever the layout
puts it — and `Neg`/`Pos` are axis-absolute: the top/left edge and the
bottom/right one, whichever end of the content is there. They coincide for
everything except a reversed `LazySpan`, where they are exact opposites,
which is the whole reason both exist. The one question a scrollable acts
on is `pinned_to_end`, and `dir` is what resolves a `Pin` into it.
`Start`/`End` are content-relative; `Neg`/`Pos` are axis-absolute. They
diverge for a reversed `LazySpan`. A scrollable acts on `pinned_to_end`,
with `dir` resolving the chosen `Pin`.
### Two coordinate spaces, two conversion points
@@ -217,8 +186,8 @@ framework:
old-children diff calls `remove_rec`, and the `ActiveData` — with its
`size` — is freed. The framework's copy is gone for precisely the rows
the walk has to pass through without drawing.
2. **A widget may one day render in two places at once** (Iris,
2026-09-08), so anything keyed by `WidgetId` alone that describes where
2. **A widget may render in two places at once**, so anything keyed by
`WidgetId` alone that describes where
or how big a widget was drawn will be wrong then. Where and how big
belongs to the owner that placed it.
@@ -242,8 +211,8 @@ inside the same frame**. `moved_by` counts that correction along with the
move that caused it, which is why `amt` stays equal to what is on screen
rather than drifting by every overshoot.
Layout is a pure function of the state, not of how many frames have been
drawn (Iris, 2026-09-08). A correction that lands next frame is a frame
Layout is a pure function of state, not of how many frames have been
drawn. A correction that lands next frame is a frame
drawn wrong, and there may be no next frame — a fling that stopped is not
asking for one.
@@ -305,40 +274,9 @@ cannot pan; there is a `debug_assert` in `drag` naming that.
rebased after 65,536 pixels to preserve `f32` precision, a rare O(visible)
move-slot pass rather than steady-state work.
## Tests that pin the behaviour
## Verification
In `lazy_span.rs`, all of these fail if the corresponding piece is undone:
- `a_negative_delta_moves_toward_the_end` — the sign, end to end.
- `a_delta_moves_both_directions_the_same_way_on_screen` — the sign is a
screen direction, checked against where rows were *drawn*.
- `amt_counts_only_what_the_child_could_take` — why the owner reports what
it did rather than the caller adding up what it asked for.
- `a_fling_stops_at_the_first_row`,
`scrolling_past_the_start_lands_on_it_in_the_same_frame` — the walls,
with no settling frame drawn on purpose.
- `a_dir_up_span_grows_upward_from_item_zero`,
`a_reversed_span_hit_tests_in_screen_space` — the position conversions
(`flip_pos`), as `a_delta_moves_both_directions_the_same_way_on_screen`
is the delta one (`flip_delta`).
- `a_registered_fling_is_driven_by_tick_animations_and_then_unregisters`
a fling that nothing registers never moves, whatever its velocity.
In `app-rust/tests/` (layer 1, no window or GPU):
- `top_edge.rs`'s `scrolling_past_the_first_row_settles_on_it` /
`scrolling_past_the_last_row_settles_on_it` — both ends, no settling
frame.
- `phone_screen.rs`'s `a_recorded_flick_releases_with_a_velocity_and_
flings_the_list` — the velocity against
`benches/velocity_reference.py`'s number, and the fling's travel against
`benches/fling_spline_reference.py`'s.
- `phone_screen.rs`'s `a_long_press_and_drag_selects_text` — what caught
two `DragGesture`s fighting over the transcript.
- `catch_a_fling.rs`, `gesture_cancel.rs`, `fence_fling.rs` — press-catches
a coasting area, cancels, and a code fence panning sideways
independently of the transcript.
`docs/RUST.md`'s "Three test layers" says which layer answers what. Test
at the cheapest one that can answer the question; the emulator is for JNI,
the IME, insets and one verification run, not for iterating on layout.
The unit and headless integration tests exercise direction, both walls,
reversed hit-testing, fling registration, cancellation, nested horizontal
pans, and transcript selection. `docs/RUST.md` defines the three test layers;
use the cheapest layer that can observe the behavior under test.
+38 -228
View File
@@ -1,240 +1,50 @@
# How iris renders an unbounded number of images
**Built 2026-09-04**, in `iris/core` and `iris/src/default/render.rs`.
This file is the design and the measurements behind it; the deliberation
that produced it -- the prior-art survey, the proposal and its review --
was deleted on 2026-09-08, having been carried out. What is kept is why
the old approach could not stay (it is the reason the current one looks
as it does), the numbers, and what actually landed.
Iris cannot require Vulkan descriptor indexing. The Android Vulkan Profile
2025, covering 80.1% of active Vulkan-capable Android devices as of October
2025, does not require `VK_EXT_descriptor_indexing` or its bindless texture
features ([Android Vulkan profiles](https://developer.android.com/ndk/guides/graphics/android-vulkan-profile)).
Arm guarantees the extension only on Valhall and fifth-generation GPUs
([Arm Vulkan guidance](https://developer.arm.com/mobile-graphics-and-gaming/vulkan-api-best-practices-on-arm-gpus)).
Iris (the person) asked whether iris's (the library's) approach to "draw
however many images happen to be on screen" -- relevant here because a
transcript can hold an unbounded number of attached screenshots -- works
on mobile, her recollection being that it does not. It did not, and this
is what replaced it.
The emulator also rejects wgpu requests for `TEXTURE_BINDING_ARRAY`,
`SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING`, and
`PARTIALLY_BOUND_BINDING_ARRAY`. Those features therefore must not enter
Iris's required device feature set.
## The problem
## Current design
Every texture iris ever creates — every `Image` widget
(`iris/src/widget/image.rs`) and every glyph atlas page — gets a permanent
slot in one array via `Textures::add` (`iris/core/src/primitive/texture.rs:65`).
Both of iris's texture-sampling primitives (`TEXTURE` and `GLYPH`) read that
array by index: `core/src/render/shader.wgsl:56` declares
`var views: binding_array<texture_2d<f32>>`, sized by
`UiLimits::default()` (`core/src/render/mod.rs:347`) at **100,000 textures,
1,000 samplers**. Getting a device to accept that layout needs three wgpu
features — `TEXTURE_BINDING_ARRAY`,
`SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING`,
`PARTIALLY_BOUND_BINDING_ARRAY` — which correspond to Vulkan's
`VK_EXT_descriptor_indexing` ("bindless"), promoted to Vulkan core at 1.2.
Glyph atlas pages are layers of one `texture_2d_array`. `GpuTextures` doubles
the array when it runs out of layers, copies the old layers on the GPU, and
rebuilds every bind group that referenced the old view. Page numbers are
assigned synchronously by `Textures::add_page` because glyph insertion needs
the layer before the renderer processes queued texture updates.
A transcript with an unbounded number of image attachments is exactly the
case that grows this array without bound: each attachment becomes its own
`Image` widget, which takes its own permanent array slot until dropped.
Standalone images each own a bind group and are not placed in the glyph
array. Each render layer keeps ordinary rect/glyph instances separately from
image instances. It draws the ordinary batch once, then binds and draws each
standalone image. This removes any fixed image count at the cost of one bind
and draw call per visible image, which is the appropriate tradeoff for phone
transcripts containing a modest number of screenshots.
## What was measured
The masks storage buffer appears in every image bind group. If that buffer or
the atlas array is reallocated, all affected bind groups must be rebuilt;
retaining a bind group across either reallocation would leave it pointing at
the old GPU resource.
**A new rig, `scripts/rigs/gpu-probe`**, asks a device for exactly iris's features
and limits with no window and no APK — a plain executable pushed with
`adb push` and run from `/data/local/tmp`. It has two parts:
`wgpu::Adapter::request_device` with iris's exact `Features`/`Limits`
(`src/main.rs`), and a raw Vulkan query bypassing wgpu entirely via `ash`
(`src/vk.rs`), to tell "the driver doesn't have it" apart from "wgpu didn't
detect it."
Texture updates accumulate their rebuild requirement with OR. A patch must
never clear a rebuild requested by an earlier push in the same batch.
- **On this VM's own GPU** (Vulkan via Venus onto an RX 7900 XT):
`IRIS DEVICE: ok`. Not the case that matters — nobody's phone is a
discrete desktop GPU — but it is why the design was never checked before
now: it always worked in the one place it was tried.
- **On the Android emulator's guest Vulkan**, both ICDs it ships
(`vk_swiftshader_icd.json` and, cold-booted, `lvp_icd.json`/lavapipe):
`request_device` **fails**
`Unsupported features were requested: TEXTURE_BINDING_ARRAY |
SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING |
PARTIALLY_BOUND_BINDING_ARRAY`. The raw `ash` query on lavapipe shows the
driver itself reporting all seven descriptor-indexing sub-features as
`true` at device API version 1.3 — so wgpu-hal's own feature detection is
being more conservative than the driver here, for a reason not chased
further (a likely instance-version negotiation gap, since the extension
only promoted to core at 1.2). That part is a wgpu-hal/emulator question,
not the finding that matters, and is **not** why this design is rejected.
Within a render layer, images are drawn after rects and glyphs. Both primitive
lists use `swap_remove`, so no code may infer draw adjacency from arena
adjacency after a free.
**The finding that matters is about real phones, sourced rather than
recalled:**
Standalone images currently use `NonFiltering` sampling. Thumbnail scaling
and filtering remain image-widget decisions, not texture-storage decisions.
- 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 by a value uniform across the invocation — Vulkan 1.0 baseline,
unrelated to bindless) and stops there; true of the 2021 and 2022
profiles as well.
- 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, with no claim made
for Bifrost, Midgard or Utgard, which are still common in budget and
older Android phones that are still in daily use.
- A search engine's summarized claim of "1% support on Android" for this
extension was checked against its cited source (an Arm blog post from
2021) and **was not actually there** — that number does not appear in
any primary source found and should not be repeated. The baseline-
profile finding above is the one with an attributable source; use it
instead.
## Verification rig
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 neither the emulator's failure nor the current
official hardware baseline gives any reason to expect that to change soon.
## Implemented, 2026-09-04
The shape above, built as proposed with one structural addition the proposal
didn't need to spell out and one bug it predicted made moot rather than
literally fixed. Files: `core/src/primitive/texture.rs` (`Textures`,
`TextureHandle`), `core/src/render/texture.rs` (`GpuTextures`),
`core/src/render/primitive.rs` (`Primitives`, `GlyphPrimitive`),
`core/src/render/atlas.rs`, `core/src/ui/painter.rs`,
`core/src/render/mod.rs` (`UiRenderNode`, `UiLimits` removed),
`core/src/render/shader.wgsl`, `src/default/render.rs`, and
`scripts/rigs/gpu-probe/src/main.rs`.
**1. Atlas pages as array layers.** `GpuTextures` owns one
`texture_2d_array` (`array_texture`/`array_view`), grown by doubling
(`grow_array`): a new texture is created at twice the layer capacity, the
old layers are copied across with `copy_texture_to_texture` (GPU-side, no
readback), and every bind group that referenced the old view — the main
one and every live standalone image's — is rebuilt, since the view's
identity changed. `GlyphPrimitive` carries `layer: u32` instead of
`view_idx`/`sampler_idx`; the layer number is assigned synchronously in
`Textures::add_page` (a plain counter, `next_page_layer`), not by the
renderer, because `GlyphAtlas::insert` needs it in the same call, before
any GPU sync happens — the renderer only finds out later, when it
processes the queued `Push`.
**2. Standalone images, one bind group each.** `TextureKind` on
`TextureHandle`/`Textures` distinguishes `Image` (a plain bind-group index,
`slot`) from `Page { layer }`. `Primitives` gained a second per-layer list
`images: Vec<PrimitiveInstance>`, tagged `IMAGE_BINDING` — separate from
`instances` (rects and glyphs), written by `Painter::write_image` rather
than through the generic `Primitive` trait, since an image has nowhere in
`PrimitiveData` to put a per-instance entry once the bind group already
picks the texture. `UiRenderNode::draw` draws a layer's `instance` buffer
once as before, then walks `image_instance` one entry at a time, binding
that texture's `BindGroup` (`GpuTextures::image_bind_group`) and issuing
`draw(0..4, k..k+1)` per image. Group 2's layout is exactly the proposed
`{atlas array, one image texture, sampler, masks}`; the main draw binds a
1x1 null view in the image slot.
**The one addition beyond the proposal**: the masks storage buffer lives
in every per-image bind group (group 2, binding 3), and `ArrBuf<Mask>`
recreates its buffer whenever the mask count changes size
(`render/util/mod.rs`'s `ArrBuf::update` now returns whether it resized).
A resize invalidates every bind group holding the old buffer, not just the
main one, so `GpuTextures::update` takes a `masks_resized: bool` and calls
`rebuild_image_bind_groups` when it's set, alongside the same rebuild the
array-growth path already needed. This wasn't a design question the
proposal had to answer (it treated bind-group construction as a given),
but it's exactly the shape of trap layer growth already had, so it uses
the same fix.
**3. No thumbnail atlas.** Not built, as proposed.
**4. Removed**: `TEXTURE_BINDING_ARRAY`, `PARTIALLY_BOUND_BINDING_ARRAY`,
`SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING` from
`src/default/render.rs`'s `request_device`, and `UiLimits` (the type
itself, not just its binding-array methods — once its two fields were
gone there was nothing left in it, and `UiRenderNode::new` no longer takes
a limits parameter). `binding_array` no longer appears anywhere in
`shader.wgsl`.
**5. Sampling** is still `NonFiltering`, unchanged, per the proposal's own
note that this is a separate decision for whenever the image widget itself
is touched.
**The `changed = false` bug is structurally gone, not patched.** The old
`GpuTextures::update` held one `changed: bool` that a `Patch` reset
unconditionally, which could erase an earlier `Push` in the same batch (a
new atlas page's `Push` immediately followed by `GlyphAtlas::insert`'s
`Patch`, both queued before the renderer ever runs). The new `update`
computes the rebuild signal by OR-ing each event's own answer
(`rebuild_main |= self.push(...)`), and `Patch`'s arm simply never
contributes to it — there is no shared mutable flag left for a `Patch` to
stomp on. Documented at the call site
(`core/src/render/texture.rs`, `GpuTextures::update`'s doc comment and the
`Patch` match arm's comment) rather than fixed as a one-line diff, since
the mechanism that could go wrong no longer exists.
**In-layer draw order is an explicit invariant now, not just a fact about
`swap_remove`.** `UiRenderNode::draw` draws every layer's images after its
rects and glyphs, and `Primitives::apply_free`'s doc comment states
directly that both of a layer's lists (`instances` and `images`) free with
`swap_remove` and that nothing may assume adjacency survives a free —
recorded there because `apply_free` is the one place a change to either
list's ordering would have to be reconciled.
**Verified:**
- `cargo fmt --all -- --check`, `cargo build --workspace --all-targets`,
`cargo clippy --all-targets`, `cargo test --workspace` all clean in
`iris/`, on the pinned `nightly-2026-09-03` toolchain. 14 tests pass
(unchanged from I1; nothing here is pure-logic enough to add a unit
test to — it's all GPU resource wiring).
- `iris/run-headless.sh minimal --shot /tmp/minimal.png` and
`iris/run-headless.sh tabs --shot /tmp/tabs.png`: both render correctly
on this VM's GPU (Venus) — `tabs`'s glyph-atlas text renders in every
panel, confirming `GlyphPrimitive.layer` addresses the array correctly.
- The standalone-image path specifically: a throwaway example (not
committed) with an `image(...)` widget as part of the root, run the same
way, rendered the image next to glyph-atlas text in one frame —
confirming a live `BindGroup` built by `GpuTextures::create_image` and
bound per-`draw()` call actually samples the right texture. `tabs`'s own
"image span" tab exercises the same widget but needs a click to reach,
which the headless compositor can't deliver (no seat devices, per I1's
own note on this file) — the throwaway example is what stood in for it.
- **Exercised, 2026-09-04: `grow_array` under real load, on `tabs`.**
Rather than building a purpose-made glyph flood, `PAGE`
(`core/src/render/atlas.rs`) was temporarily dropped from 1024 to 64 —
small enough that `tabs`'s ordinary mix of sizes and families (nothing
exotic: a handful of `Text` widgets at a few sizes, one at
`Family::Monospace`) already exceeds one page's worth of distinct
glyphs. A one-line `eprintln!` in `grow_array` confirmed two real grows
in a single run (`GROW_ARRAY: 1 -> 2` then `GROW_ARRAY: 2 -> 4`, i.e.
glyphs landed on at least a third layer), and
`iris/run-headless.sh tabs --shot` showed every tab's text rendering
correctly with no corruption or missing glyphs — confirming the
`copy_texture_to_texture` grow-and-relocate path and cross-layer
sampling (`GlyphPrimitive.layer` addressing a layer beyond the first)
both work. Command:
`sed -i 's/PAGE: u32 = 1024/PAGE: u32 = 64/' core/src/render/atlas.rs`,
rebuild, `./run-headless.sh tabs --shot /tmp/x.png`, then
`git checkout -- core/src/render/atlas.rs` to revert — this is a
throwaway diagnostic value, never a committed change, since a real
1024px page holding only a handful of glyphs at a time would be mostly
wasted space in normal use. Confirmed the revert left `tabs` and
`minimal` byte-identical to the pre-check screenshots afterward.
- **The decisive check**, `scripts/rigs/gpu-probe` rewritten to request iris's new
(empty) feature/limit set and run on this checkout's own emulator
(`ai-app-2`, via `emu`), booted with `EMU_GPU=software` so the guest gets
a real Vulkan device (SwiftShader) rather than the `-gpu host` default,
which disables Vulkan in this VM entirely (`-feature -Vulkan`, because
gfxstream can't pair Venus with the real GPU here — worth remembering,
since the *default* `emu up` gives a device with **no** Vulkan adapter
at all, which reads exactly like the old bindless failure if you don't
know to ask for `EMU_GPU=software`):
cd scripts/rigs/gpu-probe
ANDROID_NDK_HOME=$HOME/Android/Sdk/ndk/29.0.14206865 \
cargo ndk -t arm64-v8a -P 26 build --release
EMU_GPU=software emu up # from ~/repos/emulator-tools
adb push target/aarch64-linux-android/release/gpu-probe /data/local/tmp/
adb shell chmod 755 /data/local/tmp/gpu-probe
adb shell /data/local/tmp/gpu-probe
Output: `adapters: 1 — Vulkan SwiftShader Device (Subzero) (Cpu)`,
`features iris requires:` (none listed — the set is empty),
`max_buffer_size … ok`, and **`IRIS DEVICE: ok`**. This is the fix
measured working, on the exact rig that first measured it failing.
Emulator stopped afterward (`emu down`); nothing was left running.
`scripts/rigs/gpu-probe` requests Iris's exact feature and limit set without a
window. Run it on the target device when changing renderer requirements. A
successful desktop adapter is not evidence that the same feature is available
on Android hardware.
+9 -36
View File
@@ -1,46 +1,19 @@
# TODO
Working list from Iris, 2026-09-03. Remove an entry when it lands; annotate
one in place when it turns out to need a decision.
Only open product work lives here. Remove an entry when it lands.
## App — transcript
- [ ] Messages received from other agents are inconsistent — sometimes they
appear, sometimes they don't. **Needs a rig.** Read the code rather than
measured: a live Claude session only learns of a peer message from the
`origin` object on a turn's `result`
(`session/claude/translate.rs`), which the CLI attaches to a turn the
message *started*. So a message that arrives mid-turn, or a second one
within one turn, has nowhere to be reported — while an imported session,
which syncs from the CLI's own file, picks up every one of them. That
would show exactly as "sometimes". Confirming it means driving a real
stream-json session and sending it messages in both states.
appear, sometimes they don't. **Needs a rig.** The live driver only sees
the `origin` attached to a turn result, so a mid-turn or second message
may have nowhere to appear; imports read every message from the CLI file.
Drive a real stream-json session and send messages in both states.
## Session settings
- [ ] Autocompact belongs in session settings; empty disables it, which is the
default. Iris chose "hand it to the driver" — only where a driver has
auto-compaction of its own. **That option was offered on a false premise
and is not buildable yet.** It named pi's `set_auto_compaction`, but pi
was never built as a driver here: `session/llama.rs` talks to
`llama-server`'s OpenAI-compatible endpoint directly, and its `compact()`
refuses outright. Claude Code's auto-compaction is the CLI's own and
nothing in the stream-json control protocol this app uses configures it.
So the setting would be stored, passed to a driver, refused by every one
of them, and the field would never appear on any session. What is needed
first is either a driver that can take it, or a different rule — the
server watching `contextTokens` and running `/compact` itself is the one
that would work today, for Claude sessions, and it is the option that was
not chosen.
## From Iris's phone log export, 2026-09-07 (Compose app)
- [ ] **Crash on 2026-09-03 11:40, `IllegalArgumentException: Reversed
range is not supported`** at `ToolInput.kt:200` (`highlighted`, inside
`ToolInputView` -> `RawBlock` -> `ToolCard`). An `AnnotatedString`
range was built with end before start while highlighting a tool
input. Found in the per-package system log she exported; the tool
input that triggered it is not in the log. Reproduce by fuzzing
`highlighted` with inputs whose token boundaries collapse, and guard
the range construction.
default. No current driver accepts that setting: llama refuses compact,
and Claude's stream-json protocol cannot configure the CLI's own
autocompaction. This needs either a capable driver or a new rule such as
watching `contextTokens` and issuing `/compact` from the server.