RUST.md's P0 box gets Iris's first real-phone report (no crash) and the four defects it found (glyph-wipe-on-first-touch, missing bold glyphs, text far too small, status-bar inset not applied), what was fixed and how it was verified on the emulator, and what's still open (item 1's root cause, and the top-row height anomaly noted in the last commit). IRIS_TODO.md gets a new "From the phone, 2026-09-06" section for the two items explicitly deferred to a follow-up agent: no scroll momentum/fling, and occasional jitter scrolling down. IRIS.md gets the public-API entry for TextData's bundled fonts/ font_diagnostics, UiRenderNode::new/resize's new window_size parameter, AndroidUiState::content_scale, AndroidAppState::on_insets_changed, and iris_core::WgpuErrorLog. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
30 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-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 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.
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 existingrecord(total)(unchanged, and still what a caller with no split should use — it now reads ascpu_p50 == total,gpu_wait_p50 == 0, rather than fabricating a number for a half it never measured).FrameStatsgainscpu_p50andgpu_wait_p50: medians of redraw-start-to-submit and submit-to-after-present()respectively, independent of each other and of the existingp50/p90/p99/worst(which are unchanged, and still over the whole frame). The Android renderer'sdraw()now returns thesubmit_to_presentDurationit measured, whichandroid::view::render()passes torecord_split.- Caveat carried in both doc comments:
submit_to_presentis 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 aListscrolled elsewhere is untouched.Noneif the list is empty.RowKeymay differ between the old and new row; onlyheights/extentscare, and both are invalidated for the evicted key the same waypop_backalready does.List::clear(): drops every loaded row and resets toList::new's state (more_before/more_afteruntouched — a caller that wants those cleared too callsset_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 fromtranscript_ui::build_treeon every folded event. Diffs the twogroup_tool_runsoutputs 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 viaList::replace_back, with any further new rows appended after it. A row changing before the tail (onlygroup_tool_runsretroactively grouping tool calls into a run does this) falls back toList::clearplus a full rebuild, counted inTranscriptScreen::take_rebuilds().bench_client.rs,transcript_client.rsanddesktop-app/app.rsall call this now instead of rebuilding on every event; only the opening page (andapply's own fallback) still callsbuild_tree.TextEditCtx::set_with_spans(text, spans):set()plus a freshVec<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
~101–130ms/~76–103ms 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).
TextDatanow 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 theSansSerif/Monospacegeneric-family lists, rather than relying on the platform's font enumeration alone.TextData::font_diagnostics() -> FontDiagnosticsreports 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 linkingiris-core;build-apk.sh's own output says the delivered (compressed) number.UiRenderNode::new/resizenow take the window size explicitly (window_size: impl Into<Vec2>) instead of deriving it from the surface's physicalSurfaceConfiguration. Existing callers pass a logical size (physical ÷ density/scale-factor) now; this is what makes afont_size: 16.016 dp instead of 16 raw device pixels on a high-density phone. Before this,scale_factordid not exist anywhere in the crate, on either platform.AndroidUiState::content_scale: f32(DisplayMetrics.density, read once innew_peer) and the desktop equivalent (window.scale_factor()) now divide every physical-pixel number before it reaches layout or touch handling — seecontent_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 fromrender()exactly whenAndroidUiState::insets()changes. Nothing previously consumedinsets().topat all; a screen with chrome under the status bar implements this to pad it, in the same logical unitscontent_scaleconverts everything else to. - New:
iris_core::WgpuErrorLog, installed viaDevice:: on_uncaptured_erroron the Android device (wgpu's default handler is an unconditional panic outsideUiRenderNode::new's own error scopes). ExplicitArc-backed value passed to the callback and kept onAndroidRenderer, not a global — a caller wanting one on desktop builds its own the same way.