Iris: "the documentation is also pretty crazy too. Can you go through it
and remove everything that's already done and decided? There's entire md
files iirc for projects already complete. And many with checkboxes already
ticked off that just fill up context."
docs/RUST.md 8503 -> 905 the framework bake-off (options,
recommendation, twelve closed
experiment boxes) and two superseded
"where things stand" sections, out;
what the experiments settled kept as
one line each
docs/IRIS_TODO.md 1383 -> 229 fifty closed items and six
phone-report sections whose defects
are all fixed
docs/LAYOUT.md 1116 -> 829 the pre-implementation framing: the
old trait, the checklist, the
migration list, the pass conditions
docs/TEXTURES.md 496 -> 240 the prior-art survey, the proposal
and its review, all implemented
docs/REVIEW-*.md 673 -> 0 two completed review passes; the two
findings left open on purpose (mask
hit-testing, the phone's font set)
moved into RUST.md
What survives a prune is what cannot be cheaply re-derived: measurements
(the APK-size table, the phone bench reports), dead ends, invariants and
their reasons, and the design of what exists now rather than the route to
it. AGENTS.md now says that, so the next session prunes as it goes rather
than appending; docs/IRIS_TODO.md's header says items are deleted when
they land rather than ticked.
Deleting the two review files left eighteen citations dangling in code
comments that state their reason inline and cited the file for provenance
only — those now read "(review, 2026-09-06)" and carry no dead pointer.
The emulator's measured GPU capabilities moved to the this-machine-android
skill, where machine facts belong. IRIS.md and DECISIONS.md are dated
records and were not rewritten; each gained one note that paths in older
entries predate the 2026-09-08 crate merge, pointing at the mapping.
Not touched, deliberately: docs/DECISIONS.md's entries (that file *is* the
queue of things for Iris to review, so deleting decided items would remove
what it exists for) and iris/readme.md and iris/TODO, which are hers.
Verified: ./run-tests.sh and `cd iris && cargo test` green, clippy and fmt
clean in every workspace, and every remaining docs/*.md cross-reference
resolves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
110 KiB
iris: the log of how it is being built
For Iris to read on her own time. An entry is anything major: a new capability or widget, a design decision and what it was chosen over, a mechanism that changed shape, a defect whose root cause says something about the framework -- and the public-surface changes a widget or app author would notice, which is all this file used to hold (widened on Iris's instruction, 2026-09-08: "any major additions or design things should be added there, not just public API stuff"). Small and trivial things still stay out.
Paths in older entries are pre-2026-09-08. The port's crates became
one crate on that date -- client-core -> app-rust's src/client,
transcript-ui -> src/ui, transcript-fixture -> src/ui/fixture.rs
and tests/, desktop-app -> src/desktop, android-app ->
src/android, android-shell -> src/shell. Entries are left as they
were written rather than rewritten, since each is a dated record;
docs/RUST.md's "One app crate" is the mapping.
An entry gives the date, what changed, why, and a short before/after where it helps judge the change without the session that made it. Newest first.
2026-09-08 (newest): a LazySpan clips itself, and nothing is unbounded
Four things you asked for, in one change.
A lazy span knows nothing about masks. It used to assert that
something around it had called .masked() and refuse to draw otherwise,
which is why a plain full-screen list -- the benchmark, any simple app --
panicked on its second line. What it does now is only the part that is
its own: it culls, so a row entirely outside the box it was offered is
never drawn, and a row straddling an edge is still drawn in full, because
virtualisation decides which rows and never how much of one. Whoever
wants that overhang cut off adds .masked(), exactly as whoever wants
scrolling adds .scrollable() -- your words: "masking should be opt in".
The transcript opts in, because it is a list under a header bar; a
full-screen list does not, and the widget has no business assuming either.
I got this wrong once on the way: the first version had the span set a mask of itself. That fixes the panic and is still the widget deciding something that is not its to decide -- a caller already clipped by something bigger ends up double-masked, and one that wants the overhang has no way to say so. Worth recording as the shape to avoid, since it looks like the tidy answer.
Everything on the transcript screen is capped now. One rule in one
place -- client_core::text_cap, mirrored as TextCap.kt with the same
numbers, so a bench comparing the apps compares renderers and not
policies:
a tool call's input 80 lines or 4 KiB -> "Show all N lines"
a tool call's output 80 lines or 4 KiB -> (already was)
a message 200 lines or 16 KiB -> "Show all N lines"
The input is what your edit card needed: an Edit's old_string and
new_string arrive whole and are routinely the biggest text on screen.
Messages are capped for the reason you gave -- both user and agent, in
both apps.
Three rules that took a screenshot to get right. A message is cut on a
block boundary, not mid-block: cut to its own opening line a fence
renders as an empty panel, which reads as a fault rather than as a cap
(the exception is a message that is one enormous block, which is
truncated, since dropping it would leave the row blank). A reply still
streaming is never capped, because a row that stopped growing at two
hundred lines while the model was plainly still writing reads as the
stream having died. And the input's two blocks share one "Show all",
since they are two halves of one answer -- while input and output have
their own, since wanting the whole of a new_string says nothing about
wanting the whole of the build log under it.
The Compose app does not wrap raw text any more, per your call: a tool's leftover input fields and its output pan sideways like the command already did. A wrapped log destroys the column alignment that carried its meaning, one line at a time and only on the long lines -- so iris was right and Compose was the one to change.
2026-09-08: redrawing one widget cost O(its own primitives squared)
Your report -- expanding a tool card with a long horizontally-scrolling edit in it lags -- is a framework defect, not a text-layout one, and the size of it is not close: a 51,200-glyph block took 1.37 seconds to redraw, of which shaping and rasterising the text was 20ms. It is 29ms now, and the cost is linear in the glyph count rather than quadratic.
What happened. A widget redrawn in place frees every primitive it owned
and writes fresh ones. Freeing compacts each layer's draw order with
swap_remove, and every primitive that gets swapped into a hole has to be
told its new position -- so a widget with N primitives generates ~N
renumberings. Finding the handle to renumber was a linear scan of
everything that widget drew, which made the pass N^2. For a paragraph
that is nothing. For one text widget holding a whole old_string and
new_string, N is every glyph in the card.
The fix is a back-pointer: the primitive arena now records, per slot,
where that slot's handle sits in its owner's ActiveData::primitives
(Primitives::handle_index), written at the one place a handle is taken
(Painter::own). apply_free indexes straight to it.
50,000 glyphs, redrawn: before 636ms/redraw after 2.4ms/redraw
per glyph: before 12.7us after 0.043us (flat in N)
benches/message_list.rs grew scenario (g) for it, and the number to
read is per-glyph: flat as N grows is the pass condition, and a total
hides it. That file had also stopped running at all -- scenarios (a) and
(e) built a LazySpan with no mask around it, which the span asserted
against, so the whole benchmark panicked on its second line. Worked around
here and fixed properly in the entry above, which deletes the assert.
What this does not fix, and what the entry below closes: an open card still shapes, rasterises and submits every glyph of its input and output, not the screenful you can see. iris does not cull within a widget, so the only bound available is a cap on what goes in.
2026-09-08: one ScrollController, a Scrollable trait, and Pin
Your three points on docs/SCROLL.md, in one change. The shape is the one
you proposed: a controller both scrolling widgets contain, rather than
a protocol between them.
Scroll is ScrollArea, because it only scrolls a predefined area --
your word for it. ScrollController (widget/position/scrollable.rs)
holds everything that is not a particular widget's layout: the position,
the pending delta, the travel left each way, the pin, the DragGesture
and the Flinger. Scrollable is the trait over it -- one required
pair of methods handing the controller back, and scroll, fling, drag,
amt, is_scrolling, cancel_fling, tick_fling and the pin as
defaults.
The three scrolling methods are off Widget. scrolls_itself,
apply_scroll and scroll_offset existed only so a Scroll could drive a
LazySpan it had no business wrapping. A LazySpan owns its own
controller now, so there is no wrapper, no measure/apply/place dance
between two widgets, and no amt with two meanings depending on which kind
of child it had. The transcript's tree lost a node with it: list is the
layout and the position.
before Masked(Scroll(LazySpan)) .scrollable_to_end(Axis::Y)
after Masked(LazySpan) .scrollable()
.scrollable(axis, pin) is the only one now -- scrollable,
scrollable_on and scrollable_to_end were one mechanism with the
arguments hidden in the names. A LazySpan has an inherent
scrollable() that shadows it, since Rust resolves inherent methods
first: same word at the call site, and the wrapping version cannot reach a
widget that must not be wrapped. The axis and pin are already its own.
Pin says which end either way round. Start/End are
content-relative, Neg/Pos axis-absolute -- your ask, so a caller can
say "the bottom" and mean it whichever way the content runs. They coincide
for everything except a reversed LazySpan, where they are opposites.
A delta's sign is now a screen direction, positive scrolling up or
left. It was "positive brings earlier content into view", which points the
opposite way for a Dir::UP span -- a real defect, latent only because
nothing builds one yet, and invisible to the existing test because that
test asserts in the same space the bug lives in.
Why overscroll exists at all, since you asked: a lazy span cannot see the wall until it has walked to it, so with rows loaded past an edge it honestly reports infinite travel, takes the whole delta, and the walk finds the content ran out 200px ago. It is given back inside the same frame. The rows past the edge have never been measured, and measuring them is the work virtualisation exists to skip.
One behaviour changed: a delta is applied by the next draw rather
than the moment it arrives, since the layout is the only thing that knows
where the content ends. Nothing on screen differs -- input is followed by
a frame -- but amt no longer moves between draws, which several tests
were reading.
The pin question SCROLL.md had open ("the pin lives in each widget, not
in Scroll") is closed by this: it is one field on the controller, and a
caller edits one place.
2026-09-08 (earlier): List is LazySpan, and scrolling belongs to Scroll
From the design exchange after the overscroll fix, where you asked
whether List could just be Span::scrollable(). It cannot -- a lazy
layout is a real thing a Span is not, for reasons measured below -- but
almost everything you named as out of place was, and it has all moved.
List -> LazySpan (ListRow -> LazyItem, RowKey unchanged),
living beside Span under widget/position/. It is what Span is, laid
out lazily from an anchor rather than eagerly from the start, and the name
says so. It also stops colliding with BlockKind::List in the markdown
code.
It takes a Dir instead of an Axis, meaning what it means in Span:
which end item 0 sits at. That is a different question from which end
the view is pinned to, and conflating them would stand a transcript on
its head -- its oldest message is item 0 and sits at the top (Dir::DOWN)
while the view clings to the bottom. So the pin is its own argument:
LazySpan::new(dir, at_end), spelled like Scroll::new's. Dir::UP is
real rather than nominal: the walk works in direction-relative pixels from
the leading edge, with abs_region flipping the box and flip_pos
converting the screen-space positions the hit-testing helpers speak in.
Everything about scrolling left the list. Its Flinger, its
density, its Arc<dyn RequestRedraw> (which had no business existing in
a single-threaded frame loop), its tick, and the whole
fling/cancel_fling/tick_fling/is_scrolling/fling_velocity
surface are gone. Scroll was the only other Flinger user, so there is
now exactly one implementation of the physics and sense.rs keeps the
parts both ever shared. A transcript is list.scrollable_to_end() like
anything else.
The new public surface: three Widget methods
fn scrolls_itself(&self) -> bool { false }
fn apply_scroll(&mut self, delta: &mut f32) {}
fn scroll_offset(&self) -> f32 { 0.0 }
Scroll asks the first, and if the child says yes it stops sliding the
child about as a lump and starts handing it deltas. Each method is &self
or &mut self for a reason worth keeping: reaching a widget through
Widgets::get_dyn_mut marks it dirty, so asking the capability question
through apply_scroll would dirty every ordinary child on every scroll
tick and cost exactly the O(1) move the whole scheme exists for.
Scroll::draw is then measure, apply, place -- the same measure-then-place
idiom it already used for its own content length. The measuring draw is
free in the common case (unchanged region, nothing dirty, so draw_inner
returns immediately and the child's stored walls are still correct) and
really walks exactly when the content changed, which is when they need
re-reading. Nothing is marked by hand: reaching the child to hand it
the delta is itself what dirties it, so the placing draw really draws.
That is why Painter::draw_again could stay deleted.
Why scroll_offset exists
apply_scroll leaving a remainder was meant to be the whole story, and it
is not quite. A lazy span usually cannot say where its content ends
until it has walked there, so it takes a delta in full whenever the wall
is not already in view, and the walk that follows gives part of it back.
The remainder is exact only when the wall was already visible. Scroll
adding remainders up would over-count by every overshoot and never
correct, so it reads the child's accumulated movement after the placing
draw instead, and amt is set from that. amt therefore always equals
what is on screen.
For a self-positioning child amt is movement, not position: paging
rows in above moves the origin and the child cannot say by how much,
never having measured them. The direction is the same as an ordinary
child's; the absolute value is not comparable, and a scrollbar would need
a real content length before it could use either.
One convention for a scroll delta
There were two, and they read alike: Scroll::scroll(+) moved toward the
start while LazySpan::scroll(+) moved toward the end, with the
latter's doc claiming to mirror the former. Every call site had to
remember which it was talking to, and Selection::drag negated on the way
in. There is one now -- the finger's, which is Scroll's -- and
LazySpan::scroll is private with the single negation inside
apply_scroll. a_negative_delta_moves_toward_the_end pins it across the
whole handoff, since no type can catch a scroll running backwards.
What the measurements said, for the record
- A
Spanis skipped entirely in the steady state ((0,0,0)counters), but when it is redrawn it costs two draws per child -- 21 draws for 10 children -- because phase 1 offers each child the ambient region to learn its length and phase 2 offers it its real share. Any mutation of aSpantherefore redraws all of it: 24 draws for 11 children after one prepend. That is why a transcript cannot be one. - A settled scroll tick of the lazy span with 31 rows on screen is
1 real draw and 31 move-slot writes, no primitive rewrites and no
text reshaped; an idle frame is
(0,0,0,0). That is the number against which "store the edges and only recompute what changed" would be judged, and it is why the walk was left alone. - The framework's own
ActiveData::sizecannot serve as the row-height cache:remove_recfrees it the moment a row is virtualised away, which is exactly when the walk needs it. The cache stays in the container, keyed byRowKey-- which is also right for the reason you gave, that a widget may one day render in two places and a size keyed byWidgetIdwould break.
2026-09-08 (later still): a List clamps its overscroll in the same frame, and draw_again is gone
The last place in iris that corrected itself on a later frame. List's
walk outward from its anchor could end up off the end of its content --
a fling stops wherever the spline's last step left it, and a scroll is
deliberately unclamped because nothing at the moment of the call knows
where the content ends. clamp_to_content measured that gap from the
edges the walk had just placed, wrote it to the anchor, and asked for
another frame. So one frame was drawn with the content past its own end,
and on Iris's phone a hard fling to the top left the whole screen blank
until something asked for that frame -- which a fling that has stopped no
longer does.
It is the same shape as Scroll's fix. The walk is now List::lay_out,
and List::draw runs it, asks overscroll_gap whether the layout landed
off the end, and on a gap moves the anchor and runs the walk again,
inside the same frame. overscroll_gap is a pure measurement -- no
painter, no redraw handle -- and the decision to lay out again is draw's.
Three properties make the second pass cheap and correct:
- It runs only on a frame that actually overscrolled. An ordinary scroll tick still walks once.
- One further pass always settles it. The gap is measured from the
edges the first walk placed, so moving the anchor by it puts that edge
exactly on the viewport's; the opposite end can only open a new gap if
the content is shorter than the viewport, which
overscroll_gapdeclines to touch at all (a short list is bottom-anchored on purpose). - The second walk is mostly moves. Every row keeps the box its cached
height gives it and only its offset changes, which is
draw_inner's O(1)movpath.
Public surface: Painter::draw_again is removed. List was its only
caller, so with this there is no "ask for a corrective frame" mechanism in
the framework -- which is the point, since reaching for one is the sign a
placement should have been redone inside the draw that discovered the
problem.
List::place also lost half its body to the same simplification the rule
suggests: a placement is one pinned edge plus a height, so Placement:: edges(height) gives the box and the top-known and bottom-known cases stop
being two copies of the same arithmetic.
Tests that draw no settling frame on purpose, and fail without the change:
fling_toward_the_start_stops_at_the_first_row and the new
scrolling_past_the_start_is_given_back_in_the_same_frame in list.rs,
and scrolling_past_the_first_row_settles_on_it /
scrolling_past_the_last_row_settles_on_it at layer 1
(transcript-fixture/tests/top_edge.rs).
2026-09-08 (later): a Scroll measures and places its content in one frame
Iris's phone: "when typing with the keyboard up and entering enough newlines ... the text drops down close to the bottom and seems to ignore the padding. If I close (and optionally reopen) the keyboard it seems to fix itself."
Scroll::draw used to place its child against last frame's content
length. Every newline therefore drew the field in a box one line short of
its text, and since that text is centred in its box it hung half a line
past each end -- putting the caret's line box a full 12dp below the bar's
inside edge, flush with its bottom, with the padding eaten. The comment
there said the lag "self-corrects the next frame". There was no next
frame: a keystroke dirties the field, not the scroll area, and after that
frame the tree is clean, so the stale placement was simply the last one
drawn -- until the keyboard closed, whose inset rewrite dirtied the bar
and forced the redraw. That is the "it fixes itself" half of the report.
The rule Iris stated when she saw the first fix, and which the code now follows: layout is a pure function of the state, never of how many frames have been drawn. Nothing should heal itself, because nothing should be drawn wrong in the first place; where two draws are genuinely needed to place something, both happen in the same frame.
So Scroll::draw now draws its child twice: once at last frame's length
purely to measure it, then once at the length it just measured, with the
end-pin and the clamp applied only to that second placement. The same
measure-then-place idiom Span::draw and List::place already use.
The second draw is free unless the content's length actually changed
-- an ordinary scroll tick offers the same size at a new offset, so the
first call is draw_inner's O(1) mov and the second, with an identical
region, returns at its first line. Growing a bottom-anchored area is
still O(1) in the sense that mattered; what it is not is free to place
its child against a length already known to be wrong. Last frame's length
survives only as a hint that keeps the common case cheap; nothing drawn
depends on it.
Two consequences worth knowing:
- An end-anchored
Scrollnow sits at its end on its first drawn frame, not its second. It could not before: the end-pin needs the content's length, which was a frame behind, so a fresh area showed its start and jumped. Two layout tests that scrolled down from what they assumed was the top now build their area withat_end: false, which is what they always meant. List::clamp_to_contentis now the only place left that corrects on the next frame -- it finds a fling has run past the content's end and marks itself for a redraw. Same defect, larger machinery; recorded in docs/IRIS_TODO.md rather than folded into this change.
Covered by a_newline_leaves_the_caret_inside_the_composers_padding
(layer 1, transcript-fixture/tests/phone_screen.rs), which draws no
settling frame on purpose and fails on the old code with the caret
exactly on the bar's edge. On the emulator the caret's bottom moved from
1535 -- the bar's own bottom edge -- to 1509, 26px inside a 31px padding;
the remainder is parley's line box standing ~6px taller than its line
height. phone.rs grew a --typed TEXT argument beside --message,
since laying the composer out from scratch and growing one already drawn
are different cases and only the second reproduces this.
2026-09-08: what a cancel means, what a row's box is, and one fling for every scroll area
Iris's second 2026-09-08 report, from the bench on her phone. Four items, and each turned out to be a rule stated in one place and missing from its siblings rather than a special case.
A gesture the platform takes away is a cancel, not a release
(CursorState::cancelled). Android's ACTION_CANCEL used to take the
same arm as ACTION_UP, so the system's own swipe up from the bottom
edge to leave the app arrived as a flick released at speed: the
transcript flung while the app was in the background, and came back
somewhere else. A cancelled sample now hands CursorSense::Cancel to
the capture holder and every widget still tracking the press, clears
both, and derives nothing else from that sample -- no tap, no selection,
no fling. That is the same sense a widget already gets when it loses a
capture race; what is new is that the platform can raise it, and that
the winner hears it too when the platform is the one cancelling.
A DragGesture ignores a cancel when it is the one holding the
capture. A cancel goes to every pressed widget that did not capture,
and one gesture is routinely driven by several of those: a transcript
row's text block feeds the shared gesture that captures under the
list's id, so the block is a "loser" on the very frame its own pan
committed. Cancel means "somebody else won", so the question is
whether the holder is us -- and now it is asked. With that, a row's
block registers the whole drag_senses() set, which is what the doc on
that set has always said a widget driving a gesture must do; it was the
one place that did not, and it is why panning a code fence sideways and
then tapping made the transcript jump.
A row is drawn at the box its own height implies, in the frame that
height changes (List::place). A row is offered its cached height
so that an unchanged row takes draw_inner's cheap path; a
.background(rect(..)) fills whatever box it is handed. So on the frame
a row changed height its text laid out at the new height and its
background painted at the old one -- collapsing or opening a tool card
looked closed while its text was there, then open while it was not. When
the measurement disagrees with the offer, the row is now drawn again at
its true box. The bottom-anchored half had a reposition for this,
which writes an offset and never a size, so it could not fix it either:
the same rule, applied to one member of a set of two.
Every scroll area flings, on either axis (iris::sense::Flinger).
The fling was List's alone -- the curve, the clock, the incremental
delta, Compose's two release thresholds -- and a Scroll dropped its
released velocity on the floor, with a comment explaining that the areas
it wrapped were only a screenful. That stopped being true the moment a
code fence became one. Flinger is that machinery as a type both use;
what it deliberately does not know is which way a positive delta moves
the content or where the content ends, because a List and a Scroll
answer those oppositely. The caller applies tick's delta in its own
convention and calls stop at its own wall. Scroll::drag now answers
whether it started a fling, which is what scroll_area needs to call
UiData::animate -- the same split List::fling already documented,
for the same reason: only the caller can reach the frame loop.
Removed, not worked around: tool.rs no longer flattens its two
Spans into one, so a tool group holds its cards 4dp off its own edge
again. The defect that shape was avoiding -- "a Span of Padded
children inside another Span places those children a slot out of step"
-- is not reproducible on 2026-09-08, checked both with a headless
render and with a new layer-1 test.
2026-09-08: a gesture can be cancelled, and the pointer belongs to the input handler
Two changes to how a drag ends, from defects on Iris's phone (a code fence panned sideways made the transcript jump on the next tap, and made the fence itself snap back).
CursorSense::Cancel, and GestureOutcome::Cancelled. Taking
pointer capture cuts every other widget off from the press completely --
no PressEnd, no Drop -- so anything else tracking that press was left
with a gesture open at an origin belonging to a finger long gone, and the
next touch anywhere was measured from it. A widget that loses a capture
race is now told, exactly once. It is a separate sense from Drop
deliberately: Drop means "your gesture finished" and callers act on it
(a fling, a tap, a link followed), which is precisely wrong here.
CursorSense::drag_senses() is what a widget driving a DragGesture
registers -- the frames plus unclick, Drop and Cancel. Both ways a
gesture can end, stated once rather than remembered per call site;
forgetting Drop is what left a Scroll panning from a stale position.
The pointer's state left UiRenderState. capture_pointer,
release_pointer and captured_pointer are gone from it. Capture and
the pressed set are PointerInput -- the cursor senses' Event::Global,
a new associated type for state an event owns that belongs to no single
widget -- held by the event manager that runs the dispatch and reached
by &mut, with no lock anywhere. A handler asks through
ctx.data.pointer (PointerRequests: capture(id), release(),
holder()).
// before -- interior mutability on whatever structure was reachable
ctx.data.render.capture_pointer(id);
// after
ctx.data.pointer.capture(id);
DragGesture::handle and Scroll::drag take &PointerRequests where
they took &UiRenderState. task_on also lost a Data: Send bound it
never needed -- the future it spawns never sees the event's data, and
that bound was the whole reason the pointer state had been behind a
Mutex.
2026-09-08: mark(dir, dp, colour) -- a drawn triangle, and a scroll area's opening edge
iris::widget::mark draws a filled, antialiased triangle pointing
along a Dir, at a size in dp. It replaces the disclosure codepoints
U+25B8/25BE/25B4, which were a bet that the platform's fonts have them --
once iris stopped bundling its own faces, Iris's phone drew an empty box.
It rasterises one oversampled bitmap into the ordinary texture path and
scales it into the box asked for, so no new primitive was needed and it
is correct at any density.
scrollable_on now opens at the beginning of its content, and
scrollable_to_end(axis) is the other one -- pinned to the end and
staying there while the content grows, which is what a composer wants and
what everything did before. A code fence was opening at the end of its
longest line, in the middle of a word. Scroll::new takes the edge as a
third argument rather than deciding for its caller.
The design point behind that bug is worth more than the bug: Scroll
held its content's length as an f32 that was 0.0 both for "there is
nothing here" and for "I have not drawn yet". Those lead somewhere
different, and the code could not ask which it had -- so the first
frame's clamp computed a scroll range of zero, read amt == len as
"sitting at the end", and pinned itself there. It is an Option now,
and the clamp declines to answer a question it cannot yet answer. Any
measurement iris caches from a previous frame has this shape (LAYOUT.md
section 4's one-frame lag is the general case), so the rule is: give the
unmeasured state its own value, not a plausible number.
2026-09-08: masks have a shape -- .masked_by(shape), and clipping applies to touch
A mask no longer carries a rectangle. It carries the slot of a primitive already drawn, and the fragment stage evaluates that primitive's own coverage at each masked pixel and multiplies it into the alpha -- the same rounded-rect SDF the primitive itself is drawn with. Nothing about the shape is copied, so a rounded container's corner and the corner its content is cut to cannot fall out of step, and nested masks multiply rather than intersect: a pixel inside two feathered corners is dimmed by both.
// before -- the mask clipped to the padded box, the rounding was
// only painted behind it, and the two knew nothing of each other
field.scrollable_on(Axis::X)
.masked()
.pad(dp(FRAME_PAD_DP))
.background(rect(fill).radius(dp(FRAME_RADIUS_DP)))
// after -- one rect, drawn and clipped to
field.scrollable_on(Axis::X)
.pad(dp(FRAME_PAD_DP))
.masked_by(rect(fill).radius(dp(FRAME_RADIUS_DP)))
.masked() is unchanged for callers and still clips to the widget's own
box; under it, it now writes an undrawn rect primitive and points the
mask at that, so square-cornered clipping is the same mechanism rather
than a special case. .masked_by(shape) draws shape behind the
content, in its own layer, and clips to the first primitive it drew.
There is no radius or shape argument anywhere -- that is the point.
A press now has to be inside the shape, not just the box. A corner
the container rounded away is not there to be tapped, which needed the
coverage function on the CPU as well as in the shader;
iris/tests/mask_sdf.rs runs the shader's own text against the Rust one
over a grid of points so the two cannot drift apart.
One limit worth knowing before reaching for it: a mask's shape must be a rect, asserted by name. Clipping to a glyph or an image would need, respectively, a CPU-side alpha plane for the hit test and a bind-group switch the fragment stage cannot make. The shader has the branch where either would go.
2026-09-07: iris runs on a GLES-only Android device, and reports the renderer it cannot build
AndroidRenderer::new asked wgpu for Backends::PRIMARY, which does not
include GL. A device that offers a Vulkan driver with no adapter behind
it -- this checkout's emulator -- therefore had no adapter at all, and the
.expect on that turned into a crash loop with nothing on screen. It now
probes for a PRIMARY adapter first and falls back to Backends::GL when
there is none, so Vulkan still wins wherever it has an adapter and
nothing changes on a phone.
The probe deliberately runs on an instance that never touches the window:
an Android window can be connected to one graphics API only, so an
instance carrying both backends lets Vulkan claim the window and leaves
the GLES surface unusable. That is why this is a second instance rather
than one wider Backends value.
The other half a caller sees: AndroidRenderer::new already returned
Result<Self, String>, and now every way it can fail goes through
that -- no surface, no adapter, no device, as well as the bind-group
validation failure it was originally written for. surface_changed puts
that string on screen and in the log ring instead of aborting.
2026-09-07: VelocityTracker takes positions, not deltas
A flick released at the wrong speed because the tracker averaged. It now does what Compose's touch scrolling does, and that changes what a caller feeds it.
// before -- one frame's motion
tracker.add_sample(dy, now);
// after -- where the finger was
tracker.add_position(pos.axis(axis), now);
VelocityTracker::velocity is a port of Compose's VelocityTracker1D
with Strategy.Lsq2: a degree-2 least-squares fit through the last 20
positions, differentiated at the newest sample, with Compose's 100ms
horizon, 40ms stopped-gap and three-sample minimum. Positions rather than
deltas because a fit needs points on a curve -- Compose itself throws on
differential data for this strategy.
Three consequences a caller sees. A gesture with fewer than three
samples answers 0.0, where the average answered a number from two;
that is Compose's answer too, and on the phone a 120Hz flick delivers
four or five. A finger that rests for more than 40ms before lifting
answers 0.0 rather than flinging at the speed it arrived with.
add_position must be called in time order -- the same debug assert
as before, now load-bearing for the fit's x-axis.
Also new: VelocityTracker::samples_display (the held samples as
t_ms:position, printed by DragGesture at debug level so a flick
reported from a phone can be replayed), DragArbiter::axis, and
sense::MAX_FLING_VELOCITY_DP_S (8000, ViewConfiguration's own).
List::fling now applies that maximum against its own density and
ignores anything at or under 1px/s, which is Compose's pair of thresholds
exactly -- there is deliberately no 50dp/s minimum, because Compose's
scrolling never consults the one in ViewConfiguration.
2026-09-07: client-core carries the app's own log
Not iris itself but the crate beside it, and it is a new public surface an
app author will use: client_core::log_ring. Because Iris's phone has no
logcat, an app now keeps a bounded copy of its own log and hands it to
Dev Updater on the device.
Before, an app installed a platform logger and that was the end of it:
android_logger::init_once(config); // Android
// nothing at all on the desktop
After, the platform's logger becomes the inner logger of a ring that
records everything alongside it -- logcat and a terminal see exactly
what they saw before:
client_core::log_ring::install_process_logger(
Box::new(android_logger::AndroidLogger::new(config)),
LevelFilter::Debug,
)?;
let ring = client_core::log_ring::process_ring(); // 2000 lines / 256 KiB
ring.to_text(); // for a report
ring.summary(); // "1801 lines held, 12 dropped, last 20:09:24"
// and, for whatever hands the log out of the process:
let (lines, next) = ring.since(cursor); // inclusive of `cursor`
ring.newest_seq(); // None for a ring nothing was written to
process_ring is a deliberate process-global, unusually for this project:
log already has exactly one backend per process, and a ring passed around
as a parameter would be a second answer to "which lines exist".
Amended later the same day. client_core::log_upload and
ai-server's POST /client-log are gone -- an app no longer sends
its log anywhere. It exposes it on the device instead, and Dev Updater
reads it there: on Android that is a ContentProvider at
<applicationId>.devlog, which is Dev Updater's own contract (its
README.md, "An app's own log") rather than anything iris-specific.
LogRing::newest_seq() is the one addition that went with it: a reader
holding a cursor uses it to notice the process restarted, since the
ring is in memory and a new process starts again at sequence zero.
The reasoning and the rejected alternatives are in docs/DECISIONS.md,
2026-09-07.
2026-09-07: TextData no longer bundles a font
Iris's call: "remove the font for now; just match what compose does."
TextData::default() used to embed six Noto Sans/Noto Sans Mono .ttfs
(3.6 MB, include_bytes!) and register them ahead of the platform's own
fonts in the SansSerif/Monospace fallback lists. That registration is
gone; TextData::default()'s signature is unchanged, but what it produces
now depends entirely on fontique's platform discovery (already on by
default, previously shadowed) -- Roboto/Roboto Flex on Android, whatever
the desktop's fontconfig resolves on Linux. No caller-visible type or
method changed, but every consumer of iris-core text now renders with
whatever the host platform's fonts are, not a fixed bundled face -- worth
knowing if you were relying on pixel-identical text across devices.
.so shrank by 3.75 MB. One real gap surfaced by the switch: this
fontique version's Android backend never resolves the Monospace
generic family (a fontique ordering bug, not new in this change), so
Family::Monospace text falls through to the same face as
SansSerif on Android rather than a true monospaced one -- still
visible, not blank, just not monospaced. docs/RUST.md's "Platform fonts
(2026-09-07)" has the full account.
2026-09-07: a headless harness, replayed touch, and physical-pixel desktop layout
Layer 1 and 2 of docs/RUST.md's "Three test layers".
New: iris::harness -- a screen driven in-process with no window, no
compositor and no GPU, on a clock the caller advances. Harness::new(size, density) gives you an Rsc, a UiRenderState and a state that
implements FocusHost/OpenUrl by recording what the platform was
asked for (keyboard_shown, opened_urls) rather than doing it;
frame(t_ms)/frames_until(..) run frames, touch(action, pos, t_ms)
feeds one pointer sample the way Android's on_touch_event does, and
replay(&TouchScript) runs a whole recorded gesture. TouchScript parses
a plain t_ms action x y file (down/move/up/cancel), so the
batched 120Hz flick shape your phone actually delivers is a file that
cargo test can replay -- something the emulator cannot produce at all.
New: List::fling_velocity() -> Option<f32>, what the release
measured, readable where it landed rather than by re-timing the gesture.
Changed: List starts a fling's curve at its first tick_fling, not
at the release. The only clock it reads is now the one its driver hands
it; in a running app the difference is at most a frame.
Changed: the desktop backend lays out in physical pixels with a
density, exactly as Android does. iris::default::content_scale(window)
is the desktop's content_scale -- winit's scale factor, overridable with
the IRIS_SCALE environment variable -- and it now feeds
UiRenderState::set_density/TextData::density instead of dividing
coordinates into a separate "logical" space. That division had
UiRenderState::resize (physical) and the window uniform (logical)
disagreeing on any display whose scale factor is not 1.0, and rasterised
glyphs at one resolution to display them at another. Input::event lost
its scale_factor parameter as a result, and DefaultUiState:: window_size() now answers physical pixels. On a 1.0 display nothing
changes. The override is what lets run-headless.sh --phone open a window
at your phone's own 1080x2424 and 2.55.
2026-09-07: the fling curve was the identity function
You said the fling "seems to just be linear velocity with an abrupt stop."
It was, exactly: android_fling_spline's lookup returned t for every
t. Two halves of AOSP's spline build loop had been transposed, which made
its two tables identical, and the lookup interpolated one against the
other -- which reduces algebraically to t. So a fling coasted at its
release speed for the whole (correctly computed) duration and stopped dead
at the end of it.
Ported exactly now from OverScroller.java and Compose's
SplineBasedDecay.kt, which agree line for line. One public addition:
FlingCalculator::velocity_at(velocity, elapsed) -> f32, beside the
existing position_at -- AOSP's mCurrVelocity and Compose's
FlingInfo.velocity. It is what makes "is this decelerating" answerable
rather than inferred, and it is what List::tick_fling's new
iris fling tick: debug line reports each frame.
The lesson worth keeping, since it cost two builds on your phone: every
test the calculator had compared it with itself -- monotonic, correctly
signed, integrates to the closed form, per-tick deltas non-increasing --
and all of them pass on a straight line. The numbers now come from
iris/benches/fling_spline_reference.py, a separate hand transcription of
the two sources, checked in beside the tests.
2026-09-07: the Android insets bridge counts its own dispatches
AndroidUiState::insets_report() -> String is new, and the bench app's
Diagnostics pane shows it. It carries the last insets plus how many times
the platform has delivered any, because "the keyboard did not push
anything up" has two causes that look identical on screen -- the listener
never fired, or it fired with a zero height -- and you have no logcat on
the phone. dispatches=0 prints a sentence saying so rather than the
numbers, which would be defaults rather than measurements.
2026-09-07: widgets can animate, and a fling finally moves
Iris's phone said "fling still doesn't work" twice. The velocity was only
half of it: nothing in iris advanced an animation between input
events, so List::fling stored a speed that nothing ever applied. Three
public changes come out of fixing that.
Widget::tick(&mut self, now: Instant) -> bool is a new trait method,
defaulted to false, so no existing widget changes. A widget that
overrides it is animating; answering false is how it stops.
UiData::animate(id) and UiData::tick_animations(now) -> bool are
the registry and its driver. A gesture that starts an animation registers
the widget; each backend calls tick_animations once per frame before the
draw and asks for another frame while it answers true. That answer is
the only thing in iris that makes a frame happen without an input event,
and an animation's path out is its own tick returning false -- nothing
has to remember to unregister it.
// before: the velocity was stored and never applied
list(ui).fling(-v);
// after
list(ui).fling(-v);
let id = list.id();
ui.ui_mut().animate(id);
The two calls are deliberate rather than folded into fling: the velocity
is the list's business and whether anything animates at all is the frame
loop's, and a caller driving its own frames (the benchmark, the headless
tests) still calls tick_fling directly.
FlingCalculator needs the real display density, and its coefficient
was wrong. new(density) takes physical pixels per dp and the
velocity handed to it must be in those same physical pixels -- the
density does not cancel out, contrary to what that type's doc used to
claim. Separately, physical_coefficient multiplied by the scroll
friction (0.015) where AOSP multiplies by its own tuning constant 0.84, a
factor of 56 inside an exponential. Together they gave an ordinary flick a
45-second coast, which nobody could see while flings never animated.
List reads its density from the painter now, and
a_flick_lasts_what_aosps_own_formula_says_it_does pins the absolute
numbers (0.59s and 621px for 3000px/s at density 2.75) against AOSP's
formula -- the check every previous test could not make, because they all
compared the calculator with itself.
MOVE_CHAIN_LIMIT is 64, not 16, in render_state.rs and
shader.wgsl alike. It bounds a walk so a cyclic parent cannot hang
either side; it was never meant as a claim about tree depth, and the
transcript screen's composer field sits 17 slots below the root. Past the
bound both walks silently stop summing, so a widget draws and hit-tests
short with nothing to say so; the CPU assert now prints the chain, so a
cycle and a deep tree can be told apart.
2026-09-06: tool cards, ToolState, and a screen that knows whether its session is working
transcript_ui::tool is new: a card per tool call, a group per run
(P1b). Three things in the public surface follow from it.
client_core::transcript_fold::ToolState is what a card colours
itself by -- Running, Deciding, Succeeded, Failed, NoResult --
built by ToolState::of(&item, session_working). The pair it exists for
is Succeeded against NoResult: a call that finished having printed
nothing and a call whose result never arrived both leave an empty
output, and drawing them the same way states a verdict nobody reached.
Only the session's own status separates them, which is why of takes it.
event_model::Event::ToolEnd gained is_error (#[serde(default)],
so an older transcript still parses), and
client_core::transcript_fold::TranscriptItem::ToolRun gained failed.
Without them a result was everything a card knew and a broken call drew
exactly as confidently as one that worked -- the missing state, not a
wrong one. Every construction site of both had to gain a field; the value
comes from the CLI's own tool_result, read in one place
(import::tool_result_is_error) by both the live translator and the
import replay.
TranscriptScreen::set_session_working(rsc, bool) is new, and is the
only thing that writes it. Before: a card with no result was drawn the
same whether its turn was still going or had been interrupted. After:
only the newest row can say "running", because every row behind it
belongs to a turn that has ended, and changing the flag redraws that one
row rather than the screen. TranscriptScreen::expand_tail_tools(rsc, bool) joins it, answering whether there was a tool run to act on -- a
group's expanded appearance is otherwise unreachable from anything that
cannot press the screen.
transcript_ui::row::build_row now returns a TailRow rather than an
Option<RowBlocks>: Blocks for a message (a delta costs the last
markdown block) or Tools for a run (an arriving result costs one card).
One mechanism for "what can this row change cheaply", asked of the row
rather than decided again at each call site. It also takes the row's own
working flag.
Two smaller ones. client_core::tool_summary::parse_tool_input is
ToolInput.kt's subject/description/timeout/rest split, and
client_core::durations::format_millis is Durations.kt's -- both pure,
both with the Kotlin's own tests ported.
2026-09-06: a tap is its own gesture outcome, and opening a URL is a backend capability
Three related additions, all for following a markdown link.
iris::platform::OpenUrl is a new trait beside attr::FocusHost, and
has the same shape: declared in iris, implemented once per backend (a
detached xdg-open/open/start on the desktop, an ACTION_VIEW intent
on Android, deferred to the next view callback exactly the way
pending_show_keyboard is). A widget asks for the capability by bound --
Rsc::State: FocusHost + OpenUrl -- instead of a caller threading a
callback down through every builder. One method, not a general "run an
intent": a narrower capability is a narrower thing to get wrong. Nothing
is returned; the platform either shows a browser or does not, and both
are outside the process.
GestureOutcome::Tapped is new. Released(None) used to mean both
"the press ended having selected something" and "the press ended having
done nothing at all", and only the second is a tap. Any caller that acts
on a tap -- following a link -- must not also act when the finger was
panning the list past that link, so the distinction is made once, in the
gesture machine every widget already shares, rather than timed again per
widget. DragArbiter::is_undecided() is what answers it.
Selection::drag returns the outcome now instead of ().
DragArbiter/DragGesture take an axis (::on(Axis); ::new() is
still vertical). A code fence pans across its own long lines exactly the
way a transcript pans down its rows, and the two were the same state
machine with dx and dy swapped. WidgetLike::scrollable_on(axis)
joins scrollable() for the same reason. Before this, a horizontal
Scroll existed but could not be dragged by a finger at all -- its
arbiter only ever committed on the vertical axis.
Two smaller ones in the same pass. TextEditCtx::byte_at(pos, size)
answers which byte of the text a tap landed on, doing the same
region-relative transform select does, without handing out the parley
layout a caller could shape against stale text. And Rect::radius now
takes a Len, so a corner can be written in dp and come out the same
physical size on every display; a bare number still means physical pixels.
One behaviour change worth knowing about: Rect::is_size_independent()
answers false now. It answered true, and a Rect fills whatever
region it is given -- so draw_inner's fast path, which rewrites a
widget's primitives in place instead of redrawing it, could not reproduce
what draw would have done. A .background(rect(..)) behind
variable-height content kept the size of the provisional pass its parent
Span had drawn it at, which on the transcript screen meant one code
block's panel covering every block below it. Costs one primitive's redraw
when a rect is resized.
2026-09-06: a transcript row is a column of blocks, and a block is the selection unit
transcript-ui's row builder used to make one TextEdit per message.
It makes one per top-level markdown block now -- heading, paragraph,
fenced code, list, table -- in a Span::down, because a streamed delta
into a single buffer re-shaped the whole message through parley on every
event. client_core::markdown_blocks::split_blocks does the splitting;
row::RowBlocks::apply_delta updates the block a delta lands in and
leaves the rest of the message's layout alone.
The change to judge, since it is what a reader feels:
Selection is keyed by SelKey = (RowKey, u32) -- a row and a block --
so a block, not a row, is the unit a selection steps in. A drag still
runs from a reply into the tool output beneath it and copies as one
thing; what changed is that the row under the finger is filled in block by
block rather than all at once, which is if anything closer to what the
old shortcut in Selection's module doc was apologising for. register
takes a SelKey; unregister still takes a RowKey and now drops every
block of it (dropping only the first is how a freed widget gets left in
the map -- the shape a review on 2026-09-06 called out).
Selection::locate(ui, render, pos_window) is new: which block is under a
window position, with that block's own local position and size. The
list-level handler uses it for the pointer-captured half of a drag,
instead of computing a row-local position from List::extent.
row::build_row returns (RowKey, StrongWidget, Option<RowBlocks>) --
the third is the per-block state a caller keeps only for the row a reply
is streaming into, and is None for a tool run, which never streams.
2026-09-06: a reported Size may not carry dp; Len::fold_dp
New: Len::fold_dp(density) -> Len -- the same fold apply_rest does
(dp becomes physical pixels), but staying a Len so rest survives.
New rule, and it is a rule about every widget, not about the two that
broke it: a Len a widget reports from draw must not carry an
unresolved dp. dp is an input unit -- a number the widget author wrote
-- and the containers that consume a reported length read abs, rel and
rest straight off it (Span's placement arithmetic, Pad's addition),
so a reported dp is silently worth zero. MaxSize and Sized both
returned the caller's declared Len as written; a .max_height(dp(168))
therefore gave its child a slot of nothing the moment the cap actually
applied, which is what made the composer's bar collapse. Both put their
declared lengths through fold_dp now, and
UiRenderState::draw_inner debug_assert!s the invariant after every
Widget::draw, so a widget that gets this wrong says so at the mistake
rather than laying out at zero somewhere else.
Nothing changes for a caller: .max_height(dp(48)) is written the same
way. It is only widget authors who now have a rule to follow, and a
debug build that enforces it.
2026-09-06: Painter::set_mask reuses one slot; ActiveData gains two fields
Painter::set_mask(region) allocates its widget's mask slot once and
rewrites it in place on every later draw, instead of pushing a new one
each time. It has to: draw_inner's unchanged-region fast path does not
revisit a descendant whose own region did not change, so those descendants
go on referencing whichever slot they were first drawn under. Pushing a
fresh slot per draw left the composer's field clipped to a box the bar had
long since moved away from -- four live mask entries, none of them the
Masked's current region -- and it drew nothing at all. Same call, same
signature; only the lifetime changed.
ActiveData gains own_mask and move_applied (both public, since
ActiveData is). own_mask is the slot above, MaskIdx::NONE for a
widget that sets no mask. move_applied is how much of a widget's own
move-slot delta its region already accounts for: mov shifts both,
Painter::reposition shifts only the slot, and resolved_region -- and so
every hit test -- has to subtract it. Without that a widget that had been
panned had its own hit box at twice the pan while its descendants were
correct, which made the composer's field untappable after a finger drag.
2026-09-06: Scroll pans on a finger drag, and a vertical drag in a focused text field no longer selects
Three related public changes, all in aid of IRIS_TODO.md's "the composer has no touch-drag scroll".
Scroll::drag(render, id, sense, pos_window, now) is new, and
WidgetLike::scrollable() now registers it alongside the wheel handler it
already registered -- so anything built with .scrollable() pans on a
finger drag with no extra wiring at the call site. It goes through the same
sense::DragGesture that transcript-ui::Selection::drag drives List
with (arbitration, DRAG_SLOP, velocity, pointer capture), rather than a
second copy of that widget's wiring: DragGesture owns the mechanics and
each caller decides only what a committed pan means. Scroll::amt() is
new too, the read-only pan position a test or a scroll indicator needs.
There is deliberately no fling on Scroll. Unlike List it has no
per-frame tick to animate one with (List::set_redraw_handle/tick_fling),
and the areas it wraps today are at most a screenful, where Android does not
fling either. The released velocity is dropped rather than approximated.
A vertical drag inside an already-focused TextEdit no longer extends a
selection. iris::attr's on_press used to treat a focused field as the
plain click_or_drag case -- every Pressing frame updated the selection.
It now applies the same DRAG_SLOP rule the unfocused branch already
applied: a press that moves past the slop vertically abandons its pending
selection for the rest of the gesture, so the scroll area around the field
gets the drag instead. Horizontal drag-to-select is unchanged, and a long
press still starts a selection. This is Android's own EditText behaviour
(a vertical drag scrolls; only a long press selects), and it is what makes
"swipe up over the composer to scroll the transcript" work without dragging
a highlight through the message you were typing.
UiRenderState::orphaned_primitives() is new, and update now
debug_assert!s (debug builds only) that nothing is orphaned. An orphan is
a primitive still bound for the GPU that no live ActiveData names -- a
copy nothing can move, clip or free. That was the doubled Compacted: row
on the phone; see the same date's commit 76b1f99 and docs/RUST.md. The
per-frame guard is a count comparison (O(active widgets)); the walk that
names the offenders only runs when the counts disagree, because the walk is
O(primitives) and made a debug build on a phone too slow to finish a
benchmark run.
2026-09-06: a tap on a text field always leaves a caret
TextEditCtx::select used to compare the tap position against the
laid-out text's own box and set selection = None for anything outside
it. A press only reaches select after being hit-tested to the widget, so
that "outside" meant the field's own padding -- or, for an empty field,
everything, since an empty layout is a zero-width box. So tapping an empty
composer focused it and opened the keyboard while leaving no caret, and
TextEditCtx::insert/insert_str return early with no caret: every
keystroke was dropped in silence, and no glyph ever appeared. Parley's
from_point/extend_to_point already clamp a point outside the layout to
the nearest cursor position, which is also what a tap in a field's padding
should do.
Behaviour change a caller would notice, in one line: select with a
non-drag position now always produces a selection; it no longer clears
one. Clearing is TextEditCtx::deselect, which is what the backends'
focus handling already calls. A drag is unchanged -- with no previous
selection there is still nothing to extend, so it produces none.
insert_str also gained a debug_assert! for the no-caret case, so an
insert routed to an unfocused field fails at the mistake in a debug build
instead of silently swallowing input.
2026-09-06: List::anchor_position_display## 2026-09-06: List::anchor_position_display, FrameReport::mark_phase/phase_stats/late_at_hz (RUST.md's "Benchmark v2")
List gained anchor_position_display(&self) -> String, reporting the
anchor's own row index and pixel offset (idx=N/off=Mpx, or
idx=more-before/idx=more-after/idx=none) -- what a scripted
benchmark reads to report fling travel. Note the anchor does not
necessarily change slot over a long scroll (this widget's own documented
design: the anchor is a stable identity, not re-derived from what's on
screen each frame), so this is not the same measurement as a Compose
LazyListState.firstVisibleItemIndex, which does track the true topmost
visible row -- the off half is what actually reflects how far a fling
travelled.
iris_core::render::frame_report::FrameReport gained three methods for
per-phase benchmark reporting: mark_phase(name) records a named phase
boundary at the current frame/instant; phase_stats(now, refresh_hz)
returns one PhaseStats (frames, wall duration, late count/percent,
p50/p90/p99, worst) per marked phase, sliced from the existing ring by a
new parallel index_ring; late_at_hz(refresh_hz) gives the whole run's
late count/percent judged against an arbitrary refresh rate rather than
the fixed 60Hz JANK_THRESHOLD every existing caller still uses (a
separate method, not a parameter on report(), so nothing else changes
behaviour). RING_CAPACITY grew 4096->16384 to hold a full multi-phase
run without evicting earlier phases' samples.
2026-09-06: List::fling, VelocityTracker, FlingCalculator (IRIS_TODO.md's "swiping has no momentum")
iris::widget::List gained a real fling: fling(velocity_px_per_s) starts
one (cancelled by the next touch-down via cancel_fling, or automatically
once it settles or reaches loaded content's start/end), is_scrolling()
reports whether one is running, and tick_fling(now: Instant) -> bool
advances it and returns whether it is still going -- a caller that owns a
RequestRedraw handle can hand it to the list once via the new
set_redraw_handle, after which List re-arms its own next frame while
flinging with no further polling needed; a caller driving a scripted
benchmark instead calls tick_fling itself in a loop, same as it already
drives scroll.
The physics is iris::sense::FlingCalculator + VelocityTracker
(sense.rs, beside DragArbiter): a port of AOSP SplineOverScroller's
deceleration curve (the same one Compose's own ScrollableDefaults. flingBehavior() uses), cited at the definition, so a fling here travels
the same distance a Compose LazyColumn would for the same initial
velocity. VelocityTracker estimates that velocity from the drag's last
~100ms of samples rather than one frame's last delta. Unit-tested:
velocity from known samples, fling distance/duration against the closed-
form spline result (within 1%), cancel-on-touch, and the start/end clamp
(a fling stops rather than scrolling into content that was never loaded).
Before: a touch-drag panned exactly as far as the finger moved and stopped
dead on release. After: releasing mid-drag continues scrolling and
decelerates, matching the muscle memory every other Android scroll view
already trained. transcript_ui::selection::Selection::drag wires this in
-- a release only flings if the gesture had committed to panning
(DragArbiter::is_panning, new), never a selection or an undecided tap.
2026-09-06: UiRenderNode::new returns Result, not Self (RUST.md's P0 box, phone-crash fix)
iris_core::UiRenderNode::new(device, queue, config) now returns
Result<Self, String> instead of Self. Why: it used to let a bind-group-
layout validation failure reach wgpu's default error handler, which panics
with no way for a caller to intervene -- exactly what aborted the P0 bench
APK on Iris's phone with the crash report truncated to "wgpu error:
Validation Error" and nothing else recoverable. It now runs its creation
calls inside wgpu error scopes and returns the full error text (wgpu's own
"Caused by" chain) as Err instead.
Both callers changed to match: android::render::AndroidRenderer::new
itself now returns Result<Self, String> too, building a fuller report
(adapter identity, the limits/downlevel flags a layout validates against,
then wgpu's text) on failure -- its caller,
android::view::IrisViewPeer::surface_changed, logs that report as one
logcat line and shows it on screen (a new IrisView.showRendererError,
called via an ordinary JNI method call rather than a new native fn)
instead of letting the process abort. default::render::UiRenderer::new
(the winit/desktop backend) still panics on failure -- there is no
on-screen fallback there -- but the panic message is now the same full
text rather than whatever wgpu's own handler would have printed.
No change for an app that never constructs a UiRenderNode directly (every
current one goes through AndroidRenderer/UiRenderer), but anyone who
does needs an ?/.expect()/match at the call site now. Full audit and
the named hypothesis for what actually failed on the phone are in
RUST.md's P0 box, "iris bench crash on the phone, 2026-09-06."
2026-09-05: AndroidAppState::platform_ready (RUST.md's P0 box, iris half)
Added a second, optional lifecycle method to iris::android::AndroidAppState
(iris/src/android/view.rs), called once from new_peer right after new:
fn platform_ready(&mut self, rsc: &mut AndroidRsc<Self>, vm: JavaVM, view: GlobalRef) {}
Default does nothing, so every existing implementor (Client,
TranscriptClient) is unaffected. It exists for a caller that needs to call
into Java itself beyond what a RequestRedraw handle already covers --
P0's bench build (iris-android-app's new bench feature,
bench_client.rs/bench_jni.rs) uses it to hold a JavaVM + GlobalRef
to the view so its "Copy report" control and once-a-second battery sampler
can call BatteryManager/ClipboardManager through the view's own
Context, from a background tokio task as well as the UI thread. new
itself was not extended with these two parameters: most implementors need
nothing here, and new's job is building the widget tree, not holding a
platform handle. vm/view are independent handles from the ones
new_peer keeps for its own RequestRedraw (a fresh get_java_vm/
new_global_ref each), so storing them has no effect on that mechanism.
2026-09-05 (later still): iris_core::device_limits(), and iris no longer requests compute-shader limits
New public function, iris_core::device_limits() -> wgpu::Limits. Why:
adapter.request_device's required_limits was Limits::default() plus
a max_buffer_size override in both platform backends, and
Limits::default() requests desktop-tier compute-shader limits
unconditionally (max_compute_workgroups_per_dimension: 65535) even
though nothing in iris/iris-core uses a ComputePipeline — that
crashed device creation outright on a downlevel GL adapter reporting
OpenGL ES 3.0 (no compute shaders at all: the Android emulator's
EMU_GPU=software path, and any real GLES-3.0-only Android device).
device_limits() is what both android::render::AndroidRenderer::new
and default::render::UiRenderer::new now build their required_limits
from, so the request cannot drift between the two backends.
Before: Limits { max_buffer_size: 1 << 30, ..Default::default() }
inlined in each backend. After: iris_core::device_limits(), which is
the same thing with the six max_compute_* fields additionally zeroed.
A caller building its own DeviceDescriptor outside these two backends
(there are none today, but a third platform backend would want this)
should call device_limits() rather than reaching for
Limits::default() directly, unless it genuinely adds a compute pass —
in which case it wants the specific compute limits that pass needs, not
the desktop-tier default for everything.
2026-09-05 (later the same day): iris_core::FrameReport (RUST.md's I5 box)
New public type, iris_core::FrameReport (re-exported from iris_core's
render module alongside FrameStats and JANK_THRESHOLD). Why: dumpsys gfxinfo cannot see a SurfaceView's own GPU-drawn frames at all, so a
wgpu-rendered iris screen had no way to ask "was this smooth" the way
Compose's own in-app render report already can -- item 3 of RUST.md's
recommendation was stuck on a one-sided number for exactly this reason.
FrameReport::record(elapsed: Duration) is called once per frame (wired
into android/view.rs's render(), wrapping the same span from redraw
start to after queue.submit+present() that Compose's report and
gfxinfo both count) and writes into a fixed 4096-entry ring -- no
allocation on the hot path. FrameReport::report() -> Option<FrameStats>
gives total frames, janky % (over JANK_THRESHOLD, the same 16.7ms 60Hz
budget gfxinfo uses), P50/P90/P99 and the worst; None if nothing has
been recorded since the last reset(), not a zeroed report that would
read as a real measurement. FrameStats's Display line says plainly
that it measures up to present() being called, not GPU/compositor
completion, since wgpu's present() isn't fenced against either.
AndroidUiState gained a pub frame_report: FrameReport field --
anything with HasAndroidUiState can now read or reset it. Before this,
there was no way to ask iris's own render path how long a frame took at
all, on any backend.
Before/after, for a caller that already has ui_state: &AndroidUiState:
// before: no such question could be asked
// after:
match ui_state.frame_report.report() {
Some(stats) => log::info!("iris frame report: {stats}"),
None => log::info!("iris frame report: no frames recorded yet"),
}
ui_state.frame_report.reset(); // via android_state_mut()
iris-android-app's transcript screen exposes this as two named,
tappable controls ("Frame report", "Reset frame report") rather than
requiring a caller to wire its own UI -- see transcript_client.rs's
frame_report_controls.
2026-09-05: Tasks::redraw_handle (RUST.md's I5 Android integration)
New public method on iris::task::Tasks, redraw_handle(&self) -> Arc<dyn RequestRedraw>. Why: a caller running its own long-lived loop
inside one spawned task (a live SSE follow, the Android transcript
client's select_session) has no other way to ask for a frame after each
TaskCtx::update -- Tasks::spawn's own wrapper only requests one, after
the whole async closure finishes, which fits a single request-then-update
but not a stream that needs to be seen redrawing after each event. This
is the same gap iris/desktop-app's module doc names for why it uses
winit's Proxy<AppEvent> instead of Tasks -- android-view has no
Proxy, so this is what closes it there.
A real bug this uncovered, not a hypothetical: calling the returned
handle's request_redraw() from the background thread crashed the process
(SIGABRT, Result::unwrap() on an Err value: JavaException) the first
time an Android transcript fetch called it a second time. android/render.rs's
AndroidRedrawHandle was already attaching the calling thread to the JVM
correctly, but its request_redraw called View::post_frame_callback,
whose Java side calls Choreographer.getInstance() -- which throws unless
the calling thread already has a Looper, and a tokio worker thread,
even freshly JNI-attached, has none. Fixed by routing through
View::post_delayed(0) instead (Android's own thread-safe "queue work onto
this View's UI thread" primitive, needing no caller-side Looper), landing
on a new IrisViewPeer::delayed_callback override that drains tasks and
renders -- same body as do_frame, on the UI thread where
post_frame_callback is safe again. Any future caller of redraw_handle()
from a background thread gets this for free; nothing about the fix is
specific to the transcript screen.
2026-09-05: transcript_ui::build_tree (RUST.md's E4)
transcript_ui::build claimed the whole window (ui_state.set_root(tree))
as its last step, which is right for a window that is the transcript
screen (the winit example, an eventual Android cdylib) and wrong for the
desktop app, which puts a session list beside it. build_tree is build
minus that last step: it returns (TranscriptScreen, StrongWidget) instead
of just TranscriptScreen, and the caller decides where the tree goes —
into ui_state.set_root, or into a WidgetPtr alongside something else
(iris/desktop-app's rebuild_transcript). build is now one line calling
build_tree and doing the set_root itself, so existing callers are
unaffected.
// before, and still available, for a caller that wants to *be* the window:
let screen = transcript_ui::build(rsc, &mut ui_state, rows);
// new, for a caller embedding the screen beside something else:
let (screen, tree) = transcript_ui::build_tree(rsc, rows);
some_widget_ptr(rsc).set(tree);
2026-09-05: DragArbiter, pan-vs-select for one shared touch gesture (RUST.md's I5)
New public type, iris::sense::DragArbiter. Why: a widget author who
registers both a list-level pan and a row-level drag-to-select on the same
touch gesture has no way to arbitrate between them — core/src/sense.rs's
run_sensors always gives the innermost layer first refusal, so the inner
one wins every frame it is pressed, not just the frame the press started
(this is exactly what left transcript-ui's touch-drag panning unreachable
until now). DragArbiter is one small state machine, one instance per
gesture surface (a whole list, not per row), that a caller drives with its
own press_start/update/release calls and a caller-supplied Instant
(so it is unit-testable without a real clock or a render harness). It
decides the way Android itself does: an ordinary vertical drag pans
immediately; a stationary press held LONG_PRESS (500ms) starts a
selection, which any further drag then extends; a horizontal drag while
something is already selected extends it immediately, skipping the wait.
// One per list, held alongside whatever state coordinates the rows:
let mut arbiter = DragArbiter::new();
// On press-down:
arbiter.press_start(pos, Instant::now(), already_selected);
// Every frame the button/finger stays down:
match arbiter.update(pos, Instant::now()) {
DragOutcome::Pan(dy) => list.scroll(-dy),
DragOutcome::SelectStart => selection.begin(...),
DragOutcome::SelectExtend => selection.extend(...),
DragOutcome::Undecided => {}
}
// On release:
arbiter.release();
transcript-ui's Selection::drag (transcript-ui/src/selection.rs) is
the reference caller: every row's CursorSense::click_or_drag() | CursorSense::unclick() handler routes through one Selection-owned
arbiter instead of calling begin/extend directly, so a drag that starts
on a row's own rendered text now pans the list correctly instead of
always starting a selection. 8 new unit tests in iris/src/sense.rs's
drag_arbiter_tests module.
2026-09-05, later: DragArbiter::is_idle(), recovering a missed press_start
Follow-up to the above, from a real touch-scroll dropout: a gesture's
ACTION_DOWN can land on a caller's own dead space (a row's padding, a
gap, a header with no handler) that never calls press_start, so the
first frame the arbiter actually sees is a Pressing-shaped update
with no matching start. Before this, update's Idle arm had no way to
tell that apart from "nothing is happening" and answered Undecided
forever for the rest of that gesture. is_idle(&self) -> bool lets a
caller notice the gap and recover: if is_idle() is true on a frame the
caller knows a press is genuinely down (its own Pressing/equivalent
sense fired), call press_start right there instead of assuming one
already happened. transcript-ui's Selection::drag is the reference
caller — one new match arm, checked before the ordinary update-only
case. Any other DragArbiter caller with the same "one sensor per
sub-region, no fallback for dead space" shape has the same gap and wants
the same recovery.
2026-09-05: SpanStyle, per-range text styling (RUST.md's I5)
A TextBuffer used to have exactly one style (TextAttrs: colour, size,
family, ...) for its whole string, applied via push_default into parley's
ranged builder. SpanStyle is a second, optional layer: a byte range plus
whichever of colour/family/font size/bold/italic/underline it overrides,
pushed with parley's own push(property, range) instead. Why: a transcript
row's markdown (a heading, bold, inline code, a link) all inside one
wrapped paragraph needs each to carry its own look while the paragraph
still wraps and selects as a single buffer — the thing masonry's
TextArea cannot do (StyleSet is one style for the whole editor,
text_area.rs:43-44's // TODO: RichTextInput), and the reason this
existed at all.
let (text, spans) = transcript_ui::markdown::render_markdown(src, 16.0);
wtext(text)
.spans(spans) // new: TextBuilder::spans, on both Text and TextEdit
.editable(EditMode::MultiLine)
.add(rsc);
Two things a widget author should know before reaching for it:
- Call
.spans()before or after.editable(), both work — the field lives onTextBuilderitself, not either output type, and bothTextOutput::runandTextEditOutput::runapply it to the buffer viaTextBuffer::set_spans. These two call sites are a pair: adding a thirdTextBuilderOutputimpl without also callingset_spansthere reproduces the exact bug this box shipped once already (spans silently dropped forTextEdit, found only by screenshotting, not by any test —markdown.rs's own unit tests check string/range logic, which is correct in isolation and proves nothing about whether the render path ever sees it). - Colour is now per-glyph, not per-buffer.
PlacedGlyphgained acolor: UiColorfield (from parley's own per-runStyle::brush), andPainter::glyphsdraws each glyph in its own colour instead ofRenderedText::coloruniformly.RenderedText::colorstill exists (the buffer's base colour, for a caller that wants it as a whole, e.g. to tint a cursor) but no longer drives what a glyph actually renders as.
2026-09-05: accessibility names via AccessKit (RUST.md's I4)
.label() (already in trait_fns.rs, previously unused anywhere in-tree)
is now load-bearing: it's the one thing that puts a widget in the AccessKit
tree iris_core::ui::access::AccessTree builds and both backends push
out. A widget author who wants a control to be findable by name (and
tappable by name, through ui-trace/a real screen reader) calls .label()
on it; nothing else is required, and a widget nobody labels is invisible
to this system at zero cost, not just zero UI.
let button = rect(Color::LIME)
.on(CursorSense::click(), move |_, rsc| { ... })
.label("Add task"); // now findable by uiautomator/AccessKit as "Add task"
Two new things a widget author might touch directly:
Widget::access_role(&self) -> accesskit::Role, defaultUnknown. Override it if your widget has a real platform equivalent —TextEditnow returnsTextInput/MultilineTextInputbyEditMode. Only consulted for a widget that also has a.label(); an unlabelled widget'saccess_roleis never called.Widgets::named() -> impl Iterator<Item = WidgetId>— every widget with an explicit label, for anything else that wants to walk the same setAccessTreedoes.
Nothing about Painter, draw, or the layout/move machinery changed —
this sits entirely beside them, reading resolved_region's output rather
than participating in producing it.
2026-09-05: List, a virtualised bottom-anchored list (RUST.md's I3)
A new widget, iris::widget::List (iris/src/widget/list.rs -- read its
module doc first), for the transcript's kind of screen: variable-height
rows, keyed by a u64, composed only while visible, moved rather than
re-laid-out on scroll, a scroll anchor that survives a row inserted above
it, "more" sentinels at each end, and "hold the edge nearest the tap" when
a row's height changes (note_tap, resolved in the layout pass).
let mut list = List::new(Axis::Y);
list.push_back(ListRow::new(key, row_widget)); // O(1)
list.push_front(ListRow::new(older_key, row)); // O(1), anchor unaffected
list.set_more_before(Some(spinner_widget)); // sentinel, drawn at the edge
list.note_tap(viewport_y); // before mutating a row's height
let (top, bottom) = list.extent(key).unwrap(); // last frame's on-screen box, if visible
Built entirely out of existing primitives (Painter::widget/widget_within/
reposition/draw_twice, and draw_inner's own old-children diffing) --
no new mechanism was added to the render core for it. One correctness
lesson worth reading even for other widgets: a row that fills whatever
region it is offered (Rect, is_size_independent) cannot be measured at
a throwaway oversized region and then merely repositioned into place --
reposition only ever writes an offset, never a size, so the oversized
primitive stays oversized. List fixes this by caching each row's real
height once measured and placing an already-known row directly at its
exact box; see list.rs's place for the full reasoning and
a_fill_shaped_background_is_not_left_oversized for the regression test.
2026-09-05: a second backend (android-view), and what moved to make room for it
RUST.md's I2. Three changes a widget or app author would notice, all in
service of the same thing: default (winit) and the new android
(android-view) backends sharing what does not depend on windowing.
Selector/Selectable's bound changed fromRsc::State: HasDefaultUiStatetoRsc::State: FocusHost(new trait,attr.rs).HasDefaultUiStatestill exists and still works —default/attr.rsnow implementsFocusHostfor anything that has it — so a winit app's existing code is unaffected. An Android app implementsFocusHostviaHasAndroidUiStateinstead. Affects only an app that referencedHasDefaultUiStatedirectly at aSelectable/Selectorcall site rather than through.attr::<Selectable>(()), which nothing in-tree does.Tasks::inittakesArc<dyn RequestRedraw>instead ofArc<winit::window::Window>.RequestRedraw(task.rs) is one method,fn request_redraw(&self);winit::window::Windowimplements it (default/render.rs), soTasks::init(window)at a call site is unchanged by inference. Only matters if something constructed aTasksdirectly rather than throughDefaultRsc/AndroidRsc.TextEdit::apply_event/TextInputResultare#[cfg(not(target_os = "android"))]— they take awinit::event::KeyEvent, which does not exist on Android;android/input.rsdrives the same primitives (backspace/delete/motion/insert, all still unconditional) fromndk::event::Keycodedirectly instead. New unconditional getters on the way:TextEdit::text()/selection_range()/caret(), andTextEditCtx::delete_byte_range/set_cursor_byte— the primitivesandroid/ime.rs'sInputConnectionbridge needed and that were not previously exposed publicly.
2026-09-04: Widget::draw reports the size it used; desired_width/desired_height are gone
A widget used to implement three methods (draw, desired_width,
desired_height); it now implements one, fn draw(&mut self, painter: &mut Painter) -> Size, which draws into painter.region() and returns how much
of it was used. Why: the two extra methods routinely re-simulated what
draw was about to do anyway (Span::desired_ortho copied its own draw
loop to get cross-axis sizing right) — one visit per widget per frame
instead of up to three. A container that needs a child's size before
placing it (alignment, centering) draws the child once at a provisional
region, reads the returned Size, and calls the new Painter::reposition
to move it into its final spot — an O(1) offset write, not a second draw. A
widget whose drawn output never depends on the size it's given (a
fixed-size Rect, a decoded Image) overrides the new fn is_size_independent(&self) -> bool { false } to true, which skips
redrawing it when only its offered region changes shape.
// before
fn draw(&mut self, painter: &mut Painter) { /* ... */ }
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
// after
fn draw(&mut self, painter: &mut Painter) -> Size { /* ... */ }
SizeCtx and Cache are gone with it — see LAYOUT.md for the full
design, the move-offset mechanism this shipped alongside, and the file
list.
2026-09-04: texture pipeline rebuilt off the binding array
Textures/TextureHandle, GlyphPrimitive, and UiRenderNode::new all
changed shape. Why: the old pipeline bound every texture ever drawn in one
binding_array<texture_2d<f32>> and asked every device, unconditionally,
for VK_EXT_descriptor_indexing — a real share of Android GPUs lack it,
and it failed outright on the Android emulator's software Vulkan. See
TEXTURES.md's "Recommended shape" and "Implemented, 2026-09-04".
UiRenderNode::newdrops itslimits: UiLimitsparameter, andUiLimitsis gone. Before:UiRenderNode::new(&device, &queue, &config, UiLimits::default()). After:UiRenderNode::new(&device, &queue, &config). Nothing replaces it — there are no more binding-array limits to size.src/default/render.rs's device request asks for no features and no binding-array limits. Before:required_features: Features::TEXTURE_BINDING_ARRAY | Features::PARTIALLY_BOUND_BINDING_ARRAY | Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXINGplus twomax_binding_array_*limits. After:Features::empty()(theDeviceDescriptordefault) and onlymax_buffer_sizeset, which was never about the binding array.TextureHandlehas noprimitive()method any more; a caller outsideirisshouldn't have been calling it (it fed the old renderer's internals), but if something did: useimage_index()for a standalone image's bind-group index. There is no equivalent for a page — a page has no bind group of its own now, see below.GlyphPrimitivehas no public constructor from a struct literal. Before:GlyphPrimitive { uv_min, uv_max, view_idx, sampler_idx, color, flags }. After:GlyphPrimitive::new(uv_min, uv_max, layer, color, flags)— onelayer(the shared atlas array's layer) instead of aview_idx/sampler_idxpair, since a page is now a layer of one array texture rather than its own bound texture.- A widget author drawing images is unaffected:
Painter::texture/texture_at/texture_withinandTextures::addkeep their signatures. What changed underneath is that each standalone image now gets its ownwgpu::BindGroupand draw call instead of a slot in the shared array — invisible from the widget API, visible only inUiRenderNode's internals and iniris's device requirements.
2026-09-05: FrameReport splits each frame at queue.submit
FrameStats gains two fields, and FrameReport gains a second recording
method, to answer "is a slow frame iris's own CPU work or the driver/GPU"
with a number instead of a guess (RUST.md's I5 box).
FrameReport::record_split(total, submit_to_present)is a second way to record a frame, alongside the existingrecord(total)(unchanged, and still what a caller with no split should use — it now reads ascpu_p50 == total,gpu_wait_p50 == 0, rather than fabricating a number for a half it never measured).FrameStatsgainscpu_p50andgpu_wait_p50: medians of redraw-start-to-submit and submit-to-after-present()respectively, independent of each other and of the existingp50/p90/p99/worst(which are unchanged, and still over the whole frame). The Android renderer'sdraw()now returns thesubmit_to_presentDurationit measured, whichandroid::view::render()passes torecord_split.- Caveat carried in both doc comments:
submit_to_presentis not fenced against the GPU actually finishing — it is "how long the CPU was blocked handing the frame to the driver," not a confirmed GPU-completion time. Enough to separate "iris is slow building the frame" from "iris is slow handing it off," not enough to claim an exact GPU budget.
2026-09-05: List::replace_back/List::clear, and TranscriptScreen::apply
Fixes the "every client refolds and rebuilds the whole widget tree per
streamed event" cost RUST.md's P0 box measured (20 events/second against a
~3,200-row transcript). Two small additions to iris::widget::List
(iris/src/widget/list.rs), plus one new method on transcript-ui's
TranscriptScreen.
List::replace_back(row: ListRow) -> Option<ListRow>: swaps the last row's widget for a new one without moving it — same slot index, so an anchor already pinned there (in particular a list flush with its own end) stays pinned, and aListscrolled elsewhere is untouched.Noneif the list is empty.RowKeymay differ between the old and new row; onlyheights/extentscare, and both are invalidated for the evicted key the same waypop_backalready does.List::clear(): drops every loaded row and resets toList::new's state (more_before/more_afteruntouched — a caller that wants those cleared too callsset_more_before(None)/set_more_after(None)itself). The fallback path for a change that touches more than the tail.transcript_ui::TranscriptScreen::apply(&self, rsc, old: &[TranscriptItem], new: &[TranscriptItem]): the incremental alternative to rebuilding the whole screen fromtranscript_ui::build_treeon every folded event. Diffs the twogroup_tool_runsoutputs and picks the cheapest update: nothing changed (no-op), a pure append (push_row, unchanged cost), or — the common streaming case, a delta into a still-open assistant message — a rebuild of just the one changed row viaList::replace_back, with any further new rows appended after it. A row changing before the tail (onlygroup_tool_runsretroactively grouping tool calls into a run does this) falls back toList::clearplus a full rebuild, counted inTranscriptScreen::take_rebuilds(). A caller that keeps its own row-keyed side table alongsideList(Selection'srows: BTreeMap<RowKey, WeakWidget<TextEdit>>is the one this crate has) must clear it in step withList::clear()— the fallback drops every rowListwas holding, so any side table not cleared the same way is left pointing at widgets the clear just freed (a review on 2026-09-06 finding 1, fixed 2026-09-06 bySelection::clear(), called fromapply'sRebuildarm right beforeList::clear()).bench_client.rs,transcript_client.rsanddesktop-app/app.rsall call this now instead of rebuilding on every event; only the opening page (andapply's own fallback) still callsbuild_tree.TextEditCtx::set_with_spans(text, spans):set()plus a freshVec<SpanStyle>in one call, needed because a streamed row's markdown re-renders to both a new string and a new span list on every delta and the two have to land together — a stale span list drawn against new text can point past its end.set()itself is unchanged (still clears spans to none, as before).
Measured on this checkout's emulator (iris/android-app/run-bench.sh,
release, x86_64, force-gles): worst-frame and p99 during the streaming
phase dropped from 369.3ms/284.5ms (full rebuild per event, prior pass) to
~101–130ms/~76–103ms across three runs (this fix) — see RUST.md's P0 box
for the full numbers and the comparison's caveats (different AVD
instances, not a controlled A/B on identical hardware state).
2026-09-06: bundled fonts, content_scale, AndroidAppState::on_insets_changed
From RUST.md's P0 box, working Iris's first real-phone report (font/scale/ inset bugs the emulator never showed).
TextDatanow bundles Noto Sans + Noto Sans Mono (regular/bold/ italic/bold-italic static faces, OFL) and registers them ahead of the platform's own fonts in theSansSerif/Monospacegeneric-family lists, rather than relying on the platform's font enumeration alone.TextData::font_diagnostics() -> FontDiagnosticsreports what was found and what each style axis resolved to — logged once at startup and shown on a screen's Diagnostics page if it has one. Adds ~3.6 MB uncompressed to any binary linkingiris-core;build-apk.sh's own output says the delivered (compressed) number.UiRenderNode::new/resizenow take the window size explicitly (window_size: impl Into<Vec2>) instead of deriving it from the surface's physicalSurfaceConfiguration. Existing callers pass a logical size (physical ÷ density/scale-factor) now; this is what makes afont_size: 16.016 dp instead of 16 raw device pixels on a high-density phone. Before this,scale_factordid not exist anywhere in the crate, on either platform.AndroidUiState::content_scale: f32(DisplayMetrics.density, read once innew_peer) and the desktop equivalent (window.scale_factor()) now divide every physical-pixel number before it reaches layout or touch handling — seecontent_scale's own field doc for the full list of what depends on it.- New:
AndroidAppState::on_insets_changed(&mut self, rsc, LogicalInsets), a default-no-op hook called fromrender()exactly whenAndroidUiState::insets()changes. Nothing previously consumedinsets().topat all; a screen with chrome under the status bar implements this to pad it, in the same logical unitscontent_scaleconverts everything else to. - New:
iris_core::WgpuErrorLog, installed viaDevice:: on_uncaptured_erroron the Android device (wgpu's default handler is an unconditional panic outsideUiRenderNode::new's own error scopes). ExplicitArc-backed value passed to the callback and kept onAndroidRenderer, not a global — a caller wanting one on desktop builds its own the same way.
2026-09-06: Len::dp, physical pixels throughout, the keyboard glyph wipe
Iris's phone report on build a9232ac (screenshots): text now the right size but blurry; the keyboard still wipes every glyph; the header buttons have nothing behind them. All three are fixed; this entry is the public API side. docs/LAYOUT.md has the layout-side writeup, docs/RUST.md's P0 box has the full investigation and the phone verification still to do.
- The keyboard wipe was
surface_changedrebuilding the whole renderer on every resize, including an IME-driven one — a fresh, empty glyph atlas while the CPU-side glyph cache kept UV coordinates from the old one.surface_changednow callsAndroidRenderer::resize(reconfigures the surface and window uniform only) when a renderer is already live, and only builds a new one when there genuinely isn't one yet. Lenhas a third field,dp(Android's dp / CSS's reference pixel, 1/160in), beside the existingabs(now explicitly physical pixels) andrel/rest.len_fns::dp/Len::dpconstruct one, used exactly likeabs/rel/rest—dp(16)instead of a bare16wherever a size should look the same physical size on any density. This is the unit IRIS_TODO.md's "density-independent length unit" item asked for; it replaces the previous stopgap (the whole rendered scene divided bycontent_scalethen implicitly stretched back up), which is also what made text blurry — a glyph rasterised at the small, pre-stretch size and then upscaled onto the real framebuffer.UiRenderState/Paintergaineddensity()/set_density()(physical pixels per dp). Every place a length resolves (Len::apply_rest,Size::to_uivec2) now takes it;Span::gapandPadding's four sides moved from a baref32toLenso they takedp(...)too. A bare number anywhere is unaffected — stillabs, physical pixels.- Text is rasterised at physical resolution now.
TextBuffer::shapetakesdensityand multipliesfont_size/line_height(and any span override) by it before handing them to parley, so the atlas holds a bitmap at the size it is actually shown at rather than a low-resolution one stretched afterward. - Everything at the Android boundary is physical pixels now — window
size, touch coordinates, insets (
LogicalInsetsrenamedWindowInsets). The previous "logical" division bycontent_scaleis gone;content_scalenow feedsset_densityinstead. - Not yet verified on Iris's actual phone (this pass had no device) — built and checked on this checkout's emulator only. RUST.md's P0 box says what she should check for: crisp text at two densities, the keyboard no longer wiping, and the header's background.
2026-09-06: composing text, focus-on-tap, and atlas invalidation on a new renderer
Three small but public API changes, from the same phone-report pass as the entry above (RUST.md's P0 box has the full account, including a real bug still not root-caused).
FocusHostgainedis_focused(&self, id) -> bool(both platform impls).attr.rs'sSelector/Selectableused to grant focus (and so request the IME) on the very first frame of any press, before it was known whether the gesture was a tap or a drag — a swipe over a text field wrongly summoned the keyboard. They now wait for a completed tap (press and release with no frame crossingsense::DRAG_SLOP) unless the field is already focused, in which case dragging inside it to select text is unchanged.TextEditgained one newpub(crate)field (press_origin) to track this; no public surface change there.android::ime'sInputConnectionnow callsInputMethodManager:: updateSelectionafter every edit (IrisViewPeer::update_ime_selection, called fromafter_input). Gboard was holding keystrokes back because nothing ever told it where the app's own selection/composing region had moved to — this is what android-view's own demo does in itsrender()and this bridge never did.GlyphAtlas::clear()andTextures::reset()(iris_core). Called together, once, fromandroid::view'ssurface_changedexactly when a genuinely newAndroidRendereris built (backgrounding and returning, not a keyboard-triggered resize, which already reuses the renderer) — both CPU-side caches otherwise kept pointing at the old, now-destroyed device's textures, which is why text used to vanish again after leaving and returning to the app.
2026-09-06: take_counters counts text layouts too
One public API change, from the verification pass over the composer-scroll and per-block-row work (RUST.md's "Verification pass over Tasks A and B").
-
UiRenderState::take_countersreturns four numbers, not three:(draws, region rewrites, move writes, **text shapes**). The new one is bumped inPainter::render_text, whichTextView::renderonly reaches on a cache miss, so it counts layouts actually computed rather than layouts asked for. Callers destructuring the tuple need one more_.It exists because a draw counter cannot answer the question the per-block transcript row was built for. A widget can be redrawn without re-shaping (the layout is memoized by width) and re-shaped without any extra draw, and re-shaping is the expensive half — so "a streamed delta costs one block" was, until now, argued from the code rather than measured. With the counter it is a test: one delta into a 100-paragraph reply shapes exactly 1 text layout, the same as into a one-paragraph one.
2026-09-07: iris::diagnostics -- a trace toggle for input/frame lines, gating four existing per-frame debug! calls
One new public module and one behaviour change to four existing log lines, from Iris's "add another button to copy input event info ... instrument a lot of the code with timings" request (RUST.md's own section has the full account).
iris::diagnostics::set_trace(bool)/trace_enabled() -> bool, a process-global switch, off by default. It gates two new diagnostics (sense::log_input_event, one line per platform pointer sample under targetiris::input;diagnostics::log_frame, one line per frame underiris::frame, with the frame number, the frame clock, time since the last input, layout/draw durations,RedrawKind, primitives on screen, and whether something is animating) and, as of a same-day review finding (D1), four olderdebug!lines that were previously unconditional:android::view's tworender():lines,widget:: list'siris fling tick:,widget::text'siris text render:, andsense'siris drag release samples:. Notlog::log_enabled!/log::set_max_level, because the app installs its logger atLevelFilter::Debugalready and the ring records everything that level lets through regardless of target — the gate has to live on this side. Not wired to a control: the Diagnostics pane is inbench_client.rs, off-limits while another agent had it open; this is the whole surface a button needs.UiRenderStategainedRedrawKind,frame_number(),epoch(),last_layout_duration(),last_redraw_kind(),active_primitive_count(),note_input(Instant)andtime_since_input(Instant) -> Option<Duration>(iris-core). All read back bylog_frame;note_inputis called once fromSensorUi::run_sensors, which both backends and the harness already share, so a frame'ssince_inputis comparable across all three without either platform doing its own bookkeeping.iris::harness::TouchActiongainedword() -> &'static str, the inverse of its ownparse-- what a caller (here,Harness::touch) hands the input logger so a.touchfile and aniris::inputline agree on one spelling of each action.iris_core::AxisgainedDebug— a one-line derive, needed to log which axis a drag committed to.iris/benches/report_to_touch.py(new): turns a report'siris::inputlines back into a.touchfile, expanding inline historical samples into their own lines first. Round-tripped against the harness iniris/transcript-fixture/tests/input_log_roundtrip.rs.
2026-09-07: the phone app is told which server to talk to, and pins from the link
Not an iris API change -- a client-facing one, in the crates around it, worth knowing because it changes what a build of the Android app is.
- An iris APK is no longer tied to the machine that compiled it. It
used to have the server's host, port, token and CA compiled in, which
made a build good for exactly one emulator/server pair and put a token
in the artifact. Now it registers
aiapp://enrolllike the Compose app: open the link (Dev Updater's Enroll button already offers it, and the phone asks which app should take it) and the app stores where to go and what to trust. - The CA rides in the link as
&ca=<base64url DER>, which is what makes the above possible at all -- a pinned certificate cannot be baked into an APK cross-compiled somewhere else. Optional, so the projects that do build on their own machine keep the short link and the small QR. docs/DECISIONS.md, 2026-09-07, has why not a fingerprint. client_core::confignow holds the storage as well as the parsing:EnrolledServergained an optionalca_pem, andEnrollmentStore(the 0600 JSON file, moved out ofdesktop-app) is one implementation for both the desktop and the phone -- only the directory differs.desktop-app --cais now the override for a link that carried no CA rather than a required flag.
2026-09-08: a new GPU device re-uploads its textures instead of forgetting them, and a mark is one texture per shape
Two defects with one cause: widget::mark built a texture per widget,
so a transcript screen had one 48x48 standalone image, one bind group and
one draw call per folded card rather than one per picture -- and the
Android surface-rebuild path assumed no long-lived widget held a texture
handle at all.
Textures::resetis gone;Textures::reuploadreplaces it. A new GPU device holds none of the old one's textures, but this side still holds their pixels, so the answer is to queue every slot for upload again in slot order (empty slots included, asPushFree, so the indices after a hole still land where they were) rather than to throw the slot numbering away. Resetting left every liveTextureHandlenaming a slot nothing recognised: the first frame after the emulator's Vulkan-to-GLES fallback panicked with "texture slot 89 is not a live standalone image: None", before anything had been touched.- The glyph atlas is no longer cleared on that path either, which falls out of the same change: its pages are slots here and their pixels are on this side, so re-uploading restores exactly the atlas that was there. An app switch no longer re-rasterises every glyph on screen.
Textures::shared(key, make)(new): the one texture for a description, built on the first ask and handed out again after, keyed by aSharedTextureKey { owner, id }the caller packs exactly rather than hashes. The map holds its own reference, so a shared slot is never freed and never recycled under a widget still drawing it.mark()is its first caller: three marks now exist for the whole transcript screen (open, closed, collapse) instead of one per card, and the rasterising is paid once.
2026-09-08: an app's own log survives the process that wrote it
devlog's provider could only ever show the run that was still up. After
a crash, Dev Updater's query starts the app process for the provider
alone -- no activity runs, so MainActivity.nativeSetFilesDir never
fired and the panic hook's file was never replayed. The Runtime tab
therefore showed one line, iris devlog: serving this app's log at ...,
which is exactly the run nobody needs.
DevLogProvider.nativeReadynow takes the files directory too, andapp_log::set_crash_diris called from whichever of the provider and the activity runs first (it deletes the file, so the second says nothing).- The panic hook saves context, not just the panic: the dying run's
last 80 log lines go into the file with it, and are replayed into the
new run's ring ahead of the panic line, so the Runtime tab reads
chronologically -- what the app was doing, then what killed it, then
this run. They are read with a new non-blocking
LogRing::try_tail_text, because a panic raised while the ring's own lock was held would otherwise deadlock the hook and hang the process instead of aborting it.
2026-09-08: iris ships an icon font, and widget::mark is gone
Iris's question -- "why does mark exist? The font should be working if it's working for compose and nerd fonts are bundled" -- and its answer: the Compose app draws icons from its own committed Nerd Fonts subset, while iris was setting the disclosure mark with bare Unicode geometric codepoints out of whatever face the platform resolved. So iris now does what Compose does.
-
iris::icon(new module): the codepoints iris draws, one constant each --OPEN,CLOSED,COLLAPSEtoday. Every one has to have a matching entry iniris/core/build-icon-font.sh'sGLYPHS, which is what builds the shippediris/core/assets/fonts/nerd_icons.ttf(992 bytes, Material Design, Mono face).every_icon_is_in_the_bundled_fontfails the build if the two lists drift. -
Family::Icons(new variant): how any text asks for that family. Before/after:// was mark(if open { Dir::DOWN } else { Dir::RIGHT }, 9.0, MUTED) // now text(if open { icon::OPEN } else { icon::CLOSED }, 9.0, MUTED) .family(Family::Icons)It names an intention, not a font name: only
TextDataknows what the bundled file registered as, and it resolves the variant during shaping (TextData::resolve_family, also public). A named family rather than a generic one, so nothing falls back into it for ordinary text and an icon cannot fall back out of it onto a system face that happens to have the codepoint. -
iris::widget::markis removed -- added earlier the same day and superseded within it. It drew one correct triangle; every further icon would have been another rasteriser, and an icon as text takes the size, colour and baseline of the line it sits in for free. -
FontDiagnostics::icon_family(new field), in the startup log line and the Diagnostics pane: which family the icons resolved to, so a build whose bundled font failed to register says so instead of drawing tofu.
This does not reopen the 2026-09-07 platform-fonts decision. Body and monospace text still come from the platform's own collection; an icon is the opposite case, a small closed set of codepoints no system font is guaranteed to have, and it is the same division the Compose app makes.
2026-09-08: a press only reaches what the pointer is actually on
Iris's report -- "if I try to scroll vertically while a horizontal scroll animation is still active, it stays locked to the horizontal scroll. It should let it keep going and instead only affect vertical scrolling" -- and her own diagnosis of it, which was the right one: "it seems like iris is set up so the animation stuff is global which it definitely should not be. Tapping outside of something that a fling is currently active for should have no code in common with the fling that could influence it."
It was global, and it was in sense::should_run. run_sensors runs a
widget one frame after the pointer leaves it (ActivationState::End,
which is not Off) so a HoverEnd can fire, and should_run derived
PressStart/Pressing/PressEnd/Scroll from the raw button and wheel
state without consulting hover at all. So that farewell frame carried a
press to a widget the finger was nowhere near.
That alone would have been a stray event; what made it eat the gesture is
the catch added on 2026-09-07 (PressState::scrolling), which commits a
press on already-moving content to a pan immediately, with no DRAG_SLOP
-- so the widget captured the pointer on that frame and every later sample
went to it. And the widget's hover was stale in the first place because a
gesture that ends while captured returns from run_sensors' capture
branch, which never reaches the loop that would have updated it.
Measured on the real screen before the fix: a fence flicked sideways, then a finger put down on a row 500px above it and dragged 160px down the screen. The list moved by zero, the fence moved by zero, and the fence held the pointer for the whole gesture -- the report, exactly.
should_runnow requireshover.is_on()for every non-hover sense. Press and wheel both, since a wheel event reaching a widget the cursor has just left is the same fault with a different sense.DropandCancelare unaffected: they are delivered deliberately to a widget that is not under the pointer, andrun_sensorshands both an explicitOn.Scroll::is_scrolling(new): whether a fling is coasting in this area, the same question and the same nameList::is_scrollingalready answers for the other scrolling widget.
Nothing about the fling, the arbiter or the catch changed. A press outside a coasting area now has no code in common with it, so the horizontal fling keeps coasting through a vertical drag on its own -- which is the second half of what Iris asked for, and it falls out of the fix rather than being arranged. A press inside a coasting area is still a catch on either axis, which is what Compose does ("Compose does catch no matter what axis if you tap in the horizontal area").
Two tests, one per layer:
sense_tests::a_press_does_not_reach_a_widget_the_pointer_has_just_left
is the mechanism with two stacked scroll areas and no screen, and
fence_fling.rs's
a_drag_away_from_a_coasting_fence_scrolls_the_list_and_leaves_it_coasting
is the report itself over the real transcript. Both fail on the old code.
2026-09-08: the composer is clipped to its bar, not inside its padding
Iris: "the message input box doesn't clip correctly ... the box should be clipped rather than the inset text."
The composer was .masked().background(rect(...)) -- two boxes, one
inside the other. The mask sat inside the dp(FIELD_PAD_DP) padding, so
a message longer than the six lines shown was cut through the middle of a
glyph 12dp in from the bar's edge, with a band of bare surface above the
cut. Measured at the phone's own size and density (1080x2424 at 2.55): the
bar's top edge at y=1995.6 and the text sliced at y=2026.2.
It is .masked_by(rect(BAR_FILL)) now: the same rect is the surface drawn
behind the field and the shape the field is clipped to, so the two
cannot fall out of step -- the idiom row.rs already uses to cut a code
fence to its own rounded panel. Text now disappears under the bar's edge
at 1995.6. The padding still holds text off the edge at the end the
content is anchored to, which is the end anybody is reading.
The composer's overflowing and keyboard-open states had no way to be
looked at headlessly, since that window has no keyboard: the phone rig
takes --message TEXT and --ime PX for them
(transcript-fixture/examples/phone.rs, through RUN_HEADLESS_ARGS).