Iris's 2026-09-08 report, both halves, and her own diagnosis of the second: "tapping outside of something that a fling is currently active for should have no code in common with the fling that could influence it." `run_sensors` runs a widget one frame after the pointer leaves it (`ActivationState::End`, which is not `Off`) so `HoverEnd` can fire, and `should_run` derived the press and wheel senses from raw button state without consulting `hover`. That farewell frame carried a `PressStart` to a widget the finger was nowhere near -- and a press on already-coasting content is a catch, which commits to a pan with no `DRAG_SLOP`, so the widget captured the pointer and swallowed the whole gesture. Its hover was stale because a gesture that ends while captured returns from the capture branch, which never reaches the loop that updates it. Measured before the fix on the real screen: a fence flicked sideways, then a finger down on a row 500px above it dragged 160px down the screen -- the list moved by zero, the fence moved by zero, and the fence held the pointer throughout. After: the list follows the finger and the fence's fling carries on coasting, which is what she asked for and falls out of the fix rather than being arranged. `should_run` now requires `hover.is_on()` for every non-hover sense. `Drop`/`Cancel` are unaffected -- they are delivered deliberately to a widget that is not under the pointer, with an explicit `On`. Also: the composer is clipped to its own bar rather than inside its padding (`.masked_by(rect(BAR_FILL))` in place of a `.masked()` + `.background()` pair) -- "the box should be clipped rather than the inset text". A long message was being sliced mid-glyph 12dp in from the bar's edge, leaving a band of bare surface above the cut. New: `Scroll::is_scrolling`, the name `List` already uses; the phone rig's `--message TEXT` and `--ime PX`, since the composer's overflowing and keyboard-open states cannot otherwise be looked at headlessly. Tests fail on the old code, one per layer: `a_press_does_not_reach_a_widget_the_pointer_has_just_left` (sensors, no screen) and `a_drag_away_from_a_coasting_fence_scrolls_the_list_and_ leaves_it_coasting` (the report itself, layer 1). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1611 lines
89 KiB
Markdown
1611 lines
89 KiB
Markdown
# iris: the log of how it is being built
|
||
|
||
For Iris to read on her own time. An entry is anything **major**: a new
|
||
capability or widget, a design decision and what it was chosen over, a
|
||
mechanism that changed shape, a defect whose root cause says something
|
||
about the framework -- and the public-surface changes a widget or app
|
||
author would notice, which is all this file used to hold (widened on
|
||
Iris's instruction, 2026-09-08: "any major additions or design things
|
||
should be added there, not just public API stuff"). Small and trivial
|
||
things still stay out.
|
||
|
||
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-08: what a cancel means, what a row's box is, and one fling for every scroll area
|
||
|
||
Iris's second 2026-09-08 report, from the bench on her phone. Four items,
|
||
and each turned out to be a rule stated in one place and missing from its
|
||
siblings rather than a special case.
|
||
|
||
**A gesture the *platform* takes away is a cancel, not a release**
|
||
(`CursorState::cancelled`). Android's `ACTION_CANCEL` used to take the
|
||
same arm as `ACTION_UP`, so the system's own swipe up from the bottom
|
||
edge to leave the app arrived as a flick released at speed: the
|
||
transcript flung while the app was in the background, and came back
|
||
somewhere else. A cancelled sample now hands `CursorSense::Cancel` to
|
||
the capture holder *and* every widget still tracking the press, clears
|
||
both, and derives nothing else from that sample -- no tap, no selection,
|
||
no fling. That is the same sense a widget already gets when it loses a
|
||
capture race; what is new is that the platform can raise it, and that
|
||
the *winner* hears it too when the platform is the one cancelling.
|
||
|
||
**A `DragGesture` ignores a cancel when it is the one holding the
|
||
capture.** A cancel goes to every pressed widget that did not capture,
|
||
and one gesture is routinely driven by several of those: a transcript
|
||
row's text block feeds the shared gesture that captures under the
|
||
*list's* id, so the block is a "loser" on the very frame its own pan
|
||
committed. `Cancel` means "somebody else won", so the question is
|
||
whether the holder is us -- and now it is asked. With that, a row's
|
||
block registers the whole `drag_senses()` set, which is what the doc on
|
||
that set has always said a widget driving a gesture must do; it was the
|
||
one place that did not, and it is why panning a code fence sideways and
|
||
then tapping made the transcript jump.
|
||
|
||
**A row is drawn at the box its own height implies, in the frame that
|
||
height changes** (`List::place`). A row is offered its *cached* height
|
||
so that an unchanged row takes `draw_inner`'s cheap path; a
|
||
`.background(rect(..))` fills whatever box it is handed. So on the frame
|
||
a row changed height its text laid out at the new height and its
|
||
background painted at the old one -- collapsing or opening a tool card
|
||
looked closed while its text was there, then open while it was not. When
|
||
the measurement disagrees with the offer, the row is now drawn again at
|
||
its true box. The bottom-anchored half had a `reposition` for this,
|
||
which writes an offset and never a size, so it could not fix it either:
|
||
the same rule, applied to one member of a set of two.
|
||
|
||
**Every scroll area flings, on either axis** (`iris::sense::Flinger`).
|
||
The fling was `List`'s alone -- the curve, the clock, the incremental
|
||
delta, Compose's two release thresholds -- and a `Scroll` dropped its
|
||
released velocity on the floor, with a comment explaining that the areas
|
||
it wrapped were only a screenful. That stopped being true the moment a
|
||
code fence became one. `Flinger` is that machinery as a type both use;
|
||
what it deliberately does not know is which way a positive delta moves
|
||
the content or where the content ends, because a `List` and a `Scroll`
|
||
answer those oppositely. The caller applies `tick`'s delta in its own
|
||
convention and calls `stop` at its own wall. `Scroll::drag` now answers
|
||
whether it started a fling, which is what `scroll_area` needs to call
|
||
`UiData::animate` -- the same split `List::fling` already documented,
|
||
for the same reason: only the caller can reach the frame loop.
|
||
|
||
**Removed, not worked around**: `tool.rs` no longer flattens its two
|
||
`Span`s into one, so a tool group holds its cards 4dp off its own edge
|
||
again. The defect that shape was avoiding -- "a `Span` of `Pad`ded
|
||
children inside another `Span` places those children a slot out of step"
|
||
-- is not reproducible on 2026-09-08, checked both with a headless
|
||
render and with a new layer-1 test.
|
||
|
||
## 2026-09-08: a gesture can be cancelled, and the pointer belongs to the input handler
|
||
|
||
Two changes to how a drag ends, from defects on Iris's phone (a code
|
||
fence panned sideways made the transcript jump on the next tap, and made
|
||
the fence itself snap back).
|
||
|
||
**`CursorSense::Cancel`, and `GestureOutcome::Cancelled`.** Taking
|
||
pointer capture cuts every other widget off from the press completely --
|
||
no `PressEnd`, no `Drop` -- so anything else tracking that press was left
|
||
with a gesture open at an origin belonging to a finger long gone, and the
|
||
next touch anywhere was measured from it. A widget that loses a capture
|
||
race is now told, exactly once. It is a separate sense from `Drop`
|
||
deliberately: `Drop` means "your gesture finished" and callers act on it
|
||
(a fling, a tap, a link followed), which is precisely wrong here.
|
||
|
||
**`CursorSense::drag_senses()`** is what a widget driving a `DragGesture`
|
||
registers -- the frames plus `unclick`, `Drop` and `Cancel`. Both ways a
|
||
gesture can end, stated once rather than remembered per call site;
|
||
forgetting `Drop` is what left a `Scroll` panning from a stale position.
|
||
|
||
**The pointer's state left `UiRenderState`.** `capture_pointer`,
|
||
`release_pointer` and `captured_pointer` are gone from it. Capture and
|
||
the pressed set are `PointerInput` -- the cursor senses' `Event::Global`,
|
||
a new associated type for state an event owns that belongs to no single
|
||
widget -- held by the event manager that runs the dispatch and reached
|
||
by `&mut`, with no lock anywhere. A handler asks through
|
||
`ctx.data.pointer` (`PointerRequests`: `capture(id)`, `release()`,
|
||
`holder()`).
|
||
|
||
// before -- interior mutability on whatever structure was reachable
|
||
ctx.data.render.capture_pointer(id);
|
||
// after
|
||
ctx.data.pointer.capture(id);
|
||
|
||
`DragGesture::handle` and `Scroll::drag` take `&PointerRequests` where
|
||
they took `&UiRenderState`. `task_on` also lost a `Data: Send` bound it
|
||
never needed -- the future it spawns never sees the event's data, and
|
||
that bound was the whole reason the pointer state had been behind a
|
||
`Mutex`.
|
||
|
||
## 2026-09-08: `mark(dir, dp, colour)` -- a drawn triangle, and a scroll area's opening edge
|
||
|
||
**`iris::widget::mark`** draws a filled, antialiased triangle pointing
|
||
along a `Dir`, at a size in dp. It replaces the disclosure codepoints
|
||
U+25B8/25BE/25B4, which were a bet that the platform's fonts have them --
|
||
once iris stopped bundling its own faces, Iris's phone drew an empty box.
|
||
It rasterises one oversampled bitmap into the ordinary texture path and
|
||
scales it into the box asked for, so no new primitive was needed and it
|
||
is correct at any density.
|
||
|
||
**`scrollable_on` now opens at the beginning of its content, and
|
||
`scrollable_to_end(axis)` is the other one** -- pinned to the end and
|
||
staying there while the content grows, which is what a composer wants and
|
||
what everything did before. A code fence was opening at the end of its
|
||
longest line, in the middle of a word. `Scroll::new` takes the edge as a
|
||
third argument rather than deciding for its caller.
|
||
|
||
The design point behind that bug is worth more than the bug: `Scroll`
|
||
held its content's length as an `f32` that was `0.0` both for "there is
|
||
nothing here" and for "I have not drawn yet". Those lead somewhere
|
||
different, and the code could not ask which it had -- so the first
|
||
frame's clamp computed a scroll range of zero, read `amt == len` as
|
||
"sitting at the end", and pinned itself there. It is an `Option` now,
|
||
and the clamp declines to answer a question it cannot yet answer. Any
|
||
measurement iris caches from a previous frame has this shape (LAYOUT.md
|
||
section 4's one-frame lag is the general case), so the rule is: give the
|
||
unmeasured state its own value, not a plausible number.
|
||
|
||
## 2026-09-08: masks have a shape -- `.masked_by(shape)`, and clipping applies to touch
|
||
|
||
A mask no longer carries a rectangle. It carries **the slot of a
|
||
primitive already drawn**, and the fragment stage evaluates that
|
||
primitive's own coverage at each masked pixel and multiplies it into the
|
||
alpha -- the same rounded-rect SDF the primitive itself is drawn with.
|
||
Nothing about the shape is copied, so a rounded container's corner and
|
||
the corner its content is cut to cannot fall out of step, and nested
|
||
masks multiply rather than intersect: a pixel inside two feathered
|
||
corners is dimmed by both.
|
||
|
||
// before -- the mask clipped to the padded box, the rounding was
|
||
// only painted behind it, and the two knew nothing of each other
|
||
field.scrollable_on(Axis::X)
|
||
.masked()
|
||
.pad(dp(FRAME_PAD_DP))
|
||
.background(rect(fill).radius(dp(FRAME_RADIUS_DP)))
|
||
|
||
// after -- one rect, drawn and clipped to
|
||
field.scrollable_on(Axis::X)
|
||
.pad(dp(FRAME_PAD_DP))
|
||
.masked_by(rect(fill).radius(dp(FRAME_RADIUS_DP)))
|
||
|
||
`.masked()` is unchanged for callers and still clips to the widget's own
|
||
box; under it, it now writes an undrawn rect primitive and points the
|
||
mask at that, so square-cornered clipping is the same mechanism rather
|
||
than a special case. `.masked_by(shape)` draws `shape` behind the
|
||
content, in its own layer, and clips to the first primitive it drew.
|
||
There is no radius or shape argument anywhere -- that is the point.
|
||
|
||
**A press now has to be inside the shape, not just the box.** A corner
|
||
the container rounded away is not there to be tapped, which needed the
|
||
coverage function on the CPU as well as in the shader;
|
||
`iris/tests/mask_sdf.rs` runs the shader's own text against the Rust one
|
||
over a grid of points so the two cannot drift apart.
|
||
|
||
One limit worth knowing before reaching for it: **a mask's shape must be
|
||
a rect**, asserted by name. Clipping to a glyph or an image would need,
|
||
respectively, a CPU-side alpha plane for the hit test and a bind-group
|
||
switch the fragment stage cannot make. The shader has the branch where
|
||
either would go.
|
||
|
||
## 2026-09-07: iris runs on a GLES-only Android device, and reports the renderer it cannot build
|
||
|
||
`AndroidRenderer::new` asked wgpu for `Backends::PRIMARY`, which does not
|
||
include `GL`. A device that offers a Vulkan driver with no adapter behind
|
||
it -- this checkout's emulator -- therefore had no adapter at all, and the
|
||
`.expect` on that turned into a crash loop with nothing on screen. It now
|
||
probes for a `PRIMARY` adapter first and falls back to `Backends::GL` when
|
||
there is none, so **Vulkan still wins wherever it has an adapter** and
|
||
nothing changes on a phone.
|
||
|
||
The probe deliberately runs on an instance that never touches the window:
|
||
an Android window can be connected to one graphics API only, so an
|
||
instance carrying both backends lets Vulkan claim the window and leaves
|
||
the GLES surface unusable. That is why this is a second instance rather
|
||
than one wider `Backends` value.
|
||
|
||
The other half a caller sees: `AndroidRenderer::new` already returned
|
||
`Result<Self, String>`, and now **every** way it can fail goes through
|
||
that -- no surface, no adapter, no device, as well as the bind-group
|
||
validation failure it was originally written for. `surface_changed` puts
|
||
that string on screen and in the log ring instead of aborting.
|
||
|
||
## 2026-09-07: `VelocityTracker` takes positions, not deltas
|
||
|
||
A flick released at the wrong speed because the tracker averaged. It now
|
||
does what Compose's touch scrolling does, and that changes what a caller
|
||
feeds it.
|
||
|
||
// before -- one frame's motion
|
||
tracker.add_sample(dy, now);
|
||
// after -- where the finger was
|
||
tracker.add_position(pos.axis(axis), now);
|
||
|
||
`VelocityTracker::velocity` is a port of Compose's `VelocityTracker1D`
|
||
with `Strategy.Lsq2`: a degree-2 least-squares fit through the last 20
|
||
positions, differentiated at the newest sample, with Compose's 100ms
|
||
horizon, 40ms stopped-gap and three-sample minimum. Positions rather than
|
||
deltas because a fit needs points on a curve -- Compose itself throws on
|
||
differential data for this strategy.
|
||
|
||
Three consequences a caller sees. **A gesture with fewer than three
|
||
samples answers `0.0`**, where the average answered a number from two;
|
||
that is Compose's answer too, and on the phone a 120Hz flick delivers
|
||
four or five. **A finger that rests for more than 40ms before lifting
|
||
answers `0.0`** rather than flinging at the speed it arrived with.
|
||
**`add_position` must be called in time order** -- the same debug assert
|
||
as before, now load-bearing for the fit's x-axis.
|
||
|
||
Also new: `VelocityTracker::samples_display` (the held samples as
|
||
`t_ms:position`, printed by `DragGesture` at debug level so a flick
|
||
reported from a phone can be replayed), `DragArbiter::axis`, and
|
||
`sense::MAX_FLING_VELOCITY_DP_S` (8000, `ViewConfiguration`'s own).
|
||
`List::fling` now applies that maximum against its own density and
|
||
ignores anything at or under 1px/s, which is Compose's pair of thresholds
|
||
exactly -- there is deliberately no 50dp/s minimum, because Compose's
|
||
scrolling never consults the one in `ViewConfiguration`.
|
||
|
||
## 2026-09-07: `client-core` carries the app's own log
|
||
|
||
Not iris itself but the crate beside it, and it is a new public surface an
|
||
app author will use: `client_core::log_ring`. Because Iris's phone has no
|
||
`logcat`, an app now keeps a bounded copy of its own log and hands it to
|
||
Dev Updater on the device.
|
||
|
||
Before, an app installed a platform logger and that was the end of it:
|
||
|
||
android_logger::init_once(config); // Android
|
||
// nothing at all on the desktop
|
||
|
||
After, the platform's logger becomes the *inner* logger of a ring that
|
||
records everything alongside it -- `logcat` and a terminal see exactly
|
||
what they saw before:
|
||
|
||
client_core::log_ring::install_process_logger(
|
||
Box::new(android_logger::AndroidLogger::new(config)),
|
||
LevelFilter::Debug,
|
||
)?;
|
||
let ring = client_core::log_ring::process_ring(); // 2000 lines / 256 KiB
|
||
ring.to_text(); // for a report
|
||
ring.summary(); // "1801 lines held, 12 dropped, last 20:09:24"
|
||
|
||
// and, for whatever hands the log out of the process:
|
||
let (lines, next) = ring.since(cursor); // inclusive of `cursor`
|
||
ring.newest_seq(); // None for a ring nothing was written to
|
||
|
||
`process_ring` is a deliberate process-global, unusually for this project:
|
||
`log` already has exactly one backend per process, and a ring passed around
|
||
as a parameter would be a second answer to "which lines exist".
|
||
|
||
**Amended later the same day.** `client_core::log_upload` and
|
||
`ai-server`'s `POST /client-log` are **gone** -- an app no longer sends
|
||
its log anywhere. It exposes it on the device instead, and Dev Updater
|
||
reads it there: on Android that is a `ContentProvider` at
|
||
`<applicationId>.devlog`, which is Dev Updater's own contract (its
|
||
`README.md`, "An app's own log") rather than anything iris-specific.
|
||
`LogRing::newest_seq()` is the one addition that went with it: a reader
|
||
holding a cursor uses it to notice the process **restarted**, since the
|
||
ring is in memory and a new process starts again at sequence zero.
|
||
The reasoning and the rejected alternatives are in docs/DECISIONS.md,
|
||
2026-09-07.
|
||
|
||
## 2026-09-07: `TextData` no longer bundles a font
|
||
|
||
Iris's call: "remove the font for now; just match what compose does."
|
||
`TextData::default()` used to embed six Noto Sans/Noto Sans Mono `.ttf`s
|
||
(3.6 MB, `include_bytes!`) and register them ahead of the platform's own
|
||
fonts in the `SansSerif`/`Monospace` fallback lists. That registration is
|
||
gone; `TextData::default()`'s signature is unchanged, but what it produces
|
||
now depends entirely on `fontique`'s platform discovery (already on by
|
||
default, previously shadowed) -- Roboto/Roboto Flex on Android, whatever
|
||
the desktop's fontconfig resolves on Linux. No caller-visible type or
|
||
method changed, but every consumer of `iris-core` text now renders with
|
||
whatever the host platform's fonts are, not a fixed bundled face -- worth
|
||
knowing if you were relying on pixel-identical text across devices.
|
||
`.so` shrank by 3.75 MB. One real gap surfaced by the switch: this
|
||
fontique version's Android backend never resolves the `Monospace`
|
||
generic family (a fontique ordering bug, not new in this change), so
|
||
`Family::Monospace` text falls through to the same face as
|
||
`SansSerif` on Android rather than a true monospaced one -- still
|
||
visible, not blank, just not monospaced. docs/RUST.md's "Platform fonts
|
||
(2026-09-07)" has the full account.
|
||
|
||
## 2026-09-07: a headless harness, replayed touch, and physical-pixel desktop layout
|
||
|
||
Layer 1 and 2 of docs/RUST.md's "Three test layers".
|
||
|
||
**New: `iris::harness`** -- a screen driven in-process with no window, no
|
||
compositor and no GPU, on a clock the caller advances. `Harness::new(size,
|
||
density)` gives you an `Rsc`, a `UiRenderState` and a state that
|
||
implements `FocusHost`/`OpenUrl` by *recording* what the platform was
|
||
asked for (`keyboard_shown`, `opened_urls`) rather than doing it;
|
||
`frame(t_ms)`/`frames_until(..)` run frames, `touch(action, pos, t_ms)`
|
||
feeds one pointer sample the way Android's `on_touch_event` does, and
|
||
`replay(&TouchScript)` runs a whole recorded gesture. `TouchScript` parses
|
||
a plain `t_ms action x y` file (`down`/`move`/`up`/`cancel`), so the
|
||
batched 120Hz flick shape your phone actually delivers is a file that
|
||
`cargo test` can replay -- something the emulator cannot produce at all.
|
||
|
||
**New: `List::fling_velocity() -> Option<f32>`**, what the release
|
||
measured, readable where it landed rather than by re-timing the gesture.
|
||
|
||
**Changed: `List` starts a fling's curve at its first `tick_fling`, not
|
||
at the release.** The only clock it reads is now the one its driver hands
|
||
it; in a running app the difference is at most a frame.
|
||
|
||
**Changed: the desktop backend lays out in physical pixels with a
|
||
density, exactly as Android does.** `iris::default::content_scale(window)`
|
||
is the desktop's `content_scale` -- winit's scale factor, overridable with
|
||
the `IRIS_SCALE` environment variable -- and it now feeds
|
||
`UiRenderState::set_density`/`TextData::density` instead of dividing
|
||
coordinates into a separate "logical" space. That division had
|
||
`UiRenderState::resize` (physical) and the window uniform (logical)
|
||
disagreeing on any display whose scale factor is not 1.0, and rasterised
|
||
glyphs at one resolution to display them at another. `Input::event` lost
|
||
its `scale_factor` parameter as a result, and `DefaultUiState::
|
||
window_size()` now answers physical pixels. On a 1.0 display nothing
|
||
changes. The override is what lets `run-headless.sh --phone` open a window
|
||
at your phone's own 1080x2424 and 2.55.
|
||
|
||
## 2026-09-07: the fling curve was the identity function
|
||
|
||
You said the fling "seems to just be linear velocity with an abrupt stop."
|
||
It was, exactly: `android_fling_spline`'s lookup returned `t` for every
|
||
`t`. Two halves of AOSP's spline build loop had been transposed, which made
|
||
its two tables identical, and the lookup interpolated one against the
|
||
other -- which reduces algebraically to `t`. So a fling coasted at its
|
||
release speed for the whole (correctly computed) duration and stopped dead
|
||
at the end of it.
|
||
|
||
Ported exactly now from `OverScroller.java` and Compose's
|
||
`SplineBasedDecay.kt`, which agree line for line. One public addition:
|
||
|
||
**`FlingCalculator::velocity_at(velocity, elapsed) -> f32`**, beside the
|
||
existing `position_at` -- AOSP's `mCurrVelocity` and Compose's
|
||
`FlingInfo.velocity`. It is what makes "is this decelerating" answerable
|
||
rather than inferred, and it is what `List::tick_fling`'s new
|
||
`iris fling tick:` debug line reports each frame.
|
||
|
||
The lesson worth keeping, since it cost two builds on your phone: every
|
||
test the calculator had compared it with itself -- monotonic, correctly
|
||
signed, integrates to the closed form, per-tick deltas non-increasing --
|
||
and **all of them pass on a straight line**. The numbers now come from
|
||
`iris/benches/fling_spline_reference.py`, a separate hand transcription of
|
||
the two sources, checked in beside the tests.
|
||
|
||
## 2026-09-07: the Android insets bridge counts its own dispatches
|
||
|
||
`AndroidUiState::insets_report() -> String` is new, and the bench app's
|
||
Diagnostics pane shows it. It carries the last insets plus **how many times
|
||
the platform has delivered any**, because "the keyboard did not push
|
||
anything up" has two causes that look identical on screen -- the listener
|
||
never fired, or it fired with a zero height -- and you have no logcat on
|
||
the phone. `dispatches=0` prints a sentence saying so rather than the
|
||
numbers, which would be defaults rather than measurements.
|
||
|
||
## 2026-09-07: widgets can animate, and a fling finally moves
|
||
|
||
Iris's phone said "fling still doesn't work" twice. The velocity was only
|
||
half of it: **nothing in iris advanced an animation between input
|
||
events**, so `List::fling` stored a speed that nothing ever applied. Three
|
||
public changes come out of fixing that.
|
||
|
||
**`Widget::tick(&mut self, now: Instant) -> bool`** is a new trait method,
|
||
defaulted to `false`, so no existing widget changes. A widget that
|
||
overrides it is animating; answering `false` is how it stops.
|
||
|
||
**`UiData::animate(id)` and `UiData::tick_animations(now) -> bool`** are
|
||
the registry and its driver. A gesture that starts an animation registers
|
||
the widget; each backend calls `tick_animations` once per frame before the
|
||
draw and asks for another frame while it answers `true`. That answer is
|
||
the *only* thing in iris that makes a frame happen without an input event,
|
||
and an animation's path out is its own `tick` returning false -- nothing
|
||
has to remember to unregister it.
|
||
|
||
// before: the velocity was stored and never applied
|
||
list(ui).fling(-v);
|
||
// after
|
||
list(ui).fling(-v);
|
||
let id = list.id();
|
||
ui.ui_mut().animate(id);
|
||
|
||
The two calls are deliberate rather than folded into `fling`: the velocity
|
||
is the list's business and whether anything animates at all is the frame
|
||
loop's, and a caller driving its own frames (the benchmark, the headless
|
||
tests) still calls `tick_fling` directly.
|
||
|
||
**`FlingCalculator` needs the real display density, and its coefficient
|
||
was wrong.** `new(density)` takes physical pixels per `dp` and the
|
||
velocity handed to it must be in those same physical pixels -- the
|
||
density does *not* cancel out, contrary to what that type's doc used to
|
||
claim. Separately, `physical_coefficient` multiplied by the scroll
|
||
friction (0.015) where AOSP multiplies by its own tuning constant 0.84, a
|
||
factor of 56 inside an exponential. Together they gave an ordinary flick a
|
||
**45-second** coast, which nobody could see while flings never animated.
|
||
`List` reads its density from the painter now, and
|
||
`a_flick_lasts_what_aosps_own_formula_says_it_does` pins the absolute
|
||
numbers (0.59s and 621px for 3000px/s at density 2.75) against AOSP's
|
||
formula -- the check every previous test could not make, because they all
|
||
compared the calculator with itself.
|
||
|
||
**`MOVE_CHAIN_LIMIT` is 64, not 16**, in `render_state.rs` and
|
||
`shader.wgsl` alike. It bounds a walk so a cyclic `parent` cannot hang
|
||
either side; it was never meant as a claim about tree depth, and the
|
||
transcript screen's composer field sits 17 slots below the root. Past the
|
||
bound both walks silently stop summing, so a widget draws and hit-tests
|
||
short with nothing to say so; the CPU assert now prints the chain, so a
|
||
cycle and a deep tree can be told apart.
|
||
|
||
## 2026-09-06: tool cards, `ToolState`, and a screen that knows whether its session is working
|
||
|
||
`transcript_ui::tool` is new: a card per tool call, a group per run
|
||
(P1b). Three things in the public surface follow from it.
|
||
|
||
**`client_core::transcript_fold::ToolState`** is what a card colours
|
||
itself by -- `Running`, `Deciding`, `Succeeded`, `Failed`, `NoResult` --
|
||
built by `ToolState::of(&item, session_working)`. The pair it exists for
|
||
is `Succeeded` against `NoResult`: a call that finished having printed
|
||
nothing and a call whose result never arrived both leave an empty
|
||
`output`, and drawing them the same way states a verdict nobody reached.
|
||
Only the session's own status separates them, which is why `of` takes it.
|
||
|
||
**`event_model::Event::ToolEnd` gained `is_error`** (`#[serde(default)]`,
|
||
so an older transcript still parses), and
|
||
`client_core::transcript_fold::TranscriptItem::ToolRun` gained `failed`.
|
||
Without them a result was everything a card knew and a broken call drew
|
||
exactly as confidently as one that worked -- the missing state, not a
|
||
wrong one. Every construction site of both had to gain a field; the value
|
||
comes from the CLI's own `tool_result`, read in one place
|
||
(`import::tool_result_is_error`) by both the live translator and the
|
||
import replay.
|
||
|
||
**`TranscriptScreen::set_session_working(rsc, bool)`** is new, and is the
|
||
only thing that writes it. Before: a card with no result was drawn the
|
||
same whether its turn was still going or had been interrupted. After:
|
||
only the *newest* row can say "running", because every row behind it
|
||
belongs to a turn that has ended, and changing the flag redraws that one
|
||
row rather than the screen. `TranscriptScreen::expand_tail_tools(rsc,
|
||
bool)` joins it, answering whether there was a tool run to act on -- a
|
||
group's expanded appearance is otherwise unreachable from anything that
|
||
cannot press the screen.
|
||
|
||
**`transcript_ui::row::build_row` now returns a `TailRow`** rather than an
|
||
`Option<RowBlocks>`: `Blocks` for a message (a delta costs the last
|
||
markdown block) or `Tools` for a run (an arriving result costs one card).
|
||
One mechanism for "what can this row change cheaply", asked of the row
|
||
rather than decided again at each call site. It also takes the row's own
|
||
`working` flag.
|
||
|
||
Two smaller ones. `client_core::tool_summary::parse_tool_input` is
|
||
`ToolInput.kt`'s subject/description/timeout/rest split, and
|
||
`client_core::durations::format_millis` is `Durations.kt`'s -- both pure,
|
||
both with the Kotlin's own tests ported.
|
||
|
||
## 2026-09-06: a tap is its own gesture outcome, and opening a URL is a backend capability
|
||
|
||
Three related additions, all for following a markdown link.
|
||
|
||
**`iris::platform::OpenUrl`** is a new trait beside `attr::FocusHost`, and
|
||
has the same shape: declared in `iris`, implemented once per backend (a
|
||
detached `xdg-open`/`open`/`start` on the desktop, an `ACTION_VIEW` intent
|
||
on Android, deferred to the next view callback exactly the way
|
||
`pending_show_keyboard` is). A widget asks for the capability by bound --
|
||
`Rsc::State: FocusHost + OpenUrl` -- instead of a caller threading a
|
||
callback down through every builder. One method, not a general "run an
|
||
intent": a narrower capability is a narrower thing to get wrong. Nothing
|
||
is returned; the platform either shows a browser or does not, and both
|
||
are outside the process.
|
||
|
||
**`GestureOutcome::Tapped`** is new. `Released(None)` used to mean both
|
||
"the press ended having selected something" and "the press ended having
|
||
done nothing at all", and only the second is a tap. Any caller that acts
|
||
on a tap -- following a link -- must not also act when the finger was
|
||
panning the list past that link, so the distinction is made once, in the
|
||
gesture machine every widget already shares, rather than timed again per
|
||
widget. `DragArbiter::is_undecided()` is what answers it.
|
||
`Selection::drag` returns the outcome now instead of `()`.
|
||
|
||
**`DragArbiter`/`DragGesture` take an axis** (`::on(Axis)`; `::new()` is
|
||
still vertical). A code fence pans across its own long lines exactly the
|
||
way a transcript pans down its rows, and the two were the same state
|
||
machine with `dx` and `dy` swapped. `WidgetLike::scrollable_on(axis)`
|
||
joins `scrollable()` for the same reason. Before this, a horizontal
|
||
`Scroll` existed but could not be dragged by a finger at all -- its
|
||
arbiter only ever committed on the vertical axis.
|
||
|
||
Two smaller ones in the same pass. **`TextEditCtx::byte_at(pos, size)`**
|
||
answers which byte of the text a tap landed on, doing the same
|
||
region-relative transform `select` does, without handing out the parley
|
||
layout a caller could shape against stale text. And **`Rect::radius` now
|
||
takes a `Len`**, so a corner can be written in `dp` and come out the same
|
||
physical size on every display; a bare number still means physical pixels.
|
||
|
||
**One behaviour change worth knowing about**: `Rect::is_size_independent()`
|
||
answers `false` now. It answered `true`, and a `Rect` fills whatever
|
||
region it is given -- so `draw_inner`'s fast path, which rewrites a
|
||
widget's primitives in place instead of redrawing it, could not reproduce
|
||
what `draw` would have done. A `.background(rect(..))` behind
|
||
variable-height content kept the size of the provisional pass its parent
|
||
`Span` had drawn it at, which on the transcript screen meant one code
|
||
block's panel covering every block below it. Costs one primitive's redraw
|
||
when a rect is resized.
|
||
|
||
## 2026-09-06: a transcript row is a column of blocks, and a block is the selection unit
|
||
|
||
`transcript-ui`'s row builder used to make **one** `TextEdit` per message.
|
||
It makes one per top-level markdown block now -- heading, paragraph,
|
||
fenced code, list, table -- in a `Span::down`, because a streamed delta
|
||
into a single buffer re-shaped the whole message through parley on every
|
||
event. `client_core::markdown_blocks::split_blocks` does the splitting;
|
||
`row::RowBlocks::apply_delta` updates the block a delta lands in and
|
||
leaves the rest of the message's layout alone.
|
||
|
||
**The change to judge, since it is what a reader feels**:
|
||
`Selection` is keyed by `SelKey = (RowKey, u32)` -- a row and a block --
|
||
so **a block, not a row, is the unit a selection steps in**. A drag still
|
||
runs from a reply into the tool output beneath it and copies as one
|
||
thing; what changed is that the row under the finger is filled in block by
|
||
block rather than all at once, which is if anything closer to what the
|
||
old shortcut in `Selection`'s module doc was apologising for. `register`
|
||
takes a `SelKey`; `unregister` still takes a `RowKey` and now drops every
|
||
block of it (dropping only the first is how a freed widget gets left in
|
||
the map -- the shape docs/REVIEW-2026-09-06.md's finding 1 called out).
|
||
|
||
`Selection::locate(ui, render, pos_window)` is new: which block is under a
|
||
window position, with that block's own local position and size. The
|
||
list-level handler uses it for the pointer-captured half of a drag,
|
||
instead of computing a row-local position from `List::extent`.
|
||
|
||
`row::build_row` returns `(RowKey, StrongWidget, Option<RowBlocks>)` --
|
||
the third is the per-block state a caller keeps only for the row a reply
|
||
is streaming into, and is `None` for a tool run, which never streams.
|
||
|
||
## 2026-09-06: a reported `Size` may not carry `dp`; `Len::fold_dp`
|
||
|
||
**New: `Len::fold_dp(density) -> Len`** -- the same fold `apply_rest` does
|
||
(`dp` becomes physical pixels), but staying a `Len` so `rest` survives.
|
||
|
||
**New rule, and it is a rule about every widget, not about the two that
|
||
broke it**: a `Len` a widget *reports* from `draw` must not carry an
|
||
unresolved `dp`. `dp` is an input unit -- a number the widget author wrote
|
||
-- and the containers that consume a reported length read `abs`, `rel` and
|
||
`rest` straight off it (`Span`'s placement arithmetic, `Pad`'s addition),
|
||
so a reported `dp` is silently worth **zero**. `MaxSize` and `Sized` both
|
||
returned the caller's declared `Len` as written; a `.max_height(dp(168))`
|
||
therefore gave its child a slot of nothing the moment the cap actually
|
||
applied, which is what made the composer's bar collapse. Both put their
|
||
declared lengths through `fold_dp` now, and
|
||
`UiRenderState::draw_inner` `debug_assert!`s the invariant after every
|
||
`Widget::draw`, so a widget that gets this wrong says so at the mistake
|
||
rather than laying out at zero somewhere else.
|
||
|
||
Nothing changes for a caller: `.max_height(dp(48))` is written the same
|
||
way. It is only widget *authors* who now have a rule to follow, and a
|
||
debug build that enforces it.
|
||
|
||
## 2026-09-06: `Painter::set_mask` reuses one slot; `ActiveData` gains two fields
|
||
|
||
**`Painter::set_mask(region)` allocates its widget's mask slot once and
|
||
rewrites it in place** on every later draw, instead of pushing a new one
|
||
each time. It has to: `draw_inner`'s unchanged-region fast path does not
|
||
revisit a descendant whose own region did not change, so those descendants
|
||
go on referencing whichever slot they were first drawn under. Pushing a
|
||
fresh slot per draw left the composer's field clipped to a box the bar had
|
||
long since moved away from -- four live mask entries, none of them the
|
||
`Masked`'s current region -- and it drew nothing at all. Same call, same
|
||
signature; only the lifetime changed.
|
||
|
||
**`ActiveData` gains `own_mask` and `move_applied`** (both public, since
|
||
`ActiveData` is). `own_mask` is the slot above, `MaskIdx::NONE` for a
|
||
widget that sets no mask. `move_applied` is how much of a widget's own
|
||
move-slot delta its `region` already accounts for: `mov` shifts both,
|
||
`Painter::reposition` shifts only the slot, and `resolved_region` -- and so
|
||
every hit test -- has to subtract it. Without that a widget that had been
|
||
panned had its *own* hit box at twice the pan while its descendants were
|
||
correct, which made the composer's field untappable after a finger drag.
|
||
|
||
## 2026-09-06: `Scroll` pans on a finger drag, and a vertical drag in a focused text field no longer selects
|
||
|
||
Three related public changes, all in aid of IRIS_TODO.md's "the composer
|
||
has no touch-drag scroll".
|
||
|
||
**`Scroll::drag(render, id, sense, pos_window, now)` is new**, and
|
||
`WidgetLike::scrollable()` now registers it alongside the wheel handler it
|
||
already registered -- so anything built with `.scrollable()` pans on a
|
||
finger drag with no extra wiring at the call site. It goes through the same
|
||
`sense::DragGesture` that `transcript-ui::Selection::drag` drives `List`
|
||
with (arbitration, `DRAG_SLOP`, velocity, pointer capture), rather than a
|
||
second copy of that widget's wiring: `DragGesture` owns the mechanics and
|
||
each caller decides only what a committed pan *means*. `Scroll::amt()` is
|
||
new too, the read-only pan position a test or a scroll indicator needs.
|
||
|
||
There is deliberately **no fling** on `Scroll`. Unlike `List` it has no
|
||
per-frame tick to animate one with (`List::set_redraw_handle`/`tick_fling`),
|
||
and the areas it wraps today are at most a screenful, where Android does not
|
||
fling either. The released velocity is dropped rather than approximated.
|
||
|
||
**A vertical drag inside an already-focused `TextEdit` no longer extends a
|
||
selection.** `iris::attr`'s `on_press` used to treat a focused field as the
|
||
plain `click_or_drag` case -- every `Pressing` frame updated the selection.
|
||
It now applies the same `DRAG_SLOP` rule the *unfocused* branch already
|
||
applied: a press that moves past the slop vertically abandons its pending
|
||
selection for the rest of the gesture, so the scroll area around the field
|
||
gets the drag instead. Horizontal drag-to-select is unchanged, and a long
|
||
press still starts a selection. This is Android's own `EditText` behaviour
|
||
(a vertical drag scrolls; only a long press selects), and it is what makes
|
||
"swipe up over the composer to scroll the transcript" work without dragging
|
||
a highlight through the message you were typing.
|
||
|
||
**`UiRenderState::orphaned_primitives()` is new**, and `update` now
|
||
`debug_assert!`s (debug builds only) that nothing is orphaned. An orphan is
|
||
a primitive still bound for the GPU that no live `ActiveData` names -- a
|
||
copy nothing can move, clip or free. That was the doubled `Compacted:` row
|
||
on the phone; see the same date's commit `76b1f99` and docs/RUST.md. The
|
||
per-frame guard is a count comparison (O(active widgets)); the walk that
|
||
names the offenders only runs when the counts disagree, because the walk is
|
||
O(primitives) and made a debug build on a phone too slow to finish a
|
||
benchmark run.
|
||
|
||
## 2026-09-06: a tap on a text field always leaves a caret
|
||
|
||
`TextEditCtx::select` used to compare the tap position against the
|
||
*laid-out text's* own box and set `selection = None` for anything outside
|
||
it. A press only reaches `select` after being hit-tested to the widget, so
|
||
that "outside" meant the field's own padding -- or, for an **empty** field,
|
||
everything, since an empty layout is a zero-width box. So tapping an empty
|
||
composer focused it and opened the keyboard while leaving no caret, and
|
||
`TextEditCtx::insert`/`insert_str` return early with no caret: every
|
||
keystroke was dropped in silence, and no glyph ever appeared. Parley's
|
||
`from_point`/`extend_to_point` already clamp a point outside the layout to
|
||
the nearest cursor position, which is also what a tap in a field's padding
|
||
should do.
|
||
|
||
Behaviour change a caller would notice, in one line: **`select` with a
|
||
non-drag position now always produces a selection; it no longer clears
|
||
one.** Clearing is `TextEditCtx::deselect`, which is what the backends'
|
||
focus handling already calls. A drag is unchanged -- with no previous
|
||
selection there is still nothing to extend, so it produces none.
|
||
|
||
`insert_str` also gained a `debug_assert!` for the no-caret case, so an
|
||
insert routed to an unfocused field fails at the mistake in a debug build
|
||
instead of silently swallowing input.
|
||
|
||
## 2026-09-06: `List::anchor_position_display`## 2026-09-06: `List::anchor_position_display`, `FrameReport::mark_phase`/`phase_stats`/`late_at_hz` (RUST.md's "Benchmark v2")
|
||
|
||
`List` gained `anchor_position_display(&self) -> String`, reporting the
|
||
anchor's own row index and pixel offset (`idx=N/off=Mpx`, or
|
||
`idx=more-before`/`idx=more-after`/`idx=none`) -- what a scripted
|
||
benchmark reads to report fling travel. Note the anchor does not
|
||
necessarily change *slot* over a long scroll (this widget's own documented
|
||
design: the anchor is a stable identity, not re-derived from what's on
|
||
screen each frame), so this is not the same measurement as a Compose
|
||
`LazyListState.firstVisibleItemIndex`, which does track the true topmost
|
||
visible row -- the `off` half is what actually reflects how far a fling
|
||
travelled.
|
||
|
||
`iris_core::render::frame_report::FrameReport` gained three methods for
|
||
per-phase benchmark reporting: `mark_phase(name)` records a named phase
|
||
boundary at the current frame/instant; `phase_stats(now, refresh_hz)`
|
||
returns one `PhaseStats` (frames, wall duration, late count/percent,
|
||
p50/p90/p99, worst) per marked phase, sliced from the existing ring by a
|
||
new parallel `index_ring`; `late_at_hz(refresh_hz)` gives the whole run's
|
||
late count/percent judged against an arbitrary refresh rate rather than
|
||
the fixed 60Hz `JANK_THRESHOLD` every existing caller still uses (a
|
||
separate method, not a parameter on `report()`, so nothing else changes
|
||
behaviour). `RING_CAPACITY` grew 4096->16384 to hold a full multi-phase
|
||
run without evicting earlier phases' samples.
|
||
|
||
## 2026-09-06: `List::fling`, `VelocityTracker`, `FlingCalculator` (IRIS_TODO.md's "swiping has no momentum")
|
||
|
||
`iris::widget::List` gained a real fling: `fling(velocity_px_per_s)` starts
|
||
one (cancelled by the next touch-down via `cancel_fling`, or automatically
|
||
once it settles or reaches loaded content's start/end), `is_scrolling()`
|
||
reports whether one is running, and `tick_fling(now: Instant) -> bool`
|
||
advances it and returns whether it is still going -- a caller that owns a
|
||
`RequestRedraw` handle can hand it to the list once via the new
|
||
`set_redraw_handle`, after which `List` re-arms its own next frame while
|
||
flinging with no further polling needed; a caller driving a scripted
|
||
benchmark instead calls `tick_fling` itself in a loop, same as it already
|
||
drives `scroll`.
|
||
|
||
The physics is `iris::sense::FlingCalculator` + `VelocityTracker`
|
||
(`sense.rs`, beside `DragArbiter`): a port of AOSP `SplineOverScroller`'s
|
||
deceleration curve (the same one Compose's own `ScrollableDefaults.
|
||
flingBehavior()` uses), cited at the definition, so a fling here travels
|
||
the same distance a Compose `LazyColumn` would for the same initial
|
||
velocity. `VelocityTracker` estimates that velocity from the drag's last
|
||
~100ms of samples rather than one frame's last delta. Unit-tested:
|
||
velocity from known samples, fling distance/duration against the closed-
|
||
form spline result (within 1%), cancel-on-touch, and the start/end clamp
|
||
(a fling stops rather than scrolling into content that was never loaded).
|
||
|
||
Before: a touch-drag panned exactly as far as the finger moved and stopped
|
||
dead on release. After: releasing mid-drag continues scrolling and
|
||
decelerates, matching the muscle memory every other Android scroll view
|
||
already trained. `transcript_ui::selection::Selection::drag` wires this in
|
||
-- a release only flings if the gesture had committed to panning
|
||
(`DragArbiter::is_panning`, new), never a selection or an undecided tap.
|
||
|
||
## 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`:
|
||
|
||
```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()`. **A caller that keeps its own
|
||
row-keyed side table alongside `List` (`Selection`'s `rows:
|
||
BTreeMap<RowKey, WeakWidget<TextEdit>>` is the one this crate has) must
|
||
clear it in step with `List::clear()`** — the fallback drops every row
|
||
`List` was holding, so any side table not cleared the same way is left
|
||
pointing at widgets the clear just freed (docs/REVIEW-2026-09-06.md
|
||
finding 1, fixed 2026-09-06 by `Selection::clear()`, called from
|
||
`apply`'s `Rebuild` arm right before `List::clear()`). `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).
|
||
|
||
## 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).
|
||
|
||
- **`TextData` now 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 the `SansSerif`/`Monospace` generic-family
|
||
lists, rather than relying on the platform's font enumeration alone.
|
||
`TextData::font_diagnostics() -> FontDiagnostics` reports 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 linking `iris-core`; `build-apk.sh`'s own output says the
|
||
delivered (compressed) number.
|
||
- **`UiRenderNode::new`/`resize` now take the window size explicitly**
|
||
(`window_size: impl Into<Vec2>`) instead of deriving it from the
|
||
surface's physical `SurfaceConfiguration`. Existing callers pass a
|
||
*logical* size (physical ÷ density/scale-factor) now; this is what makes
|
||
a `font_size: 16.0` 16 dp instead of 16 raw device pixels on a
|
||
high-density phone. Before this, `scale_factor` did not exist anywhere
|
||
in the crate, on either platform.
|
||
- **`AndroidUiState::content_scale: f32`** (`DisplayMetrics.density`, read
|
||
once in `new_peer`) and the desktop equivalent (`window.scale_factor()`)
|
||
now divide every physical-pixel number before it reaches layout or
|
||
touch handling — see `content_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 from `render()` exactly when
|
||
`AndroidUiState::insets()` changes. Nothing previously consumed
|
||
`insets().top` at all; a screen with chrome under the status bar
|
||
implements this to pad it, in the same logical units `content_scale`
|
||
converts everything else to.
|
||
- **New: `iris_core::WgpuErrorLog`**, installed via `Device::
|
||
on_uncaptured_error` on the Android device (wgpu's default handler is an
|
||
unconditional panic outside `UiRenderNode::new`'s own error scopes).
|
||
Explicit `Arc`-backed value passed to the callback and kept on
|
||
`AndroidRenderer`, not a global — a caller wanting one on desktop builds
|
||
its own the same way.
|
||
|
||
## 2026-09-06: `Len::dp`, physical pixels throughout, the keyboard glyph wipe
|
||
|
||
Iris's phone report on build a9232ac (screenshots): text now the right
|
||
size but blurry; the keyboard still wipes every glyph; the header buttons
|
||
have nothing behind them. All three are fixed; this entry is the public
|
||
API side. docs/LAYOUT.md has the layout-side writeup, docs/RUST.md's P0
|
||
box has the full investigation and the phone verification still to do.
|
||
|
||
- **The keyboard wipe was `surface_changed` rebuilding the whole renderer
|
||
on every resize**, including an IME-driven one — a fresh, empty glyph
|
||
atlas while the CPU-side glyph cache kept UV coordinates from the old
|
||
one. `surface_changed` now calls `AndroidRenderer::resize` (reconfigures
|
||
the surface and window uniform only) when a renderer is already live,
|
||
and only builds a new one when there genuinely isn't one yet.
|
||
- **`Len` has a third field, `dp`** (Android's dp / CSS's reference pixel,
|
||
1/160in), beside the existing `abs` (now explicitly *physical* pixels)
|
||
and `rel`/`rest`. `len_fns::dp`/`Len::dp` construct one, used exactly
|
||
like `abs`/`rel`/`rest` — `dp(16)` instead of a bare `16` wherever a
|
||
size should look the same physical size on any density. This is the
|
||
unit IRIS_TODO.md's "density-independent length unit" item asked for;
|
||
it replaces the previous stopgap (the whole rendered scene divided by
|
||
`content_scale` then implicitly stretched back up), which is also what
|
||
made text blurry — a glyph rasterised at the small, pre-stretch size and
|
||
then upscaled onto the real framebuffer.
|
||
- **`UiRenderState`/`Painter` gained `density()`/`set_density()`** (physical
|
||
pixels per dp). Every place a length resolves (`Len::apply_rest`,
|
||
`Size::to_uivec2`) now takes it; `Span::gap` and `Padding`'s four sides
|
||
moved from a bare `f32` to `Len` so they take `dp(...)` too. A bare
|
||
number anywhere is unaffected — still `abs`, physical pixels.
|
||
- **Text is rasterised at physical resolution now.** `TextBuffer::shape`
|
||
takes `density` and multiplies `font_size`/`line_height` (and any span
|
||
override) by it before handing them to parley, so the atlas holds a
|
||
bitmap at the size it is actually shown at rather than a low-resolution
|
||
one stretched afterward.
|
||
- **Everything at the Android boundary is physical pixels now** — window
|
||
size, touch coordinates, insets (`LogicalInsets` renamed
|
||
`WindowInsets`). The previous "logical" division by `content_scale` is
|
||
gone; `content_scale` now feeds `set_density` instead.
|
||
- Not yet verified on Iris's actual phone (this pass had no device) —
|
||
built and checked on this checkout's emulator only. RUST.md's P0 box
|
||
says what she should check for: crisp text at two densities, the
|
||
keyboard no longer wiping, and the header's background.
|
||
|
||
## 2026-09-06: composing text, focus-on-tap, and atlas invalidation on a new renderer
|
||
|
||
Three small but public API changes, from the same phone-report pass as the
|
||
entry above (RUST.md's P0 box has the full account, including a real bug
|
||
still not root-caused).
|
||
|
||
- **`FocusHost` gained `is_focused(&self, id) -> bool`** (both platform
|
||
impls). `attr.rs`'s `Selector`/`Selectable` used to grant focus (and so
|
||
request the IME) on the very first frame of *any* press, before it was
|
||
known whether the gesture was a tap or a drag — a swipe over a text
|
||
field wrongly summoned the keyboard. They now wait for a completed tap
|
||
(press and release with no frame crossing `sense::DRAG_SLOP`) unless the
|
||
field is already focused, in which case dragging inside it to select
|
||
text is unchanged. `TextEdit` gained one new `pub(crate)` field
|
||
(`press_origin`) to track this; no public surface change there.
|
||
- **`android::ime`'s `InputConnection` now calls `InputMethodManager::
|
||
updateSelection` after every edit** (`IrisViewPeer::update_ime_selection`,
|
||
called from `after_input`). Gboard was holding keystrokes back because
|
||
nothing ever told it where the app's own selection/composing region had
|
||
moved to — this is what android-view's own demo does in its `render()`
|
||
and this bridge never did.
|
||
- **`GlyphAtlas::clear()` and `Textures::reset()`** (`iris_core`). Called
|
||
together, once, from `android::view`'s `surface_changed` exactly when a
|
||
*genuinely new* `AndroidRenderer` is built (backgrounding and returning,
|
||
not a keyboard-triggered resize, which already reuses the renderer) —
|
||
both CPU-side caches otherwise kept pointing at the old, now-destroyed
|
||
device's textures, which is why text used to vanish again after leaving
|
||
and returning to the app.
|
||
|
||
## 2026-09-06: `take_counters` counts text layouts too
|
||
|
||
One public API change, from the verification pass over the composer-scroll
|
||
and per-block-row work (RUST.md's "Verification pass over Tasks A and B").
|
||
|
||
- **`UiRenderState::take_counters` returns four numbers, not three**:
|
||
`(draws, region rewrites, move writes, **text shapes**)`. The new one is
|
||
bumped in `Painter::render_text`, which `TextView::render` only reaches
|
||
on a cache miss, so it counts layouts actually computed rather than
|
||
layouts asked for. Callers destructuring the tuple need one more `_`.
|
||
|
||
It exists because a draw counter cannot answer the question the
|
||
per-block transcript row was built for. A widget can be redrawn without
|
||
re-shaping (the layout is memoized by width) and re-shaped without any
|
||
extra draw, and re-shaping is the expensive half — so "a streamed delta
|
||
costs one block" was, until now, argued from the code rather than
|
||
measured. With the counter it is a test: one delta into a 100-paragraph
|
||
reply shapes exactly **1** text layout, the same as into a
|
||
one-paragraph one.
|
||
|
||
## 2026-09-07: `iris::diagnostics` -- a trace toggle for input/frame lines, gating four existing per-frame `debug!` calls
|
||
|
||
One new public module and one behaviour change to four existing log
|
||
lines, from Iris's "add another button to copy input event info ...
|
||
instrument a lot of the code with timings" request (RUST.md's own
|
||
section has the full account).
|
||
|
||
- **`iris::diagnostics::set_trace(bool)`/`trace_enabled() -> bool`**, a
|
||
process-global switch, off by default. It gates two new diagnostics
|
||
(`sense::log_input_event`, one line per platform pointer sample under
|
||
target `iris::input`; `diagnostics::log_frame`, one line per frame
|
||
under `iris::frame`, with the frame number, the frame clock, time
|
||
since the last input, layout/draw durations, `RedrawKind`, primitives
|
||
on screen, and whether something is animating) and, as of a same-day
|
||
review finding (D1), four *older* `debug!` lines that were previously
|
||
unconditional: `android::view`'s two `render():` lines, `widget::
|
||
list`'s `iris fling tick:`, `widget::text`'s `iris text render:`, and
|
||
`sense`'s `iris drag release samples:`. Not `log::log_enabled!`/
|
||
`log::set_max_level`, because the app installs its logger at
|
||
`LevelFilter::Debug` already and the ring records everything that
|
||
level lets through regardless of target — the gate has to live on
|
||
this side. **Not wired to a control**: the Diagnostics pane is in
|
||
`bench_client.rs`, off-limits while another agent had it open; this
|
||
is the whole surface a button needs.
|
||
- **`UiRenderState` gained `RedrawKind`, `frame_number()`, `epoch()`,
|
||
`last_layout_duration()`, `last_redraw_kind()`,
|
||
`active_primitive_count()`, `note_input(Instant)` and
|
||
`time_since_input(Instant) -> Option<Duration>`** (`iris-core`). All
|
||
read back by `log_frame`; `note_input` is called once from
|
||
`SensorUi::run_sensors`, which both backends and the harness already
|
||
share, so a frame's `since_input` is comparable across all three
|
||
without either platform doing its own bookkeeping.
|
||
- **`iris::harness::TouchAction` gained `word() -> &'static str`**, the
|
||
inverse of its own `parse` -- what a caller (here, `Harness::touch`)
|
||
hands the input logger so a `.touch` file and an `iris::input` line
|
||
agree on one spelling of each action.
|
||
- **`iris_core::Axis` gained `Debug`** — a one-line derive, needed to log
|
||
which axis a drag committed to.
|
||
- **`iris/benches/report_to_touch.py`** (new): turns a report's
|
||
`iris::input` lines back into a `.touch` file, expanding inline
|
||
historical samples into their own lines first. Round-tripped against
|
||
the harness in `iris/transcript-fixture/tests/input_log_roundtrip.rs`.
|
||
|
||
## 2026-09-07: the phone app is told which server to talk to, and pins from the link
|
||
|
||
Not an iris API change -- a client-facing one, in the crates around it,
|
||
worth knowing because it changes what a build of the Android app *is*.
|
||
|
||
- **An iris APK is no longer tied to the machine that compiled it.** It
|
||
used to have the server's host, port, token and CA compiled in, which
|
||
made a build good for exactly one emulator/server pair and put a token
|
||
in the artifact. Now it registers `aiapp://enroll` like the Compose app:
|
||
open the link (Dev Updater's Enroll button already offers it, and the
|
||
phone asks which app should take it) and the app stores where to go and
|
||
what to trust.
|
||
- **The CA rides in the link** as `&ca=<base64url DER>`, which is what
|
||
makes the above possible at all -- a pinned certificate cannot be baked
|
||
into an APK cross-compiled somewhere else. Optional, so the projects
|
||
that do build on their own machine keep the short link and the small QR.
|
||
docs/DECISIONS.md, 2026-09-07, has why not a fingerprint.
|
||
- **`client_core::config` now holds the storage as well as the parsing**:
|
||
`EnrolledServer` gained an optional `ca_pem`, and `EnrollmentStore` (the
|
||
0600 JSON file, moved out of `desktop-app`) is one implementation for
|
||
both the desktop and the phone -- only the directory differs.
|
||
`desktop-app --ca` is now the override for a link that carried no CA
|
||
rather than a required flag.
|
||
|
||
## 2026-09-08: a new GPU device re-uploads its textures instead of forgetting them, and a mark is one texture per shape
|
||
|
||
Two defects with one cause: **`widget::mark` built a texture per widget**,
|
||
so a transcript screen had one 48x48 standalone image, one bind group and
|
||
one draw call *per folded card* rather than one per picture -- and the
|
||
Android surface-rebuild path assumed no long-lived widget held a texture
|
||
handle at all.
|
||
|
||
- **`Textures::reset` is gone; `Textures::reupload` replaces it.** A new
|
||
GPU device holds none of the old one's textures, but this side still
|
||
holds their pixels, so the answer is to queue every slot for upload
|
||
again in slot order (empty slots included, as `PushFree`, so the
|
||
indices after a hole still land where they were) rather than to throw
|
||
the slot numbering away. Resetting left every live `TextureHandle`
|
||
naming a slot nothing recognised: the first frame after the emulator's
|
||
Vulkan-to-GLES fallback panicked with *"texture slot 89 is not a live
|
||
standalone image: None"*, before anything had been touched.
|
||
- **The glyph atlas is no longer cleared on that path either**, which
|
||
falls out of the same change: its pages are slots here and their pixels
|
||
are on this side, so re-uploading restores exactly the atlas that was
|
||
there. An app switch no longer re-rasterises every glyph on screen.
|
||
- **`Textures::shared(key, make)`** (new): the one texture for a
|
||
description, built on the first ask and handed out again after, keyed
|
||
by a `SharedTextureKey { owner, id }` the caller packs *exactly* rather
|
||
than hashes. The map holds its own reference, so a shared slot is never
|
||
freed and never recycled under a widget still drawing it. `mark()` is
|
||
its first caller: three marks now exist for the whole transcript screen
|
||
(open, closed, collapse) instead of one per card, and the rasterising is
|
||
paid once.
|
||
|
||
## 2026-09-08: an app's own log survives the process that wrote it
|
||
|
||
`devlog`'s provider could only ever show the run that was still up. After
|
||
a crash, Dev Updater's query starts the app process **for the provider
|
||
alone** -- no activity runs, so `MainActivity.nativeSetFilesDir` never
|
||
fired and the panic hook's file was never replayed. The Runtime tab
|
||
therefore showed one line, `iris devlog: serving this app's log at ...`,
|
||
which is exactly the run nobody needs.
|
||
|
||
- **`DevLogProvider.nativeReady` now takes the files directory too**, and
|
||
`app_log::set_crash_dir` is called from whichever of the provider and
|
||
the activity runs first (it deletes the file, so the second says
|
||
nothing).
|
||
- **The panic hook saves context, not just the panic**: the dying run's
|
||
last 80 log lines go into the file with it, and are replayed into the
|
||
new run's ring ahead of the panic line, so the Runtime tab reads
|
||
chronologically -- what the app was doing, then what killed it, then
|
||
this run. They are read with a new non-blocking
|
||
`LogRing::try_tail_text`, because a panic raised while the ring's own
|
||
lock was held would otherwise deadlock the hook and hang the process
|
||
instead of aborting it.
|
||
|
||
## 2026-09-08: iris ships an icon font, and `widget::mark` is gone
|
||
|
||
Iris's question -- "why does mark exist? The font should be working if
|
||
it's working for compose and nerd fonts are bundled" -- and its answer:
|
||
the Compose app draws icons from its own committed Nerd Fonts subset,
|
||
while iris was setting the disclosure mark with bare Unicode geometric
|
||
codepoints out of whatever face the platform resolved. So iris now does
|
||
what Compose does.
|
||
|
||
- **`iris::icon`** (new module): the codepoints iris draws, one constant
|
||
each -- `OPEN`, `CLOSED`, `COLLAPSE` today. Every one has to have a
|
||
matching entry in `iris/core/build-icon-font.sh`'s `GLYPHS`, which is
|
||
what builds the shipped `iris/core/assets/fonts/nerd_icons.ttf` (992
|
||
bytes, Material Design, Mono face). `every_icon_is_in_the_bundled_font`
|
||
fails the build if the two lists drift.
|
||
- **`Family::Icons`** (new variant): how any text asks for that family.
|
||
Before/after:
|
||
|
||
// was
|
||
mark(if open { Dir::DOWN } else { Dir::RIGHT }, 9.0, MUTED)
|
||
// now
|
||
text(if open { icon::OPEN } else { icon::CLOSED }, 9.0, MUTED)
|
||
.family(Family::Icons)
|
||
|
||
It names an intention, not a font name: only `TextData` knows what the
|
||
bundled file registered as, and it resolves the variant during shaping
|
||
(`TextData::resolve_family`, also public). A *named* family rather than
|
||
a generic one, so nothing falls back into it for ordinary text and an
|
||
icon cannot fall back out of it onto a system face that happens to have
|
||
the codepoint.
|
||
- **`iris::widget::mark` is removed** -- added earlier the same day and
|
||
superseded within it. It drew one correct triangle; every further icon
|
||
would have been another rasteriser, and an icon as text takes the size,
|
||
colour and baseline of the line it sits in for free.
|
||
- **`FontDiagnostics::icon_family`** (new field), in the startup log line
|
||
and the Diagnostics pane: which family the icons resolved to, so a
|
||
build whose bundled font failed to register says so instead of drawing
|
||
tofu.
|
||
|
||
This does not reopen the 2026-09-07 platform-fonts decision. Body and
|
||
monospace text still come from the platform's own collection; an icon is
|
||
the opposite case, a small closed set of codepoints no system font is
|
||
guaranteed to have, and it is the same division the Compose app makes.
|
||
|
||
## 2026-09-08: a press only reaches what the pointer is actually on
|
||
|
||
Iris's report -- "if I try to scroll vertically while a horizontal scroll
|
||
animation is still active, it stays locked to the horizontal scroll. It
|
||
should let it keep going and instead only affect vertical scrolling" --
|
||
and her own diagnosis of it, which was the right one: "it seems like iris
|
||
is set up so the animation stuff is global which it definitely should not
|
||
be. Tapping outside of something that a fling is currently active for
|
||
should have no code in common with the fling that could influence it."
|
||
|
||
It was global, and it was in `sense::should_run`. `run_sensors` runs a
|
||
widget one frame *after* the pointer leaves it (`ActivationState::End`,
|
||
which is not `Off`) so a `HoverEnd` can fire, and `should_run` derived
|
||
`PressStart`/`Pressing`/`PressEnd`/`Scroll` from the raw button and wheel
|
||
state without consulting `hover` at all. So that farewell frame carried a
|
||
press to a widget the finger was nowhere near.
|
||
|
||
That alone would have been a stray event; what made it eat the gesture is
|
||
the catch added on 2026-09-07 (`PressState::scrolling`), which commits a
|
||
press on already-moving content to a pan immediately, with no `DRAG_SLOP`
|
||
-- so the widget captured the pointer on that frame and every later sample
|
||
went to it. And the widget's hover was stale in the first place because a
|
||
gesture that ends while captured returns from `run_sensors`' capture
|
||
branch, which never reaches the loop that would have updated it.
|
||
|
||
Measured on the real screen before the fix: a fence flicked sideways, then
|
||
a finger put down on a row **500px above it** and dragged 160px down the
|
||
screen. The list moved by zero, the fence moved by zero, and the fence
|
||
held the pointer for the whole gesture -- the report, exactly.
|
||
|
||
- **`should_run` now requires `hover.is_on()` for every non-hover sense.**
|
||
Press and wheel both, since a wheel event reaching a widget the cursor
|
||
has just left is the same fault with a different sense. `Drop` and
|
||
`Cancel` are unaffected: they are delivered deliberately to a widget
|
||
that is *not* under the pointer, and `run_sensors` hands both an
|
||
explicit `On`.
|
||
- **`Scroll::is_scrolling`** (new): whether a fling is coasting in this
|
||
area, the same question and the same name `List::is_scrolling` already
|
||
answers for the other scrolling widget.
|
||
|
||
Nothing about the fling, the arbiter or the catch changed. A press outside
|
||
a coasting area now has no code in common with it, so the horizontal fling
|
||
keeps coasting through a vertical drag on its own -- which is the second
|
||
half of what Iris asked for, and it falls out of the fix rather than being
|
||
arranged. A press *inside* a coasting area is still a catch on either
|
||
axis, which is what Compose does ("Compose does catch no matter what axis
|
||
if you tap in the horizontal area").
|
||
|
||
Two tests, one per layer:
|
||
`sense_tests::a_press_does_not_reach_a_widget_the_pointer_has_just_left`
|
||
is the mechanism with two stacked scroll areas and no screen, and
|
||
`fence_fling.rs`'s
|
||
`a_drag_away_from_a_coasting_fence_scrolls_the_list_and_leaves_it_coasting`
|
||
is the report itself over the real transcript. Both fail on the old code.
|
||
|
||
## 2026-09-08: the composer is clipped to its bar, not inside its padding
|
||
|
||
Iris: "the message input box doesn't clip correctly ... the box should be
|
||
clipped rather than the inset text."
|
||
|
||
The composer was `.masked().background(rect(...))` -- two boxes, one
|
||
inside the other. The mask sat *inside* the `dp(FIELD_PAD_DP)` padding, so
|
||
a message longer than the six lines shown was cut through the middle of a
|
||
glyph 12dp in from the bar's edge, with a band of bare surface above the
|
||
cut. Measured at the phone's own size and density (1080x2424 at 2.55): the
|
||
bar's top edge at y=1995.6 and the text sliced at y=2026.2.
|
||
|
||
It is `.masked_by(rect(BAR_FILL))` now: the same rect is the surface drawn
|
||
behind the field *and* the shape the field is clipped to, so the two
|
||
cannot fall out of step -- the idiom `row.rs` already uses to cut a code
|
||
fence to its own rounded panel. Text now disappears under the bar's edge
|
||
at 1995.6. The padding still holds text off the edge at the end the
|
||
content is anchored to, which is the end anybody is reading.
|
||
|
||
The composer's overflowing and keyboard-open states had no way to be
|
||
looked at headlessly, since that window has no keyboard: the phone rig
|
||
takes `--message TEXT` and `--ime PX` for them
|
||
(`transcript-fixture/examples/phone.rs`, through `RUN_HEADLESS_ARGS`).
|