# 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-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` 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`: ```rust // 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`. 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` 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. ```rust // 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. ```rust // 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: `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. ```rust 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. ```rust 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`** — 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). ```rust 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 `reposition`ed 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::(())`, which nothing in-tree does. - **`Tasks::init` takes `Arc` instead of `Arc`.** `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. ```rust // 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>` 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.