Files
ai-app/docs/IRIS.md
T
2026-09-06 18:40:38 -04:00

48 KiB
Raw Blame History

iris: notable public API changes

For Iris to read on her own time. Each entry is a change to iris's public surface that a widget author or app author would notice: a trait method added, removed or re-shaped; a type that callers construct differently; a capability that moved. Small and trivial changes do not go here.

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-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 docs/REVIEW-2026-09-06.md's finding 1 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 on TextBuilder itself, not either output type, and both TextOutput::run and TextEditOutput::run apply it to the buffer via TextBuffer::set_spans. These two call sites are a pair: adding a third TextBuilderOutput impl without also calling set_spans there reproduces the exact bug this box shipped once already (spans silently dropped for TextEdit, 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. PlacedGlyph gained a color: UiColor field (from parley's own per-run Style::brush), and Painter::glyphs draws each glyph in its own colour instead of RenderedText::color uniformly. RenderedText::color still 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, default Unknown. Override it if your widget has a real platform equivalent — TextEdit now returns TextInput/MultilineTextInput by EditMode. Only consulted for a widget that also has a .label(); an unlabelled widget's access_role is never called.
  • Widgets::named() -> impl Iterator<Item = WidgetId> — every widget with an explicit label, for anything else that wants to walk the same set AccessTree does.

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 from Rsc::State: HasDefaultUiState to Rsc::State: FocusHost (new trait, attr.rs). HasDefaultUiState still exists and still works — default/attr.rs now implements FocusHost for anything that has it — so a winit app's existing code is unaffected. An Android app implements FocusHost via HasAndroidUiState instead. Affects only an app that referenced HasDefaultUiState directly at a Selectable/Selector call site rather than through .attr::<Selectable>(()), which nothing in-tree does.
  • Tasks::init takes Arc<dyn RequestRedraw> instead of Arc<winit::window::Window>. RequestRedraw (task.rs) is one method, fn request_redraw(&self); winit::window::Window implements it (default/render.rs), so Tasks::init(window) at a call site is unchanged by inference. Only matters if something constructed a Tasks directly rather than through DefaultRsc/AndroidRsc.
  • TextEdit::apply_event/TextInputResult are #[cfg(not(target_os = "android"))] — they take a winit::event::KeyEvent, which does not exist on Android; android/input.rs drives the same primitives (backspace/delete/motion/insert, all still unconditional) from ndk::event::Keycode directly instead. New unconditional getters on the way: TextEdit::text()/selection_range()/caret(), and TextEditCtx::delete_byte_range/set_cursor_byte — the primitives android/ime.rs's InputConnection bridge 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::new drops its limits: UiLimits parameter, and UiLimits is 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_INDEXING plus two max_binding_array_* limits. After: Features::empty() (the DeviceDescriptor default) and only max_buffer_size set, which was never about the binding array.
  • TextureHandle has no primitive() method any more; a caller outside iris shouldn't have been calling it (it fed the old renderer's internals), but if something did: use image_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.
  • GlyphPrimitive has 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) — one layer (the shared atlas array's layer) instead of a view_idx/sampler_idx pair, 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_within and Textures::add keep their signatures. What changed underneath is that each standalone image now gets its own wgpu::BindGroup and draw call instead of a slot in the shared array — invisible from the widget API, visible only in UiRenderNode's internals and in iris'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 existing record(total) (unchanged, and still what a caller with no split should use — it now reads as cpu_p50 == total, gpu_wait_p50 == 0, rather than fabricating a number for a half it never measured).
  • FrameStats gains cpu_p50 and gpu_wait_p50: medians of redraw-start-to-submit and submit-to-after-present() respectively, independent of each other and of the existing p50/p90/p99/worst (which are unchanged, and still over the whole frame). The Android renderer's draw() now returns the submit_to_present Duration it measured, which android::view::render() passes to record_split.
  • Caveat carried in both doc comments: submit_to_present is 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 a List scrolled elsewhere is untouched. None if the list is empty. RowKey may differ between the old and new row; only heights/extents care, and both are invalidated for the evicted key the same way pop_back already does.
  • List::clear(): drops every loaded row and resets to List::new's state (more_before/more_after untouched — a caller that wants those cleared too calls set_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 from transcript_ui::build_tree on every folded event. Diffs the two group_tool_runs outputs 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 via List::replace_back, with any further new rows appended after it. A row changing before the tail (only group_tool_runs retroactively grouping tool calls into a run does this) falls back to List::clear plus a full rebuild, counted in TranscriptScreen::take_rebuilds(). A caller that keeps its own row-keyed side table alongside List (Selection's rows: BTreeMap<RowKey, WeakWidget<TextEdit>> is the one this crate has) must clear it in step with List::clear() — the fallback drops every row List was holding, so any side table not cleared the same way is left pointing at widgets the clear just freed (docs/REVIEW-2026-09-06.md finding 1, fixed 2026-09-06 by Selection::clear(), called from apply's Rebuild arm right before List::clear()). bench_client.rs, transcript_client.rs and desktop-app/app.rs all call this now instead of rebuilding on every event; only the opening page (and apply's own fallback) still calls build_tree.
  • TextEditCtx::set_with_spans(text, spans): set() plus a fresh Vec<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 ~101130ms/~76103ms 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).

  • TextData now 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 the SansSerif/Monospace generic-family lists, rather than relying on the platform's font enumeration alone. TextData::font_diagnostics() -> FontDiagnostics reports 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 linking iris-core; build-apk.sh's own output says the delivered (compressed) number.
  • UiRenderNode::new/resize now take the window size explicitly (window_size: impl Into<Vec2>) instead of deriving it from the surface's physical SurfaceConfiguration. Existing callers pass a logical size (physical ÷ density/scale-factor) now; this is what makes a font_size: 16.0 16 dp instead of 16 raw device pixels on a high-density phone. Before this, scale_factor did not exist anywhere in the crate, on either platform.
  • AndroidUiState::content_scale: f32 (DisplayMetrics.density, read once in new_peer) and the desktop equivalent (window.scale_factor()) now divide every physical-pixel number before it reaches layout or touch handling — see content_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 from render() exactly when AndroidUiState::insets() changes. Nothing previously consumed insets().top at all; a screen with chrome under the status bar implements this to pad it, in the same logical units content_scale converts everything else to.
  • New: iris_core::WgpuErrorLog, installed via Device:: on_uncaptured_error on the Android device (wgpu's default handler is an unconditional panic outside UiRenderNode::new's own error scopes). Explicit Arc-backed value passed to the callback and kept on AndroidRenderer, 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_changed rebuilding 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_changed now calls AndroidRenderer::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.
  • Len has a third field, dp (Android's dp / CSS's reference pixel, 1/160in), beside the existing abs (now explicitly physical pixels) and rel/rest. len_fns::dp/Len::dp construct one, used exactly like abs/rel/restdp(16) instead of a bare 16 wherever 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 by content_scale then 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/Painter gained density()/set_density() (physical pixels per dp). Every place a length resolves (Len::apply_rest, Size::to_uivec2) now takes it; Span::gap and Padding's four sides moved from a bare f32 to Len so they take dp(...) too. A bare number anywhere is unaffected — still abs, physical pixels.
  • Text is rasterised at physical resolution now. TextBuffer::shape takes density and multiplies font_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 (LogicalInsets renamed WindowInsets). The previous "logical" division by content_scale is gone; content_scale now feeds set_density instead.
  • 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).

  • FocusHost gained is_focused(&self, id) -> bool (both platform impls). attr.rs's Selector/Selectable used 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 crossing sense::DRAG_SLOP) unless the field is already focused, in which case dragging inside it to select text is unchanged. TextEdit gained one new pub(crate) field (press_origin) to track this; no public surface change there.
  • android::ime's InputConnection now calls InputMethodManager:: updateSelection after every edit (IrisViewPeer::update_ime_selection, called from after_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 its render() and this bridge never did.
  • GlyphAtlas::clear() and Textures::reset() (iris_core). Called together, once, from android::view's surface_changed exactly when a genuinely new AndroidRenderer is 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_counters returns four numbers, not three: (draws, region rewrites, move writes, **text shapes**). The new one is bumped in Painter::render_text, which TextView::render only 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.