Files
ai-app/docs/IRIS.md
T
irisandClaude Fable 5.1 e6924298bc iris: fix the intermittent touch-scroll dropout (missed ACTION_DOWN hit-test)
Root-caused via temporary logcat tracing (touch events, DragArbiter state,
Selection::drag dispatch), reproduced against a real sandbox session: a
gesture's ACTION_DOWN can land on a row's own padding/gap or its header,
which CursorSense has no sensor over, so the widget that ends up handling
the gesture only ever sees Pressing frames and DragArbiter never gets
press_start -- leaving it stuck in Idle (answers Undecided forever) for the
rest of that gesture. Not the previously-suspected coalesced first
ACTION_MOVE, which is now ruled out.

DragArbiter::is_idle() lets Selection::drag notice a Pressing frame with
no matching press_start and recover the press there instead. Four new unit
tests, one of which fails on the pre-fix code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 21:05:03 -04:00

21 KiB

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<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.