RUST.md's P0 box gets the fix, the before/after streaming-phase numbers (with their caveats), the build-apk.sh/run-bench.sh scripts, and what the dropout-fix pass's three remaining verifications are blocked on (the sandbox ai-server currently fails to build, unrelated to this change). IRIS.md gets the List::replace_back/clear and TranscriptScreen::apply API entries. AGENTS.md's rigs section gets one sentence on each script. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
487 lines
26 KiB
Markdown
487 lines
26 KiB
Markdown
# 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: `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`:
|
||
|
||
```rust
|
||
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`:
|
||
|
||
```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<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.
|
||
|
||
```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, 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.
|
||
|
||
```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<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).
|
||
|
||
```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::<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.
|
||
|
||
```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<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.
|
||
|
||
## 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 a `List` scrolled elsewhere is untouched.
|
||
`None` if the list is empty. `RowKey` may differ between the old and new
|
||
row; only `heights`/`extents` care, and both are invalidated for the
|
||
evicted key the same way `pop_back` already does.
|
||
- **`List::clear()`**: drops every loaded row and resets to `List::new`'s
|
||
state (`more_before`/`more_after` untouched — a caller that wants those
|
||
cleared too calls `set_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 from
|
||
`transcript_ui::build_tree` on every folded event. Diffs the two
|
||
`group_tool_runs` outputs 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 via `List::replace_back`, with any further
|
||
new rows appended after it. A row changing *before* the tail (only
|
||
`group_tool_runs` retroactively grouping tool calls into a run does
|
||
this) falls back to `List::clear` plus a full rebuild, counted in
|
||
`TranscriptScreen::take_rebuilds()`. `bench_client.rs`, `transcript_client.rs`
|
||
and `desktop-app/app.rs` all call this now instead of rebuilding on every
|
||
event; only the opening page (and `apply`'s own fallback) still calls
|
||
`build_tree`.
|
||
- **`TextEditCtx::set_with_spans(text, spans)`**: `set()` plus a fresh
|
||
`Vec<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).
|