RUST.md: E4 ticked with the screenshot path, the exact commands against app/ui-sandbox.sh, and the streaming-duplication bug the screenshot found; "Where things stand" moved E4 out of "in flight" into its own done bullet. IRIS.md: transcript_ui::build_tree, the public API change transcript-ui gained for this. CLIENT_CORE.md: client_core::config's table row and its correspondence note. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
12 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: 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: SpanStyle, per-range text styling (RUST.md's I5)
A TextBuffer used to have exactly one style (TextAttrs: colour, size,
family, ...) for its whole string, applied via push_default into parley's
ranged builder. SpanStyle is a second, optional layer: a byte range plus
whichever of colour/family/font size/bold/italic/underline it overrides,
pushed with parley's own push(property, range) instead. Why: a transcript
row's markdown (a heading, bold, inline code, a link) all inside one
wrapped paragraph needs each to carry its own look while the paragraph
still wraps and selects as a single buffer — the thing masonry's
TextArea cannot do (StyleSet is one style for the whole editor,
text_area.rs:43-44's // TODO: RichTextInput), and the reason this
existed at all.
let (text, spans) = transcript_ui::markdown::render_markdown(src, 16.0);
wtext(text)
.spans(spans) // new: TextBuilder::spans, on both Text and TextEdit
.editable(EditMode::MultiLine)
.add(rsc);
Two things a widget author should know before reaching for it:
- Call
.spans()before or after.editable(), both work — the field lives onTextBuilderitself, not either output type, and bothTextOutput::runandTextEditOutput::runapply it to the buffer viaTextBuffer::set_spans. These two call sites are a pair: adding a thirdTextBuilderOutputimpl without also callingset_spansthere reproduces the exact bug this box shipped once already (spans silently dropped forTextEdit, found only by screenshotting, not by any test —markdown.rs's own unit tests check string/range logic, which is correct in isolation and proves nothing about whether the render path ever sees it). - Colour is now per-glyph, not per-buffer.
PlacedGlyphgained acolor: UiColorfield (from parley's own per-runStyle::brush), andPainter::glyphsdraws each glyph in its own colour instead ofRenderedText::coloruniformly.RenderedText::colorstill exists (the buffer's base colour, for a caller that wants it as a whole, e.g. to tint a cursor) but no longer drives what a glyph actually renders as.
2026-09-05: accessibility names via AccessKit (RUST.md's I4)
.label() (already in trait_fns.rs, previously unused anywhere in-tree)
is now load-bearing: it's the one thing that puts a widget in the AccessKit
tree iris_core::ui::access::AccessTree builds and both backends push
out. A widget author who wants a control to be findable by name (and
tappable by name, through ui-trace/a real screen reader) calls .label()
on it; nothing else is required, and a widget nobody labels is invisible
to this system at zero cost, not just zero UI.
let button = rect(Color::LIME)
.on(CursorSense::click(), move |_, rsc| { ... })
.label("Add task"); // now findable by uiautomator/AccessKit as "Add task"
Two new things a widget author might touch directly:
Widget::access_role(&self) -> accesskit::Role, defaultUnknown. Override it if your widget has a real platform equivalent —TextEditnow returnsTextInput/MultilineTextInputbyEditMode. Only consulted for a widget that also has a.label(); an unlabelled widget'saccess_roleis never called.Widgets::named() -> impl Iterator<Item = WidgetId>— every widget with an explicit label, for anything else that wants to walk the same setAccessTreedoes.
Nothing about Painter, draw, or the layout/move machinery changed —
this sits entirely beside them, reading resolved_region's output rather
than participating in producing it.
2026-09-05: List, a virtualised bottom-anchored list (RUST.md's I3)
A new widget, iris::widget::List (iris/src/widget/list.rs -- read its
module doc first), for the transcript's kind of screen: variable-height
rows, keyed by a u64, composed only while visible, moved rather than
re-laid-out on scroll, a scroll anchor that survives a row inserted above
it, "more" sentinels at each end, and "hold the edge nearest the tap" when
a row's height changes (note_tap, resolved in the layout pass).
let mut list = List::new(Axis::Y);
list.push_back(ListRow::new(key, row_widget)); // O(1)
list.push_front(ListRow::new(older_key, row)); // O(1), anchor unaffected
list.set_more_before(Some(spinner_widget)); // sentinel, drawn at the edge
list.note_tap(viewport_y); // before mutating a row's height
let (top, bottom) = list.extent(key).unwrap(); // last frame's on-screen box, if visible
Built entirely out of existing primitives (Painter::widget/widget_within/
reposition/draw_twice, and draw_inner's own old-children diffing) --
no new mechanism was added to the render core for it. One correctness
lesson worth reading even for other widgets: a row that fills whatever
region it is offered (Rect, is_size_independent) cannot be measured at
a throwaway oversized region and then merely repositioned into place --
reposition only ever writes an offset, never a size, so the oversized
primitive stays oversized. List fixes this by caching each row's real
height once measured and placing an already-known row directly at its
exact box; see list.rs's place for the full reasoning and
a_fill_shaped_background_is_not_left_oversized for the regression test.
2026-09-05: a second backend (android-view), and what moved to make room for it
RUST.md's I2. Three changes a widget or app author would notice, all in
service of the same thing: default (winit) and the new android
(android-view) backends sharing what does not depend on windowing.
Selector/Selectable's bound changed fromRsc::State: HasDefaultUiStatetoRsc::State: FocusHost(new trait,attr.rs).HasDefaultUiStatestill exists and still works —default/attr.rsnow implementsFocusHostfor anything that has it — so a winit app's existing code is unaffected. An Android app implementsFocusHostviaHasAndroidUiStateinstead. Affects only an app that referencedHasDefaultUiStatedirectly at aSelectable/Selectorcall site rather than through.attr::<Selectable>(()), which nothing in-tree does.Tasks::inittakesArc<dyn RequestRedraw>instead ofArc<winit::window::Window>.RequestRedraw(task.rs) is one method,fn request_redraw(&self);winit::window::Windowimplements it (default/render.rs), soTasks::init(window)at a call site is unchanged by inference. Only matters if something constructed aTasksdirectly rather than throughDefaultRsc/AndroidRsc.TextEdit::apply_event/TextInputResultare#[cfg(not(target_os = "android"))]— they take awinit::event::KeyEvent, which does not exist on Android;android/input.rsdrives the same primitives (backspace/delete/motion/insert, all still unconditional) fromndk::event::Keycodedirectly instead. New unconditional getters on the way:TextEdit::text()/selection_range()/caret(), andTextEditCtx::delete_byte_range/set_cursor_byte— the primitivesandroid/ime.rs'sInputConnectionbridge needed and that were not previously exposed publicly.
2026-09-04: Widget::draw reports the size it used; desired_width/desired_height are gone
A widget used to implement three methods (draw, desired_width,
desired_height); it now implements one, fn draw(&mut self, painter: &mut Painter) -> Size, which draws into painter.region() and returns how much
of it was used. Why: the two extra methods routinely re-simulated what
draw was about to do anyway (Span::desired_ortho copied its own draw
loop to get cross-axis sizing right) — one visit per widget per frame
instead of up to three. A container that needs a child's size before
placing it (alignment, centering) draws the child once at a provisional
region, reads the returned Size, and calls the new Painter::reposition
to move it into its final spot — an O(1) offset write, not a second draw. A
widget whose drawn output never depends on the size it's given (a
fixed-size Rect, a decoded Image) overrides the new fn is_size_independent(&self) -> bool { false } to true, which skips
redrawing it when only its offered region changes shape.
// before
fn draw(&mut self, painter: &mut Painter) { /* ... */ }
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
// after
fn draw(&mut self, painter: &mut Painter) -> Size { /* ... */ }
SizeCtx and Cache are gone with it — see LAYOUT.md for the full
design, the move-offset mechanism this shipped alongside, and the file
list.
2026-09-04: texture pipeline rebuilt off the binding array
Textures/TextureHandle, GlyphPrimitive, and UiRenderNode::new all
changed shape. Why: the old pipeline bound every texture ever drawn in one
binding_array<texture_2d<f32>> and asked every device, unconditionally,
for VK_EXT_descriptor_indexing — a real share of Android GPUs lack it,
and it failed outright on the Android emulator's software Vulkan. See
TEXTURES.md's "Recommended shape" and "Implemented, 2026-09-04".
UiRenderNode::newdrops itslimits: UiLimitsparameter, andUiLimitsis gone. Before:UiRenderNode::new(&device, &queue, &config, UiLimits::default()). After:UiRenderNode::new(&device, &queue, &config). Nothing replaces it — there are no more binding-array limits to size.src/default/render.rs's device request asks for no features and no binding-array limits. Before:required_features: Features::TEXTURE_BINDING_ARRAY | Features::PARTIALLY_BOUND_BINDING_ARRAY | Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXINGplus twomax_binding_array_*limits. After:Features::empty()(theDeviceDescriptordefault) and onlymax_buffer_sizeset, which was never about the binding array.TextureHandlehas noprimitive()method any more; a caller outsideirisshouldn't have been calling it (it fed the old renderer's internals), but if something did: useimage_index()for a standalone image's bind-group index. There is no equivalent for a page — a page has no bind group of its own now, see below.GlyphPrimitivehas no public constructor from a struct literal. Before:GlyphPrimitive { uv_min, uv_max, view_idx, sampler_idx, color, flags }. After:GlyphPrimitive::new(uv_min, uv_max, layer, color, flags)— onelayer(the shared atlas array's layer) instead of aview_idx/sampler_idxpair, since a page is now a layer of one array texture rather than its own bound texture.- A widget author drawing images is unaffected:
Painter::texture/texture_at/texture_withinandTextures::addkeep their signatures. What changed underneath is that each standalone image now gets its ownwgpu::BindGroupand draw call instead of a slot in the shared array — invisible from the widget API, visible only inUiRenderNode's internals and iniris's device requirements.