Files
ai-app/IRIS.md
T
irisandClaude Sonnet b063fbd7f9 RUST.md, IRIS.md, IRIS_TODO.md: record I5 -- transcript-ui built and
partial, the recommendation's numbers still missing

I5's own box: the seven "hard to get back" behaviours each shown or
given a sourced reason, the exact verification commands and results,
and what remains (Android integration, touch-drag-vs-selection
arbitration, row accessibility names, a tappable link, code-span chip,
selection's anchor-row shortcut, code-fence syntax highlighting) --
each also a dated IRIS_TODO.md item so it is not silently dropped.
Ticked [~] rather than [x]: the widget-tree half is built and tested,
the emulator half is not.

"Where things stand" and the Recommendation's item 3 updated in place
to say plainly that neither Masonry (E2) nor iris (I5) has produced a
render number yet, and why -- not a bad measurement, no measurement
obtainable yet on either side -- with the structural findings that do
exist (iris now does cross-row selection and per-span inline rich text,
neither of which exists in masonry/masonry_core/xilem today) recorded
as what currently favours iris absent a number.

IRIS.md gets SpanStyle's own entry: what changed, why, and the one
thing a future TextBuilderOutput impl must remember (both TextOutput
and TextEditOutput apply .spans() -- this box shipped the bug of
missing one of the pair once already).

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
2026-09-05 07:55:04 -04:00

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