Author SHA1 Message Date
irisandClaude Opus 5 b7474f61b0 iris: scrolling belongs to Scroll, and a LazySpan only lays out
Steps 2 and 3 of the plan in docs/IRIS_TODO.md, together because
deleting the fling before `Scroll` could drive it would leave the app
unable to scroll at all. IRIS.md has the account and the measurements.

`LazySpan` loses its `Flinger`, its `density`, its
`Arc<dyn RequestRedraw>` -- which had no business existing in a
single-threaded frame loop -- its `tick`, and the whole
`fling`/`cancel_fling`/`tick_fling`/`is_scrolling`/`fling_velocity`
surface. `Scroll` was the only other `Flinger` user, so there is now one
implementation of the physics rather than two, and a transcript is
`list.scrollable_to_end()` like anything else.

Three new `Widget` methods carry the handoff:

    fn scrolls_itself(&self) -> bool { false }
    fn apply_scroll(&mut self, delta: &mut f32) {}
    fn scroll_offset(&self) -> f32 { 0.0 }

`Scroll` asks the first, and a child that says yes is handed deltas
instead of being slid about as a lump -- which a lazy layout cannot be,
since which rows exist at all is a function of where it is scrolled to,
and it has no content length to be clamped against. `scrolls_itself` is
`&self` deliberately: `Widgets::get_dyn_mut` marks a widget dirty, so
asking through `apply_scroll` would dirty every ordinary child on every
tick and cost exactly the O(1) move the scheme exists for.

`Scroll::draw` is measure, apply, place -- the idiom it already used for
its own content length. The measuring draw is free in the common case
(unchanged region, nothing dirty, `draw_inner` returns immediately and
the child's stored walls are still correct) and really walks exactly
when the content changed. Nothing is marked by hand: reaching the child
to hand it the delta is what dirties it, which is why `draw_again` could
stay deleted.

`scroll_offset` was not in the plan and is needed. A lazy span usually
cannot say where its content ends until it has walked there, so it takes
a delta in full whenever the wall is not already in view and the walk
gives part of it back; the remainder is exact only when the wall was
already visible, and `Scroll` adding remainders up would over-count by
every overshoot and never correct. It reads the child's accumulated
movement after the placing draw instead, so `amt` equals what is on
screen. `amt_counts_only_what_the_child_could_take` is the test.

One convention for a scroll delta, the finger's. `Scroll::scroll(+)`
moved toward the start while `LazySpan::scroll(+)` moved toward the end,
with the latter's doc claiming to mirror the former -- so every call site
had to know which it was talking to. `LazySpan::scroll` is private now
and the single negation is inside its `apply_scroll`; call sites that
passed `-dy`/`-v` pass them through, and `phone_screen.rs`'s recorded
velocity flips sign with its magnitude unchanged.
`a_negative_delta_moves_toward_the_end` pins the sign across the whole
handoff, since nothing else can catch a list scrolling backwards.

The transcript builds its `Scroll` by hand rather than through
`.scrollable_to_end()`: that helper registers a finger drag, and
`Selection` is already the arbiter for those frames -- two `DragGesture`s
seeing one gesture is what its own doc rules out. Caught by
`a_long_press_and_drag_selects_text`, which failed when both were live.

Deferred, in DECISIONS.md and IRIS_TODO.md: the *pin* is still each
widget's own. Applying one happens when a row is appended, between
frames with no painter in hand, so moving it to `Scroll` needs a fourth
`Widget` method or a parameter on `apply_scroll`; nothing external edits
a pin today.

Verified: cargo fmt --check, clippy --workspace --all-targets clean,
cargo test --workspace green (21 suites), the arm64 release APK builds,
and the phone-shaped headless window replaying flick-120hz.touch scrolls
back through the transcript in the direction it did before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 20:39:33 -04:00
irisandClaude Opus 5 8e5928cc6a iris: List becomes LazySpan, and takes a Dir
First of three steps agreed with Iris for getting scrolling out of the
list and into `Scroll`, so that `.scrollable()` is the one way anything
in iris scrolls. docs/IRIS_TODO.md's "In progress" block carries the
whole plan and the decisions behind it; this step is the rename and the
direction.

`List` -> `LazySpan`, and it moves in beside `Span` under
`widget/position/`. It is what `Span` is -- a sequence of children along
an axis -- laid out lazily from an anchor instead of eagerly from the
start, and the name says the one thing that matters about it. It also
stops colliding with `BlockKind::List` in the markdown code.
`ListRow` -> `LazyItem`; `RowKey` keeps its name, since rows are the
vocabulary in transcript-ui.

`Axis` -> `Dir`, with the sign meaning what it means in `Span`: which
end of the box item 0 sits at. **That is a different question from which
end the view is pinned to**, and conflating them would stand a
transcript on its head -- its oldest message is item 0 and sits at the
top (`Dir::DOWN`) while the view clings to the bottom. So the pin is its
own constructor argument, `LazySpan::new(dir, at_end)`, spelled the same
way as `Scroll::new`'s.

Making `Dir::UP` real rather than nominal is most of the diff. The walk
now works entirely in direction-relative pixels from the leading edge --
`Edge::Top`/`Bottom` are `Leading`/`Trailing`, `Placement` likewise, and
`RowExtent`'s fields and every local are `lead`/`trail` -- with two
places converting: `abs_region`, which flips the box for `Sign::Neg`,
and `flip_pos`, which converts the screen-space positions the public
helpers speak in (`note_tap`, `key_at`, `extent`, all fed by pointer
events) into the walk's space. Without the second, a reversed span would
hit-test at the mirror of where it drew.

`a_dir_up_span_grows_upward_from_item_zero` asserts on where each row was
**actually drawn** (`UiRenderState::active`), not on `extents`: the first
version of it read `extent()` and passed with `abs_region`'s flip deleted
-- checking the bookkeeping against itself while every row painted at the
mirror of where it belonged. It now fails with the flip removed (row 2 at
80..100 instead of 0..20), which is the check that matters.

Verified: cargo fmt --check, clippy --workspace --all-targets clean,
cargo test --workspace green (21 suites), including the phone-shaped
fixture tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 20:17:57 -04:00
irisandClaude Opus 5 76fcbdccb9 iris: a List gives back its overscroll in the frame that found it
The last place in iris that corrected itself on a later frame, and the
item docs/IRIS_TODO.md carried from the Scroll change. Iris's rule:
"nothing in the framework should ever self heal because it should not be
drawn incorrectly in the first place. If you need 2 draws to get
something into the correct position then that should happen within the
same frame."

`clamp_to_content` measured the gap past the end of the content from the
edges the walk had just placed, wrote it to the anchor and asked for
another frame -- so one frame was drawn with the list past its own end,
and a fling that had already stopped was not going to ask for the frame
that fixed it. Now the walk outward from the anchor is `List::lay_out`,
`overscroll_gap` is a pure measurement of the same gap (no painter, no
redraw handle), and `draw` moves the anchor and walks a second time
inside the same frame.

One further pass always settles it: the gap comes from the edges the
first walk placed, so moving the anchor by it puts that edge exactly on
the viewport's, and the opposite end can only open a new gap when the
content is shorter than the viewport, which `overscroll_gap` declines to
touch. The second walk is paid only on an overscrolled frame and re-offers
every row the same cached-height box at a new offset, which `draw_inner`
dispatches as an O(1) move.

`Painter::draw_again` had no other caller and is removed with it, so the
framework no longer offers a way to ask for a corrective frame at all.

Simplification in the same change: a placement is one pinned edge plus a
height, so `Placement::edges(height)` gives the box and `place`'s
top-known and bottom-known cases stop being two copies of the same
arithmetic -- four match arms down to two.

Four tests draw no settling frame on purpose and fail without the change:
`fling_toward_the_start_stops_at_the_first_row` and the new
`scrolling_past_the_start_is_given_back_in_the_same_frame` (list.rs), and
`scrolling_past_the_first_row_settles_on_it` /
`scrolling_past_the_last_row_settles_on_it` (layer 1, top_edge.rs).

Verified: cargo fmt --check, clippy --workspace --all-targets clean,
cargo test --workspace and ./run-tests.sh green, the phone-shaped
headless window replaying flick-120hz.touch draws the transcript
correctly, and the arm64 release APK builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 17:26:51 -04:00
irisandClaude Opus 5 a00376994e iris: a Scroll measures and places its content in the same frame
Follows Iris on the previous commit: "nothing in the framework should
ever self heal because it should not be drawn incorrectly in the first
place. If you need 2 draws to get something into the correct position
then that should happen within the same frame. Layout should never be
frame dependent, it should be a pure function of the state."

So `Scroll::draw` no longer places its child against last frame's
content length and asks for a corrective frame. It draws the child once
at that length purely to measure it, then places it at the length just
measured, with the end-pin and the clamp applied only to the second
placement -- the measure-then-place idiom `Span::draw` and `List::place`
already use. Last frame's length survives as a hint that keeps the
common case cheap: when the content's length did not change the two
regions are identical, so the first call is `draw_inner`'s O(1) `mov`
and the second returns at its first line. Nothing drawn depends on the
hint.

Reverts the frame-loop change from the previous commit (a frame that
left anything dirty asked for another), which existed only to deliver
that corrective frame and would have made any widget marking itself
dirty spin at full rate.

Knock-on: an end-anchored Scroll now sits at its end on its first drawn
frame rather than its second, since the end-pin no longer waits for a
length. Two layout tests that scroll down from what they assumed was the
top now build their area with `at_end: false`, which is what they meant.

`List::clamp_to_content` is the only next-frame correction left. Its
comment cited Scroll's lag as precedent, which no longer exists; it now
says it is a deviation from the rule, and docs/IRIS_TODO.md carries it.

Verified: the layer-1 test draws no settling frame and still passes; on
the emulator the caret's bottom is 1509 against a bar edge of 1535, 26px
inside a 31px padding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 17:12:26 -04:00
irisandClaude Opus 5 ba57086361 iris: a scroll area whose content grew asks to be drawn again
Iris's phone: typing newlines into the composer with the keyboard up
dropped the caret flush against the bar's bottom edge, eating the 12dp
padding, and closing the keyboard fixed it.

`Scroll::draw` offers its child last frame's content length on purpose,
so an ordinary scroll tick is an O(1) move rather than a redraw. The
comment claimed the lag self-corrects on the next frame; nothing asked
for that frame. A keystroke dirties the field, that frame draws it in a
box one line short of its text, and the tree is clean afterwards -- so
the stale placement is the last one drawn. The composer's text is
centred in its box, so one line short hung half a line past each end and
put the caret's line box a whole padding low. Closing the keyboard
rewrote the bar's inset, dirtied it, and forced the missing redraw.

`Scroll::draw` now calls `Painter::draw_again` when what it measured
differs from what it offered, and a frame that leaves anything dirty asks
for another frame on both backends -- `draw_again` sets its mark during
the update, after the input path's own check has run, so nothing asked
before this (which applied to `List::clamp_to_content` too).

Verified at layer 1 (the new test fails on the old code with the caret
exactly on the bar's edge) and on the emulator: the caret's bottom moved
from 1535 -- the bar's own bottom edge -- to 1509, 26px inside a 31px
padding, the remainder being parley's line box overhanging its line
height. `phone.rs` grew `--typed TEXT`, which enters text over frames
rather than preloading it; only that reproduces this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 16:59:06 -04:00
irisandClaude Opus 5 1a9655414e docs: Iris's idea for retiring masked_by -- a Stack that names its mask
She asked whether `masked_by` earns its place, since
`.background(x).masked()` looks like the same thing. Measured: for a
square-cornered surface it is (identical to the pixel on the composer at
the phone's size and density), and what the pair cannot express is a
clip that is not a box, which is why the method stands for now.

Her suggestion, in IRIS_TODO.md's "Reconsider": let `Stack` name where
its mask comes from the way `StackSize::Child(n)` already names where
its size comes from, at which point `masked_by` and `Masked::shape` both
go away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 16:38:05 -04:00
irisandClaude Opus 5 5e34dba2fd iris: a press only reaches the widget the pointer is on
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>
2026-09-08 16:23:32 -04:00
irisandClaude Opus 5 cc8148cbec deps: every crate to its latest version, wgpu 28 -> 30
`cargo upgrade --incompatible` in each of the nine workspaces here, then
`cargo update`. Most of it is version numbers only -- log, winit,
bytemuck, image, tokio, libc, android_logger, proc-macro2/quote, and syn
2 -> 3 with no source change. The wg-app-link submodule's twelve
dependencies were already at their latest majors, so that shared
repository needs no commit.

wgpu 28 -> 30 (and pollster 0.4 -> 1.0) is the part with API in it:
bind-group and vertex-buffer slots are optional now, `Instance::new`
takes an owned `InstanceDescriptor` carrying the platform's display
handle (the desktop passes winit's, since wgpu wants it for a GLES
surface presented on Wayland -- which is what this machine's fallback
produces; Android passes none), `RequestAdapterOptions` and
`SurfaceConfiguration` each gained a field kept at its historical value,
`get_current_texture` answers with an enum instead of a Result, and
`present` moved onto the queue.

The one that would not have failed at compile time: naga now requires
`@interpolate(flat)` on integer varyings, so `shader.wgsl`'s three u32
outputs were rejected at `create_shader_module` -- an abort on the device
rather than a build error. Flat is the only interpolation an integer can
have, so this states what the hardware already did.

Checked: build, clippy, fmt and tests in all nine workspaces (iris 196,
server 160); layer 2 screenshots on Vulkan and on force-gles, identical;
the arm64 release APK builds and the x86_64 bench ran a full
fling/stream/type/keyboard cycle on the emulator's GLES adapter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 15:47:59 -04:00
irisandClaude Opus 5 a2e5e5881c docs: the two warnings the bench Android build still prints, and why they stand
Both are pre-existing and both are decisions rather than cleanups.
`show_diagnostics_overlay` and the Java overlay behind it are an escape
hatch that draws a report even when iris itself has stopped drawing --
the one case the in-iris diagnostics pane cannot cover -- so deleting
them to clear the warning would remove a fallback, and Iris has no
logcat on her phone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 15:28:21 -04:00
irisandClaude Opus 5 c121bc0725 iris-android-app: FIELDS_PER_LINE is gated with the reader that uses it
`cargo ndk check` on the default features warned that it was never used:
its only reader is `line_fields`, which is `#[cfg(feature =
"transcript-screen")]` because the tabs demo links no `client-core` and
so has no ring to lay out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 15:27:31 -04:00
irisandClaude Opus 5 fe7dc9c728 docs: Iris's second 2026-09-08 phone report, and the workaround list closed
RUST.md gets the report verbatim with what each of the four defects
actually was, the tests that pin them, and two traps worth not
re-finding (a fixed-coordinate tap that "failed" by 544px because it had
toggled a tool group, and a layer-1 repro that only reproduces inside a
`List`). IRIS.md and DECISIONS.md get the design half: one `Flinger`
whose seam puts the sign convention and the content's end with the
caller, a cancel as a first-class end to a gesture, and why a row is
drawn twice on the frame its height changes.

LAYOUT.md gains the two rules those turned on, since both govern the
layout rather than this pass: padding works in any container and is an
inset or an outset depending on how tight the parent's region is (Iris's
own words), and a widget offered a box it does not fit is drawn again at
its true box in the same frame rather than the next one.

IRIS_TODO.md's "worked around in tool.rs rather than fixed here" is gone
-- Iris, 2026-09-08: "There should never be workaround code." Two of the
four entries are ticked; the two that remain are missing capabilities
rather than defects being dodged, and each now carries a diagnosis of
what building it costs instead of a workaround: an overflow ellipsis
needs `TextBuffer` to have a displayed string distinct from its source
(parley has none of its own, and every byte-offset consumer -- spans,
`byte_at`, `Selection`, `apply_delta` -- moves if the buffer is
truncated), and selectable tool-card text needs a register/unregister
lifecycle across the three routes that rebuild a card, which is where a
stale `Selection` handle panics.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 15:26:12 -04:00
irisandClaude Opus 5 02b277e7ad iris: every scroll area flings, on either axis, through one Flinger
Iris, 2026-09-08: "Flinging doesn't work in horizontal scroll areas.
Flinging should be enabled by default in all scroll areas on android to
match composes behavior." Compose's `scrollable` attaches
`ScrollableDefaults.flingBehavior()` on every axis it is given and it is
not something a caller opts into, so neither is this.

`iris::sense::Flinger` is the fling `List` already had, taken out of it:
the `FlingCalculator` curve, the clock (started at the first tick, not
the release, so a caller on an explicit clock is not handed a fling that
has already expired), the incremental delta, Compose's two release
thresholds and the trace line. What it deliberately does *not* know is
which way a positive delta moves the content or whether there is content
left to move into -- a `List` scrolls its anchor one way and a `Scroll`
moves its `amt` the other, so the caller applies `tick`'s delta in its
own convention and calls `stop` at its own wall. `List` keeps
`fling`/`tick_fling`/`is_scrolling`/`cancel_fling` unchanged as a
surface, now three lines each over the shared type.

`Scroll` gains it, plus the two things a coasting widget needs and it
had no reason to have before: the display density (read from the painter
in `draw`, since the deceleration is physical -- a hardcoded 1.0 made a
one-second coast run for 45 on a list), and `PressState::scrolling`, so
a finger put down on a coasting fence stops it there from the first
sample rather than after `DRAG_SLOP`. `Scroll::drag` now answers whether
it started a fling, which is what `WidgetLike::scroll_area` needs to
call `UiData::animate` -- the same split `List::fling`'s doc describes,
and for the same reason: only the caller can reach the frame loop.

`Scroll::axis()` is public for a caller that found the widget rather
than built it.

Tests: `scroll.rs`'s three (a released pan coasts and decelerates on both
axes; both walls stop it; a press on coasting content catches it with no
slop), and `transcript-fixture/tests/fence_fling.rs`, which flicks a real
markdown fence in the real transcript screen and reads the fence's own
`Scroll` back out of what was drawn. Confirmed to fail with the release
arm removed ("the fence stopped dead at the release: 272 -> 272").

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 15:22:03 -04:00
irisandClaude Opus 5 fc82d9d7e8 iris: a cancelled gesture is not a release, and a row height is not last frame's
Three of the four defects in Iris's 2026-09-08 report, each with a
layer-1 repro that fails without the change.

**A gesture the platform takes away is now a cancel, not a release**
(`CursorState::cancelled`, `SensorUi::run_sensors`). Android mapped
`ACTION_CANCEL` onto the same arm as `ACTION_UP`, so the system's own
swipe up from the bottom edge to leave the app reached iris as a flick
released at speed and the transcript flung while the app was in the
background -- "leaving and reopening the app also randomly moved the
vertical scroll". A cancelled sample now delivers `CursorSense::Cancel`
to the capture holder *and* every widget still tracking the press,
clears both, and derives nothing else: no tap, no selection, no fling.
The harness's `TouchAction::Cancel` says the same thing, so it is
testable from a `.touch` file.

**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 `Selection`'s shared gesture, which captures
under the *list's* id, so the block is a "loser" on the very frame its
own pan committed. Acting on that released the pan the frame it started
(`catch_a_fling.rs` fails without the guard). With it, a row's block can
register the whole `drag_senses()` set, `Cancel` included, which is what
the doc on that set has always said a widget driving a gesture must do.

**A row whose measurement disagrees with the box it was offered is drawn
again at its true box, this frame** (`List::place`, both placements).
A row is offered its *cached* height and 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 and opening an edit card draws the card background a frame
late, so it looks closed even when there's text". The bottom-anchored
half used a `reposition`, which writes an offset and never a size, so it
could not fix it either.

**The nested-`Span` workaround in `tool.rs` is gone**, restoring the 4dp
inset a tool group holds its cards off its edge by. "A `Span` of
`Pad`ded children inside another `Span` places those children a slot out
of step" is **not reproducible on 2026-09-08** -- verified both with
`IRIS_TOOLS_EXPANDED=1 run-headless.sh transcript --shot` and with a new
layer-1 test.

Tests: `transcript-fixture/tests/gesture_cancel.rs` (three, including a
real code fence pushed into the screen so the pan has something to
capture it), `list.rs`'s
`a_row_that_changes_height_draws_its_background_at_the_new_height_immediately`,
`layout_tests.rs`'s
`a_span_of_padded_children_inside_a_span_draws_each_where_its_box_is`.
Each was confirmed to fail with the change backed out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 15:15:29 -04:00
irisandClaude Opus 5 9e301f30c6 iris: ship an icon font subset, and delete the drawn mark
Iris asked why `mark` existed at all -- "the font should be working if
it's working for compose and nerd fonts are bundled". It was not: the
Compose app draws its icons from its own committed Nerd Fonts subset,
while iris, which bundles no font since 2026-09-07, was setting the
disclosure mark with bare geometric codepoints (U+25B8/25BE/25B4) out of
whatever face the platform resolved -- an empty box on her phone, a dot
on this VM. The 2026-09-07 note that "iris had no equivalent icon font to
keep" is the gap: it had none because it had never had one.

So iris ships the same kind of subset. iris/core/build-icon-font.sh is
the Compose script with its own GLYPHS list, writing a 992-byte
nerd_icons.ttf with three Material Design glyphs from the Mono face;
iris::icon names the codepoints; Family::Icons is how text asks for them.
The variant names an intention rather than a font name -- only TextData
knows what the file registered as, and it resolves it during shaping --
and it is a named family, never a generic one, so nothing falls back into
it for text and an icon cannot fall back out of it onto a system face
that happens to have the codepoint.

every_icon_is_in_the_bundled_font maps each constant through the shipped
font's charmap, which is the guard the script's "the two lists have to
agree" comment asks for. FontDiagnostics gains icon_family, so a build
whose font failed to register says so instead of drawing tofu; the
emulator reports icons=Some("Symbols Nerd Font Mono").

widget/mark.rs is deleted. It drew one correct triangle, but 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.

Looked at rather than only compiled: closed and open marks in
run-headless.sh phone --phone either side of a tap, and the collapse
bar's up mark under IRIS_TOOLS_EXPANDED=1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:42:16 -04:00
irisandClaude Opus 5 341b7a5922 iris: a device change re-uploads its textures, and a mark is one texture per shape
The bench APK panicked on frame 1 on the emulator:

    iris panic at iris/core/src/render/texture.rs:461:22:
    texture slot 89 is not a live standalone image: None

widget::mark called Textures::add per widget, so a folded card per tool
call meant a standalone image, a bind group and a draw call each --
hundreds of copies of three pictures. Textures::reset, which the Android
surface-rebuild path calls for a genuinely new renderer, then threw the
slot numbering away with the pixels, leaving every one of those live
handles naming a slot nothing recognised. Its doc had said the only
standalone image in the workspace was tabs-ui's, "confirmed by grep" --
true when written, false the moment mark existed.

Textures::reupload replaces reset: queue every slot for upload again in
slot order, empty slots included, so the new device gets the same slot
numbering and a handle a widget has been holding still names its own
texture. The glyph atlas is no longer cleared on that path either, so an
app switch stops re-rasterising every glyph on screen.

Textures::shared(key, make) is one texture per description, keyed by a
SharedTextureKey the caller packs exactly rather than hashes. mark keys on
direction and colour: three mark textures for the screen, not one a card.

And the devlog can finally show a panic. After a crash, Dev Updater's
query starts the app process for the provider alone, so no activity ran,
so set_crash_dir never replayed the panic hook's file -- the Runtime tab
held one line, the provider announcing itself. DevLogProvider.nativeReady
takes the files directory and does the replay from onCreate; the hook also
saves the dying run's last 80 lines beside the panic, read through a new
non-blocking LogRing::try_tail_text so a panic holding the ring's lock
cannot deadlock the hook.

Verified on this checkout's emulator: opens clean, survives 33 full-screen
scrolls back through the fixture, image_bind_group_creates_prev=1; a real
panic replays into the next launch, and a hand-written last-panic.txt
replays in a process started by a provider query with no activity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:27:09 -04:00
irisandClaude Opus 5 c8785b6091 docs/IRIS.md: it is the log of how iris is being built, not an API changelog
Iris, 2026-09-08: 'any major additions or design things should be added
there, not just public API stuff. You may as well remove the public API
bit at this point.' Widened the header, pointed AGENTS.md at the new
scope, and added the design point behind the scroll bug -- a cached
measurement needs its own value for 'not measured yet'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:03:29 -04:00
irisandClaude Opus 5 9c560e3492 docs: tick the drawn chevron
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:01:42 -04:00
irisandClaude Opus 5 e5a90c6135 iris: mark() -- a drawn disclosure triangle, instead of a codepoint the phone lacks
The tool cards' open/closed marks were U+25B8/25BE/25B4 in whatever face
resolved. That worked while iris bundled its own fonts; since the move to
the platform collection on 2026-09-07 Iris's phone draws an empty box and
this machine draws a dot -- UI_RULES' 'don't rely on characters the
platform might not have'.

iris::widget::mark rasterises one oversampled, antialiased triangle into
the ordinary texture path and scales it into the box the caller asks for,
so it needs no new primitive and is correct at any density. Its two tests
check the shape points where it was asked to and leaves its corners
clear, which is the half nobody would look at on a device that renders it
wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:01:31 -04:00
irisandClaude Opus 5 af1b0c5ab2 docs/RUST.md: what the folded-card sanity check found
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:57:35 -04:00
irisandClaude Opus 5 cce4b28324 iris: a scroll area no longer opens at the end of content it has not measured
Scroll::content_len was 0.0 both for 'nothing here' and for 'not drawn
yet', so the first frame's clamp found a scroll range of zero, read
amt == len as 'sitting at the end' and set snap_end -- and the next
frame, now knowing the real length, jumped to it. On a phone that put a
code fence at the end of its longest line, mid-word, before anybody
touched it. It is an Option now, and the clamp does not answer a question
it cannot yet answer.

Which edge an area opens at is also a caller's decision rather than a
default: scrollable_on starts at the beginning (what is read),
scrollable_to_end pins to the end while content grows (what is typed --
the composer), both through one Scroll::new(inner, axis, at_end).

And tool.rs's raw_block pans sideways again: the 2026-09-06 'a
scrollable_on(Axis::X) around a non-editable Text draws nothing' defect
does not reproduce, most likely fixed by the shaped-mask work, so a long
command is readable rather than clipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:57:16 -04:00
irisandClaude Opus 5 94d8373289 iris-android-app: the bench observes the fling instead of driving it at 60Hz
The fling phase called List::tick_fling itself every 16ms, so on a 120Hz
phone every second frame redrew a position already drawn -- Iris saw the
benchmark scroll visibly less smoothly than her own finger, and it was
the rig rather than the renderer. A real fling is advanced once per frame
by UiData::tick_animations from the frame callback, so the phase now
starts one the way a gesture does (fling + animate) and polls
is_scrolling to know when it settled. ANIM_STEP_MS becomes POLL_MS,
which is what it always was here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:48:17 -04:00
irisandClaude Opus 5 8310431497 iris: pin the nested-scroll axis rule, and record the capture fix in RUST.md
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:46:39 -04:00
irisandClaude Opus 5 b863f9f3df iris: a capture cancels every other gesture, and the pointer leaves UiRenderState
Two defects Iris reported from her phone on 2026-09-08, one root cause
each, both in how a gesture ends.

A widget that takes pointer capture cuts every other widget off from the
press completely -- no PressEnd, no Drop -- so anything else tracking it
was left with an open gesture at a stale origin, and the *next* touch
anywhere was measured from that origin. That is the transcript jumping on
a tap after a code fence was panned sideways. CursorSense::Cancel is the
missing state: delivered once to each loser of a capture race, the way
Android sends ACTION_CANCEL and the web sends pointercancel.

And  registered click_or_drag|unclick, which never matches
a Drop, so a Scroll that had captured never saw its own gesture end and
stayed panning from where the finger left. That is the horizontal snap
back. CursorSense::drag_senses() states the rule once for every widget
driving a DragGesture instead of per call site.

The pointer's own state (who holds capture, who is tracking the press) no
longer lives in a Mutex on UiRenderState. It is Event::Global for the
cursor senses -- owned by the event manager that runs the dispatch,
reached by &mut, with a per-dispatch PointerRequests slot for handlers --
per Iris: never reach for locks first, and input-wide state belongs to
the general input handler. What had forced the lock was a Data: Send
bound on task_on that nothing needed; the spawned future never sees the
event's data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:44:51 -04:00
irisandClaude Opus 5 cdeb7b0857 docs/RUST.md: the work is done inline, not handed to subagents (Iris, 2026-09-08)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:31:02 -04:00
irisandClaude Opus 5 476609e1d3 docs/RUST.md: Iris's 2026-09-08 phone report -- tap-jump, nested scroll, folded cards, and the bench's 60Hz gesture
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:27:46 -04:00
irisandClaude Opus 5 2756087e1c emulator: settle on GLES, and make every run say which adapter drew it
Iris's call, after the guest measurement: the emulator is a GLES rig and
nothing chases hardware Vulkan in it; the Vulkan path is covered by the
desktop build and by her phone.

Nothing had to be forced. The emulator has no hardware Vulkan at all --
its only Vulkan is SwiftShader in software -- and its GLES is the host's
real RX 7900 XT through virgl at ES 3.1, so iris's existing runtime
fallback lands there by itself. Verified end to end with an ordinary
debug APK: "no Backends(VULKAN|...) adapter on this device, falling back
to GLES", then "Android Emulator OpenGL ES Translator (virgl (AMD Radeon
RX 7900 XT ...)) (Gl, OpenGL ES 3.1 ...) on Backends(GL)". So the
emulator and the phone run the same binary, differing only in what it
finds -- which is the point, and `force-gles` must not be reintroduced to
arrange the emulator's backend.

What changed:
- The Android renderer logs the full adapter line at startup, as the
  desktop already did. Only the backend enum was logged, which cannot
  tell `Gl` on the host's GPU from `Gl` on SwiftShader; the same rule was
  written on one member of the pair and not the other.
- `run-bench.sh` prints that line before any number.
- build-apk.sh, Cargo.toml and RUST.md's "Vulkan in the emulator" carried
  the stale premise that the emulator defaults to software Vulkan and has
  to be steered off it. The recipes are marked superseded rather than
  deleted, since the record of why host Vulkan is unavailable is still
  worth having.
- Drive-by: an `#[allow]`-free clippy warning in android/platform.rs
  (useless JObject conversion) that only appears on the android target.

No Vulkan requirement was found in iris itself to remove: neither backend
asks for a feature, `device_limits()` stays at wgpu's defaults with the
compute fields zeroed, and both probe rather than expect an adapter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:12:21 -04:00
irisandClaude Opus 5 af7d5f3782 docs: what the emulator actually gives a GPU app, measured in the guest
`gpu-probe` cross-compiled with cargo-ndk and run inside a default
`emu up`: the guest's GL adapter is the host's real RX 7900 XT through
virgl, reporting OpenGL ES 3.1 with compute shaders, 1024 invocations per
workgroup and 64 KB of workgroup storage -- the same numbers the desktop
gets. `Backends::PRIMARY` still finds nothing, because the guest's only
Vulkan is SwiftShader.

So GPU acceleration in the emulator is not a thing to get working; it is
the default, and it is GLES. What is missing is GPU-accelerated Vulkan,
and the Venus retry on mesa 26.2.2 fails exactly as it did on 26.1.7 with
no newer emulator package to try.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:04:15 -04:00
iris db0a41a7cd gpu-probe: say whether each adapter has compute, not just the preferred one
Compute is a downlevel capability rather than a feature -- unconditional
on any Vulkan 1.0 device, GLES 3.1 and up -- so the question shadows and
blur raise is what the *weakest* adapter iris can fall back to offers.
Both here answer yes: Venus and virgl each report COMPUTE_SHADERS, 1024
invocations per workgroup and 64 KB of workgroup storage, virgl because
it is ES 3.2. The only no-compute machine in this project is the
emulator's SwiftShader software GL at ES 3.0.

RUST.md gains that table, why DRM native context is unrelated to it, and
what each of shadows/blur/paths actually needs -- only vello proper turns
the compute question on.
2026-09-08 12:55:47 -04:00
irisandClaude Opus 5 b9924e7617 iris: the GPU test's crash was the Vulkan loader unloading Mesa, not wgpu
`mask_sdf` SIGSEGVd after printing `test result: ok`, and the workaround
was to hand the device to the process with `mem::forget` on the reading
that "dropping a wgpu device on Venus segfaults". Every part of that
except the symptom was wrong.

`rigs/gpu-probe`'s new `teardown` bin is the experiment, one variable per
mode: the same open-and-close exits 0 on the main thread and SIGSEGVs on
a spawned one; it needs no GPU work and no device, only an instance; raw
`ash` does it with no wgpu involved at all; and keeping the instance
alive fixes it. Destroying the last VkInstance makes the loader dlclose
the ICD, and Mesa's ICD here registers a pthread_key_create destructor
into its own text without `-z nodelete`, so glibc calls it through
unmapped memory when the thread exits. libtest runs every #[test] on a
spawned thread, which is the whole reason this looked like a drop bug.
`VK_LOADER_DISABLE_DYNAMIC_LIBRARY_UNLOADING=1` confirms the mechanism.

So the fix is one `wgpu::Instance` for the process -- what wgpu asks for
anyway -- and the device, queue and everything else drop normally again.
The escape and its paragraph of reasons are gone.

Also: the machine-level graphics notes duplicated in docs/RUST.md,
run-headless.sh and two source comments now point at the
`this-machine-graphics` skill, which is the only copy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 12:22:47 -04:00
irisandClaude Opus 5 f014e8d9cf docs/RUST.md: point at the this-machine-graphics skill
The GPU findings from 2026-09-08 would bite any project on this machine,
not just this one, so they are now a skill (AGENTS.md's own rule about
where a machine-wide lesson belongs). This section keeps the iris- and
port-specific half and names the skill for the rest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 12:09:45 -04:00
irisandClaude Opus 5 0ccc444246 iris: cut test debug info, say which adapter drew, and log on the desktop
Three findings from one morning, all of them things that were invisible
rather than wrong. docs/RUST.md's two new sections have the full account.

**`cargo test --workspace` was taking half an hour, and it was debug
info.** rustc's default `debug = true`, times eight test binaries each
statically linking the whole wgpu + naga + winit + parley graph, means
every one of them gets a private copy of that graph's DWARF written into
it: the linkers for one run had written ~54 GB between them and were
still going at thirty minutes -- the worst single one 16.9 GB for one
test binary -- leaving an 88 GB target/. It was not CPU: the machine was
87% idle, and rust-lld's threads were in D state in btrfs
`handle_reserve_ticket`, blocked on space reservation at 83% full. So
`debug = "line-tables-only"` on both `profile.dev` and `profile.test` --
both, because `cargo test` builds dependencies under one and the test
targets under the other. Cold, with all 19 suites run: 69 s and a 3.7 GB
target. Backtraces keep file and line; `RUSTFLAGS="-C debuginfo=2"` per
run buys back variable inspection when a debugger actually needs it.

**The desktop had no logger at all**, so every `log::` call on that side
went to `log`'s no-op default -- including the GLES fallback warning
added hours earlier. `DefaultApp::run` installs a stderr logger
(`src/default/logging.rs`, no new dependency: a level and a line is a
page of code against env_logger plus its filter dialect), and the
renderer now says which adapter won at `info`. That line is the point:
with a silent fallback, a layer-2 screenshot rendered by llvmpipe and one
rendered by the host's GPU are the same PNG, and which one it was is
exactly what the screenshot is being taken to judge.

**`tests/mask_sdf.rs` is a render pass now, not a compute pass.** It
asked for `adapter.limits()` because `iris_core::device_limits()`
deliberately zeroes the six `max_compute_*` fields -- a decision on
record since 2026-09-05, which this quietly worked around instead of
following. It now asks for what iris asks for and calls the function from
the fragment stage, where the renderer calls it. The compute pass was
*not* why it crashed, and the record should not say it was: the rewrite
crashes identically. What the crash is: dropping a wgpu device on this
VM's Venus adapter segfaults, after the test has produced its answer
(worst CPU/shader disagreement 5.8e-6). Narrowed -- plain Vulkan creating
and destroying five VkDevices on the same adapter is clean, and the same
binary with Vulkan hidden falls back to GL and exits clean. Worked around
at `Gpu::leak`, with the reason and the delete-me condition written
there.

`rigs/virtgpu-probe` is the new rig behind the Venus half: which capsets
the host offers (0x16 -- VIRGL, VIRGL2, VENUS; no capset 6, so no DRM
native context without host-side work), whether the device has compute
(it does: 1024 invocations/workgroup -- the "no compute" finding on
record is about the Android emulator's SwiftShader, a different machine),
and whether plain Vulkan teardown is clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 12:06:02 -04:00
irisandClaude Opus 5 c6da735134 docs/RUST.md: the ABI-cache half of the build-apk.sh box is done (4f6ec3a)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 11:17:15 -04:00
irisandClaude Opus 5 4f6ec3a900 iris-android-app: clear Gradle's native-libs cache, so an ABI switch takes
`build-apk.sh` already removed `app/src/main/jniLibs` before each build,
with its own comment saying why. It does not reach Gradle's own copy:
`mergeReleaseNativeLibs` is up to date against its cached inputs, so a
build that switches ABI packages the previous one. An `--abi x86_64`
release APK containing `lib/arm64-v8a/libmain.so` installed fine and
aborted at startup with `Could not get adapter!: NotFound {
active_backends: VULKAN }` under libndk_translation -- which reads
exactly like the phone's own Vulkan problem and is nothing of the kind.
It cost an hour on 2026-09-07 and was written down rather than fixed.

Scoped to the three native-lib directories rather than all of
app/build, so an ABI change costs the native merge and not the whole
Gradle build. Verified on the case that produced it: this checkout held
an x86_64 libmain.so from emulator work, and `./build-apk.sh release
--abi arm64-v8a` produced an APK whose only .so is
lib/arm64-v8a/libmain.so (7,518,840 bytes) -- that APK is
ai-app-bench a012ff9.

docs/RUST.md's queue box keeps its second half open: the 648 MB debug
bench APK still will not install.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 11:17:07 -04:00
irisandClaude Opus 5 38bf6309cb iris: a mask is a shape, not a rectangle -- .masked_by, and touch obeys it
Iris, on the code fence: "the code block scrolling currently masks in an
inner rectangle. Ideally masks should have a shape associated with them,
rounded rectangle being one of them ... so that the mask becomes the
parent container with rounded edges. Make sure alpha works properly with
it, eg. on the corners where alpha should be decreased / multiplied."

`Mask` is now `{ primitive, parent }` -- the slot of a primitive already
written, plus the mask this one nests inside. The fragment stage
evaluates that primitive's own coverage at the masked pixel, through the
same `rounded_rect_coverage` a drawn rect goes through, and multiplies it
into the alpha along the whole `parent` chain. Nothing about the shape is
copied, so a rounded container's corner and its children's clipped corner
are one piece of arithmetic and cannot drift; two nested feathers dim a
pixel twice, which is the multiply she asked for.

`.masked()` is unchanged for callers: it writes an undrawn rect
(`Drawn::No`/`NOT_DRAWN` -- owned, moved, resized and freed like any
other primitive, simply never rasterized) and points at that, so square
clipping is the same mechanism rather than a special case. New
`.masked_by(shape)` draws `shape` behind the content in its own layer and
clips to the first primitive it drew, with no radius written twice; it
replaces `.masked().background(w)`, which drew both and clipped to the
box. `transcript-ui`'s `BlockFrame::Verbatim` is the first caller.

Hit-testing applies the shape (`SensorUi::run_sensors` ->
`UiRenderState::mask_admits`, coverage above one half, which is where the
drawn edge is), as well as the widget's own box -- the two ask different
questions and both have to hold. `primitive_corners` is a floor-for-floor
transliteration of the shader's `corners_of`, not `region.to_px()`: the
phone's 2.55 density puts nothing on a whole pixel, and skipping the
rounding disagrees with the pixels by up to one along each edge.

A mask's shape must be a rect, asserted by name in `set_mask_to`. A glyph
would need a CPU-side alpha plane before the hit test could agree with
the shader, and a standalone image a bind-group switch the fragment stage
cannot make. So no texture mask exists; the branch where one would go is
in both copies of `mask_coverage`. docs/LAYOUT.md's section end lists this
and the three other places the code is narrower than the design.

Tests. Layer 1, `layout_tests.rs`: the child's coverage swept across the
container's corner arc equals the container's own exactly; nested masks
multiply rather than intersect, asserted where both feathers are partial,
which is the only place the two differ; a press in a rounded-away corner
misses while one inside the curve and one on a straight edge hit; and
`a_plain_mask_still_clips_to_a_square_box`, the half this had no reason to
touch. The first version of the corner test swept the straight chord
between the arc's ends, which lies inside the circle everywhere -- it
proved nothing and said so, which is why it counts both sides now.

`iris/tests/mask_sdf.rs` is the only test here that needs a GPU: it lifts
`distance_from_rect` and `rounded_rect_coverage` out of
`iris_core::SHAPE_SHADER` by name -- lifted, not copied, since a copy
would be edited alongside the shader -- and runs them in a compute pass
over ~200k points at five radii against `iris_core::rounded_rect_coverage`.
Worst disagreement under 1e-5; the negative control (`+ 0.01` inside the
shader's smoothstep) fails it at 0.03.

Layer 2 for looking: `./run-headless.sh phone --phone --shot /tmp/mask.png
--seconds 6 -- -p transcript-fixture` draws the fixture's horizontally
scrolled code fence clipped on the curve at both top corners.

Two things found on the way and fixed here:

- The winit backend had the defect the Android one was fixed for in
  85869d0 -- `Backends::PRIMARY` and an `.expect` on the adapter. This
  VM's Venus device disappears when the host runs out of virgl contexts,
  which happened mid-task, and layer 2 aborted with `Could not get
  adapter!` while GL sat there working. It probes and rebuilds the
  instance on `Backends::GL` exactly as Android does now, and the request
  names the backends it tried. The rule had been written on one member of
  a set of two.
- `active_primitive_count` counted mask shapes, so `iris::frame`'s
  `primitives=` -- a number Iris reads off a phone report as "how much is
  on screen" -- would have gained one per masked widget.

`widget_trait!` now accepts a `///` doc comment on its functions, since
`masked_by` is public API and rustdoc is where a contract is read.

docs/LAYOUT.md, docs/RUST.md (both queue boxes, the commands, and where
the GPU test sits among the three layers), docs/IRIS.md and
docs/IRIS_TODO.md ("Masks defined relative to each other", now closed).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 02:18:57 -04:00
irisandClaude Fable 5.1 3eb0e033d5 docs: tick report hygiene and bench header (commits 7485d78, b8ea723)
Both docs/IRIS_TODO.md's night bullets and docs/RUST.md's queue items
covered by the two client-core/iris-android-app commits above.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 22:26:49 -04:00
irisandClaude Fable 5.1 b8ea723718 iris-android-app: Copy report always copies; restore the header's text size
Two of the phone's 2026-09-07 night reports (docs/IRIS_TODO.md):

Copy report used to silently decline ("nothing to copy -- run the
benchmark first") whenever no benchmark had run yet, which read on the
phone as the button being unhittable until Diagnostics was pressed first
-- UI_RULES's "a failure is reported where it happened" failure, since it
declined with no visible effect. It now always copies something: with no
benchmark run yet it copies the diagnostics pane's own text instead (which
needs no prior button press either), with a first line saying so, and in
every case appends the ring's tail (LogRing::tail_text,
COPY_REPORT_TAIL_LINES lines, previous commit) instead of the whole ring,
which was the other half of "causes a lot of lag" pasting it into a
message box. app_log.rs wires iris::diagnostics::trace_enabled into the
ring filter that commit added.

The header's four controls no longer fit one row at HEADER_TEXT = 18, and
a previous agent had shrunk it to 13 to make room -- exactly what
UI_RULES forbids (never shrink text to fit a layout). Restored to 18 and
split bench_controls into two rows instead (run+copy, diagnostics+trace),
doubling the header's own height rather than the outer layout's reserved
space (top_bar already sizes to its own content). Checked on this
checkout's emulator: ui-trace's --field box shows two clean, non-
overlapping rows, and a screenshot shows the restored size reading
clearly; a Copy report tap with nothing run yet now logs "copied to
clipboard" instead of declining.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 22:26:27 -04:00
irisandClaude Fable 5.1 7485d78d50 client-core: filter the ring's Debug/Trace lines to iris's own targets
Iris's phone report (docs/IRIS_TODO.md, 2026-09-07 night): the ring held
1339 lines and dropped 4050 more, almost all of it naga::front/wgpu_core/
jni logging at Debug unconditionally, because RingLogger accepted every
target at whatever level `log`'s own max was set to. The trace gate added
in 992c472 only covers iris's own debug! call sites, not a dependency's.

ring_accepts() is the one filter, applied in RingLogger::log rather than
per callsite: Info and above always rings, from anywhere (a dependency's
real warning is worth keeping); Debug and Trace ring only from `iris`/
`client_core` targets, and only while tracing is on. Tracing itself is
`iris::diagnostics::trace_enabled`, passed into RingLogger as a plain
`fn() -> bool` rather than called directly, since client-core sits below
iris and must not depend on it -- the same reason `inner` (the platform
logger) is already injected rather than chosen here.

Also adds LogRing::tail_text and COPY_REPORT_TAIL_LINES (150, named and
reasoned at the constant) for the next commit's Copy report trim.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 22:26:17 -04:00
iris b87f5a597e iris: a finger put down on a moving list catches it at that sample
Iris, from the phone (docs/IRIS_TODO.md, 2026-09-07 night): "sometimes
when I try to catch it while it's still moving (particularly if I drag)
then it fails to stop & snap to where finger is." The fling did stop on
the down -- `Selection::drag` has cancelled it since the fling landed --
but the *gesture* then went through `DRAG_SLOP` like any other press, so
for the first few frames the finger was down and the content under it
did not move. Compose does not do that: `scrollable`'s
`startDragImmediately` is `isScrollInProgress`, and the drag starts on
the DOWN with no slop.

So `DragArbiter::press_start` takes a `PressState` -- what the target
looked like when the press landed, `already_selected` and `scrolling` --
and a press on moving content enters `Panning` immediately. A catch that
is released without ever moving is `Released(None)`: not a `Tapped`,
because Compose consumes that DOWN and no click detector under it sees
the gesture, so stopping a fling must not also follow the link it landed
on; and not a velocity, because there is none to hand on. The moment it
moves anything it is an ordinary pan release again and flings normally.

`DragGesture::starts_press` is the one rule for "this frame opens a
press", read by `handle` and by `Selection::drag` -- which has to prepare
its list (cancel the fling, report whether there was one) on exactly the
frames `handle` will call `press_start` on, including the recovery frames
where no `PressStart` ever arrived.

It deliberately does **not** special-case `PressStart` to true, which is
the defect the layer-1 test found: one touch-down reaches every sensor
under the finger, and a transcript row's block and the tool row
containing it drive the same shared `DragGesture`, so `handle` sees one
`PressStart` twice. Restarting on the second delivery re-read
`PressState` after the first had already acted on it -- the fling was
cancelled by then, `scrolling` came back false, and every catch quietly
became an ordinary slop-waiting press again.

Tests. Layer 1, `transcript-fixture/tests/catch_a_fling.rs`: the
recorded 120Hz flick, 150ms of fling, then a down and three 2px moves --
the content tracks the finger sample for sample
(`a_press_on_a_flinging_list_pins_the_content_to_the_finger`, which
fails at the parent commit with "the content 0.0px"); a catch released
without moving neither taps nor flings; and the half this had no reason
to touch, `the_same_small_drag_on_a_settled_list_moves_nothing` -- 6px
total is inside `DRAG_SLOP`, so making every press pin the content would
pass the first test and take the slop away from every ordinary one.
Unit, in `sense.rs`: the catch pans from the first sample, the same
press on settled content stays undecided, a catch that drags still
flings, and the double-delivered `PressStart` stays one press.
2026-09-07 22:18:16 -04:00
irisandClaude Fable 5.1 80a75c128e docs/RUST.md: who owns the killed agents' diff, and the stale worktree note
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 22:08:35 -04:00
irisandClaude Fable 5.1 50e69995b6 docs/RUST.md: the emulator crash loop was the missing GLES fallback, with the panic-hook note
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 21:50:06 -04:00
irisandClaude Fable 5.1 85869d02f8 iris: the Android renderer falls back to GLES, and every failure reports
The bench app crash-looped on this checkout's emulator with the default
features (RUST.md's queue item). Not the surface lifecycle and not "once
backgrounded": a build without `force-gles` never got a first frame.
`AndroidRenderer::new` asked wgpu for `Backends::PRIMARY`, which does not
contain `GL`, and this emulator advertises a Vulkan ICD with no adapter
behind it -- `NotFound { active_backends: VULKAN, no_adapter_backends:
VULKAN, supported_backends: VULKAN | GL }`, `.expect`ed, so SIGABRT, so
the launcher restarts it. iris was refusing a device whose only usable
adapter is a GLES one.

It now probes for a `PRIMARY` adapter and rebuilds the instance on
`Backends::GL` when there is none. The probe runs on an instance that
never touches the window on purpose: **an Android window can be
connected to one graphics API only**, so one instance carrying both
backends fails worse -- measured here on the way to this fix, Vulkan's
`vkCreateAndroidSurfaceKHR` claims the window in `create_surface` and
the GLES surface from the same window then reports `In
Surface::configure / Invalid surface`, aborting a frame later in
`Surface::get_current_texture_view`. Vulkan still wins wherever it has
an adapter (`PowerPreference::None` does not sort, and Vulkan is
enumerated first), so nothing changes on the phone.

Second half, the same rule applied to the whole set: the surface,
adapter and device requests all report through the `Result<Self,
String>` this function already returns, where two of the three used to
panic. `surface_changed` puts that string on screen and in the log
ring, which is what the Result was added for.

Emulator evidence (API 36 x86_64, debug): after, `iris renderer: no
Backends(VULKAN | METAL | DX12 | BROWSER_WEBGPU) adapter on this
device, falling back to GLES` then `new renderer built (Gl)` and
frames. Clean on both the default and a `force-gles` build for the
cases this had no reason to touch: two background/return cycles,
rotation there and back (the `already_live=true` reuse branch), a
background/return after the rotation, and cold starts. Vulkan could not
be exercised here -- that this emulator has no Vulkan adapter is the
defect itself.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 21:50:02 -04:00
irisandClaude Fable 5.1 f99ae4c366 iris-android-app: a panic hook, so an abort says something Iris can read
Checked before writing anything: under `panic = "abort"` (this crate's
Cargo.toml) a panic's message reaches the tombstone's `Abort message`
and nowhere else -- not `log`, so not `client_core::log_ring`, so not
Dev Updater's Runtime tab. That tab is the only surface Iris has on a
phone with no `adb`, so every `assert!` and `expect!` in these builds
has been failing silently as far as she is concerned; the adapter crash
fixed in the next commit looked like the app simply relaunching.

`install_panic_hook` (called from `app_log::install`) writes the
message and its location at `error` level. The ring is memory only and
the process is about to die, so it also writes `last-panic.txt` in the
app's private directory; `set_crash_dir`, called from
`nativeSetFilesDir`, replays that into the ring at `error` level on the
next start and deletes it. A crash loop therefore explains itself in
the run that is still up, which is the run somebody can look at.

Verified on this checkout's emulator against the unfixed renderer:
`iris panic at .../render.rs:140:14: Could not get adapter!: NotFound
{...}` on the run that died, and `iris app log: the previous run died
-- ...` on the next one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 21:49:45 -04:00
irisandClaude Fable 5.1 203f53470c iris: one primitive arena all layers share, with placement in a storage buffer
A mask is about to reference a primitive already drawn and evaluate it at
the masked pixel (docs/LAYOUT.md's "Masks with a shape"), which the data
layout could not answer: a primitive's placement lived in its layer's
*vertex* buffer, invisible to the fragment stage, and `rects`/`glyphs`
were per layer too -- so a mask whose shape is a rounded container in one
layer, clipping content a `Stack` put in another, would have read the
wrong layer's rect with nothing on screen to say so.

So the instances and the per-primitive data become one arena
(`UiRenderState::primitives`), bound once per frame; a layer keeps only
its draw *order*, which is what its vertex buffer now is -- one `u32`
slot per instance instead of eight attributes. The vertex stage reads the
placement it is drawing from `instances[slot]`; the fragment stage can
read any other primitive's from the same buffer, which is what the mask
work needs and the reason there is no second copy for masks.

Arena slots are stable (nothing is compacted), so a `Mask` can hold one
across frames. A slot freed during a redraw is therefore not reusable
until every layer's order has been compacted around it -- otherwise the
reused slot would draw twice, once through the stale order entry -- which
is what `Primitives::freed` and `UiRenderState::apply_free` are. That
compaction moved out of `UiRenderNode::update` into `UiRenderState::
update`: it is bookkeeping over `active`, not GPU work, and the harness
(which has no renderer) needs it too.

Same 164 tests, the `--phone` screenshot unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 21:21:55 -04:00
irisandClaude Fable 5.1 b38e797db3 docs: phone report 2026-09-07 night -- catching a fling, silent Copy report, third-party debug flooding the ring
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 21:18:29 -04:00
irisandClaude Fable 5.1 92985ba8e3 iris/Cargo.lock: the log entry for iris-android-app regenerated after the uploader's removal
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 21:09:35 -04:00
irisandClaude Fable 5.1 181ba64606 docs/REVIEW-2026-09-07.md: every finding's status after the fix pass
13 fixed, 6 moot or deferred, 2 not done on purpose. Each finding gets its
own Status line in place rather than a summary at the end, so a reader who
arrives at a finding sees what happened to it; the header carries the
counts and the six commits.

The moot ones are all in the phone-logging route 06b8a1f deleted (D2's
unbounded `POST /client-log` body, D3's silently dropped lines, R3's three
copies of one wire contract, R4's `build.rs`, and the `client_log_time`
duplication) -- the app hands its log to Dev Updater through an on-device
ContentProvider now, so there is nothing left to bound or share. Two more
are deferred to the devlog agent because `iris/android-app/**` and
`client-core/src/log_ring.rs` were open under it this pass.

The two left undone are deliberate. R2 (a mask clips drawing but not
hit-testing) waits on docs/LAYOUT.md's mask redesign, since intersecting
a chain in `resolved_region` now would be a second mechanism to unpick.
R6 is a look-at-it-on-the-phone item and no build in this VM is evidence
about her device's font set.

Full checks on the tree as pulled: `cargo fmt --check` clean in `iris/`,
`server/`, `client-core/` and `event-model/`; `cargo clippy --workspace
--all-targets` exit 0 in `iris/` and `server/` (the only line is the
`future-incompatibilities` note about naga/wgpu/winit, which predates
this pass); `cargo test --workspace` 165 in `iris/`, 160 in `server/` and
157 in `client-core/`, no failures. The one thing not run is a real
device build -- `cargo ndk -t x86_64 -P 29 check -p iris` is clean, but
`-p iris-android-app` is the devlog agent's tree.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 21:09:02 -04:00
irisandClaude Fable 5.1 a6a100edc6 iris: the chain bound is named for the walk, and two nits from the review
docs/REVIEW-2026-09-07.md's rule finding on `MOVE_CHAIN_LIMIT` plus both
nits.

`MOVE_CHAIN_LIMIT` bounds two different parent walks -- move offsets in
the vertex stage and `Mask::parent` in the fragment stage -- under a name
that says one, and the shader's own comment beside it already called it
"the bound on the parent walk". Renamed to `PARENT_CHAIN_LIMIT` in both
files at once (the constant has no other users), with the doc saying
which two chains it governs.

`DragGesture`'s release computed `self.velocity.velocity()` twice, once
for the outcome and once for the `iris drag release:` line -- a full Lsq2
fit each. Once now, into a local both read.

`transcript-ui`'s `selection.rs` called `ui.ui_mut().animate(id)` even
when `List::fling` had bailed (Compose's `|v| <= 1.0`, or no anchor), so
a frame was asked to advance an animation known not to exist. It is
behind `is_scrolling()` now, which is the same answer `fling` itself
reached. `phone_screen.rs`'s recorded flick still flings, which is the
half that says the guard did not turn a working release off.

Verified: `cargo test --lib -p iris` (104) and `-p transcript-fixture`
(12), fmt and clippy clean, and layer 2 (`run-headless.sh phone --phone`)
still renders with the mask chain intact -- code fences clipped to their
rows, the list clipped at the composer -- which is what the wgsl rename
needed looking at rather than compiling.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 21:05:55 -04:00
irisandClaude Fable 5.1 ff1d6ea932 iris: a degenerate fit has no solution, and the desktop follows a display's density
Two of docs/REVIEW-2026-09-07.md's risks.

**R7.** `poly_fit_least_squares` clamped a near-zero basis-vector norm
(`1.0 / dot(..).sqrt().max(1e-6)`) where Compose's `polyFitLeastSquares`
bails: below `0.000001f` the vectors are linearly dependent and there is
no solution. Clamping reached the solve with a `q` row of zeros and a
zero on `r`'s diagonal, produced `[NaN, NaN, NaN]`, and was rescued only
by the caller's `is_finite` check -- working, but by accident, and not
what the source it is transcribed from does. It returns `Option` now and
`velocity()` answers 0 on `None`.
`a_fit_through_linearly_dependent_points_has_no_solution` reports
`Some([NaN, NaN, NaN])` with the clamp back in place. Three samples at
one instant is exactly what the input clock produced before 2ec0fee, so
this is the second half of the same fault.

**R5.** `WindowEvent::ScaleFactorChanged` was unhandled, so dragging the
window to a display with a different scale left every `Len::dp` and every
rasterised glyph at the density the window opened on. It now re-reads
`content_scale` -- through that function rather than off the event, so
`IRIS_SCALE` still pins `--phone`'s density instead of following the
monitor -- and sets both copies. `UiRenderState::set_density` marks the
tree for a full redraw when the value actually changes, because
`Text::shape` keys its cache on `(attrs, width, density)` and nothing
else would ask for those glyphs again. Invisible on this machine (every
display here is 1.0), which is why the review asked for it in writing.

Verified: `cargo test --lib -p iris` (104), `cargo test -p
transcript-fixture` (12), `cargo ndk check -p iris`, fmt and clippy
clean, and layer 2 (`run-headless.sh phone --phone --replay
flick-120hz.touch --shot`) still draws and still clips at the composer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 21:03:43 -04:00
irisandClaude Fable 5.1 2b20bb2c91 docs/RUST.md: queue -- bench header type shrunk to fit, emulator crash loop after backgrounding
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 21:01:27 -04:00
irisandClaude Fable 5.1 3c80d9d696 iris bench: a Trace switch for the input/frame diagnostics, and the report says when it was on
`iris::diagnostics::set_trace` landed with nothing to press it. It is the
bench header's fourth control now, reading `Trace off` or `Trace on` --
a toggle whose own appearance never changes is a button that looks like
it did nothing. Its accessibility label stays the fixed "Trace input and
frames", because that is what `run-bench.sh` and `ui-trace --do "tap
'...'"` find it by and a control that renames itself when pressed is one
no script can find twice. Pressing it rebuilds the header and shows the
diagnostics pane, so the state is on screen at the moment of the press.

Both reports carry `trace_line`, from the flag read at the *start* of
what is being reported as well as at the end: the switch is on screen
while a benchmark runs, so "somebody moved it half way through" is a
state that happens, and reported as either "on" or "off" it would be a
confident sentence about a log covering half the run.

The row's type size is one constant for all four labels and drops from
18 to 13: with a fourth control the labels overlapped each other on a
1080px screen. Shrinking one label to fit is what the UI rules forbid;
resizing the row is a layout decision and all four still match.

Checked on the emulator: the switch flips its own text and colour, the
pane reads "input/frame trace: on", and `iris::frame`/`iris::input`
lines appear in the ring only after it is pressed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 20:58:58 -04:00
irisandClaude Fable 5.1 06b8a1f4b0 The app hands its log to Dev Updater on the phone, not through ai-server
Iris's call once the upload route was working: put it in Dev Updater
properly. So the app now exposes its own ring through a ContentProvider
at `<applicationId>.devlog` -- Dev Updater's contract, written down in
that project's README, not something invented here -- and Dev Updater's
phone app reads it on the same device and forwards it to its own build
machine. No tunnel, no token, no second enrolment, and any app that
server delivers can implement the same and get the same Runtime tab.

`DevLogProvider.java` plus `devlog.rs` are the platform glue only: a flat
`String[]` across JNI, a `MatrixCursor` on the Java side, and
`nativeReady` telling Rust the authority the provider actually
registered, so the Diagnostics pane can name somewhere a reader can
query rather than composing a guess. `LogRing::newest_seq()` is the one
addition in `client-core`: an in-memory ring starts again at zero, so it
is what lets a reader notice the process restarted instead of silently
skipping everything since.

Deleted with it, so there is one mechanism: `client_core::log_upload`,
`POST /client-log` on ai-server, the `AI_APP_LOG_*` baking (which left
`build.rs` with nothing to do), and the uploader on both Android
clients. Kept: the ring, `RingLogger`, `install_process_logger`, and the
Diagnostics line -- whose second half is now `devlog provider:
content://<authority>`.

Verified end to end on this checkout's emulator: iris's own
`iris::android::view` startup lines read out of the provider by the
shell, forwarded by Dev Updater's Runtime tab, and served back from
`GET /apps/android-app/components/app/logs?kind=runtime`. A component
whose package has no provider says so in as many words.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 20:58:48 -04:00
irisandClaude Fable 5.1 e10582a2cd iris: three layer-1 tests that could not fail in the direction the bug goes
docs/REVIEW-2026-09-07.md's T1, T2 and T3. Each was confirmed by breaking
its subject on purpose and watching the new assertion fire, and each of
those breaks is recorded beside the assertion.

**T1** (`phone_screen.rs`) bounded the fling's duration with
`FlingCalculator::new(PHONE_SCALE).duration(velocity)` -- the calculator
under test -- and only from above, so it could fail when a fling ran too
long and never when one stopped dead, which is the symptom Iris actually
reported. The companion `assert_ne!(before, after)` passes on one pixel of
travel. It now takes both bounds from `fling_spline_reference.py`, which
gains this case's own line (`density=2.55 v=15250.0: distance=11057.424px
duration=2.0716s`), and measures travel in pixels from a row's own
on-screen extent -- 10527px against the reference's 11057, the 5%
shortfall being the frames a tracked row leaves the screen on. Scaling
`tick_fling`'s elapsed by 1000 reports "stopped after 8ms"; scaling its
delta by 0.01 reports "travelled 111px".

**T2** (`top_edge.rs`) asserted the per-row box only on the return leg,
so a regression that drew rows in the wrong place while travelling
*backwards* was checked by the row count alone. The first leg still
cannot assert it (an unmeasured row has to be drawn to be measured), so
there is now a third leg -- back again, every height known. Widening
`intersects_viewport` downwards passes all 40 forward steps and fails at
"back 6", which is the leg that did not exist.

**T3** (`top_edge.rs`) asserted a mask exists and sits inside the list's
box, never that any row primitive references it, so a broken
`Mask::parent` chain -- what d507ae4 introduced -- left it green while a
code fence drew unclipped. It now walks every row primitive's chain and
requires the list's own mask slot on it (and rejects a chain that loops).
Forcing `Painter::set_mask`'s `parent` to `NONE` fails it with "clips to
[Id(1)], a chain that never reaches the list's own mask Id(0)".

Verified: `cargo test -p transcript-fixture` (12) and `cargo test --lib -p
iris` (103) pass, fmt and clippy clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 20:58:35 -04:00
irisandClaude Fable 5.1 551c01398f iris: the guards against silently wrong output survive into release
docs/REVIEW-2026-09-07.md's R1. Every invariant guard added on 2026-09-07
was a `debug_assert!`, and every build anybody runs on this project is
release -- the bench APK must be (the debug `libmain.so` is 325 MB and
will not install) and Iris's phone gets release too. So a `List` drawn
without a mask painted over its surroundings again, in exactly the build
the fault was found in, with nothing saying so.

Promoted to `assert!`, each O(1) or a handful per *draw* and each
protecting against output that is wrong on screen with no other symptom:
`List::draw`'s `painter.is_masked()`, `List`'s `extents`-are-on-screen
check, `Painter::set_mask`'s doubled-call check (the second call replaces
rather than nests, i.e. an unclipped widget), `Painter::glyphs`'s atlas
generation (glyphs sampled from coordinates now holding other letters),
and `List::fling`'s finiteness (one comparison per gesture; NaN
propagates into `deceleration_for`'s `ln()` and the fling never settles).

Left as `debug_assert!` and now saying so in a comment: `List::place`'s
slot-exists precondition (once per row placed per frame, and its release
failure is the `.expect` below rather than something wrong on screen) and
`poly_fit_least_squares`'s two preconditions (run on every velocity query,
with `MIN_SAMPLE_SIZE` and the `is_finite` check giving release a defined
outcome either way). `PointerClock::sample`'s ordering assert was already
annotated in 2ec0fee for the same reason.

Verified: `cargo test --lib -p iris` (103) and `cargo test -p
transcript-fixture` (12) pass in both debug *and* `--release`, which is
what says the promoted asserts do not fire on a real replayed flick;
fmt, clippy and `cargo ndk check -p iris` clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 20:52:45 -04:00
irisandClaude Fable 5.1 7e79ec11e0 docs: the fling's "before" velocity is what velocity_reference.py prints, 12250 and 12500
docs/REVIEW-2026-09-07.md's D5. Four places quoted 11750 px/s as the old
average estimator's answer -- for `flick-120hz.touch` *and* for the
press-plus-one-move-frame set, which are different sample sets, and one
number in both rows is the tell. `iris/benches/velocity_reference.py`,
which the same section says every number below it comes from, prints
12250 for the recording and 12500 for the two-sample set, and
`sense.rs:1406` already had the 12250.

Half of where 11750 came from is recoverable and is written down beside
the table: it is the recording's 196 px over 16.68 ms, a 60 Hz frame
rather than the 16 ms span the file itself records. That explains the
flick row; the other row was copied from it. The 1.30x ratio derived from
it becomes 1.24x.

Also settles the second disagreement about the same experiment (the
review's rule finding on the negative control): `sense.rs`'s doc comment
claimed reverting `velocity` to total-over-span fails "exactly this one,
the flick recording, and phone_screen.rs" while RUST.md said seven. Run
again today with the revert in place: seven in `-p iris` (the flick
recording, the accelerating flick, the horizon, the stopped finger, the
minimum sample count, both `drag_gesture` flick tests) plus
`phone_screen.rs`'s flick, everything else green. RUST.md was right and
the comment now says the same thing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 20:43:23 -04:00
irisandClaude Fable 5.1 2ec0fee84c iris: the input clock anchors on the first event's oldest sample, not its own time
docs/REVIEW-2026-09-07.md's D4. `on_touch_event` took its one anchor as
`(Instant::now(), event.event_time_nanos())` from the first MotionEvent the
view ever sees, and dated every later sample as `anchor_at + (sample -
anchor).max(0)`. An event's historical samples are by definition *older*
than its own event_time, so if that first event is a Move -- the Down went
to another view, or the view was attached mid-gesture -- its whole batch
clamps onto one instant: three samples at the same time make the Lsq2 fit
degenerate and the flick reads 0 px/s. In a debug build the ordering
debug_assert fired first, and it was comparing against `anchor_nanos`,
a value from a different event, so it was also the wrong comparison for
the first sample of every later event.

The arithmetic moves into `sense::PointerClock`, which anchors at
`now - (event_time - oldest_sample)` and carries the last sample seen
across events, so `sample()`'s ordering assert compares against the
previous event's last sample. It lives in `sense` rather than in the
android backend because `iris::android` is cfg'd out everywhere but the
device, and this is exactly the arithmetic that wanted a test off one:
`the_first_events_batched_samples_are_dated_apart` reports [0ns, 0ns, 0ns]
against the old anchoring.

The assert stays a `debug_assert!` and now says why in a comment: it runs
once per touch sample, hundreds a second on a batching 120Hz screen, and a
mis-ordered sample degrades a velocity rather than drawing something wrong.

Also drops the stale reference to `VelocityTracker::add_sample` in the
comment above it (the review's rule finding); the method is `add_position`.

Verified: `cargo test --lib -p iris` and `cargo ndk -t x86_64 -P 29 check
-p iris` clean, fmt and clippy clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 20:36:41 -04:00
irisandClaude Fable 5.1 992c472975 iris: iris::input/iris::frame diagnostics, and gating the four debug! lines that already drowned the ring
Iris asked for a button to copy raw input events and per-frame timings
through the same report Copy report already produces. sense::log_input_event
(one line per platform pointer sample, historical samples inline on
Android) and diagnostics::log_frame (one line per frame: frame number,
frame clock, time since last input, layout/draw durations, redraw kind,
primitives on screen, animating) both land under iris::diagnostics's
trace_enabled() gate, off by default since the ring is 2000 lines/256KiB
and either target at 120Hz fills it in seconds. report_to_touch.py turns
a report's iris::input lines back into a .touch file for harness/desktop
replay, round-tripped in transcript-fixture's input_log_roundtrip test.

Folds in docs/REVIEW-2026-09-07.md's D1: four older per-frame debug!
lines (android::view's two render() lines, list.rs's fling tick,
text/mod.rs's text render) were unconditional at Debug and, with the
ring's RingLogger recording everything the app's Debug install lets
through regardless of target, filled it before Copy report ever saw
anything else. All four (and sense.rs's drag-release-samples line) are
now behind the same gate. The same test proves both directions: tracing
off leaves zero Debug lines from a replayed flick, tracing on produces
the expected iris::input/iris::frame lines with real durations.

Not wired to a Diagnostics-pane button: bench_client.rs is open under
another agent. set_trace(bool) is the whole surface a control needs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:48:48 -04:00
irisandClaude Fable 5.1 729098756d docs: the CA travels in the enrol link, and why not the two alternatives
DECISIONS.md gets the decision with both rejected options and what the
longer link measures (89 -> 652 bytes, a 45x23 QR -> 93x47), RUST.md ticks
the enrolment queue item and marks the log-upload route superseded rather
than editing it, and IRIS.md says what changed for anyone building the
Android app.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:47:48 -04:00
irisandClaude Fable 5.1 d8562d96a3 iris android app: told which server by an enrol link, not by its build
The APK is cross-compiled here and run against the server on the host, so
everything build.rs baked in (AI_APP_TRANSCRIPT_HOST/_PORT/_TOKEN and this
machine's CA) was good for exactly the pair that built it -- and a token in
a delivered artifact besides. MainActivity registers aiapp://enroll, hands
the URI and the app's private files directory to Rust, and
client_core::config stores it 0600; transcript_client reads it afresh per
transport, so opening a new link repoints a running app.

Diagnostics says which of three things is true, because they want different
actions: 'enrolled: host:port', 'not enrolled -- open the enrol link from
Dev Updater', and 'enrolment unreadable: ...' for the case nothing could be
found out. The last is why status() has an Unknown arm at all.

ui-sandbox.sh's printed enrol command now carries the CA, which is what
makes it work for an app with no baked copy.

Verified on this checkout's emulator: fresh install reads 'not enrolled',
the intent enrols (log: 'enrolled with 10.0.2.2:8519', enrollment.json
-rw-------), Diagnostics then reads 'enrolled: 10.0.2.2:8519', and the CA
reconstructed from that link is byte-identical to the machine's ca.pem and
validates the server over curl. Android offered the chooser between this
app and the Compose one, which is the intended behaviour.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:45:51 -04:00
irisandClaude Fable 5.1 22210a42f5 docs: review of 2026-09-07's work -- 5 defects, 7 risks, 3 tests that cannot fail
Read-only review of ba2afba..origin/rustify (the fling spline and Lsq2
velocity, list culling/clamp/anchor re-homing, nested masks, the headless
harness, insets/targetSdk, platform fonts, and the client-core log ring
with POST /client-log).

The three that matter most: the app's own log ring is installed at
LevelFilter::Debug while the same day added three ungated per-frame
`log::debug!` callsites, so the 2000-line ring wraps in under ten seconds
and the route built to get Iris's logs to her carries frame spam instead;
POST /client-log inherits the router's 32 MiB body limit with no
per-message or rate cap, so an authenticated client can fill the host's
disk through ai-server's runtime log; and every invariant added today is
a `debug_assert!` while the phone and the bench APK are both release
builds, so none of the new guards can fire where the defects were found.

Also: the input clock anchors on the first MotionEvent's own event_time,
so that event's historical samples date before the anchor and are
silently clamped onto one instant; the "before" fling velocity quoted in
four docs (11750 px/s) is not what velocity_reference.py prints (12250);
masks clip drawing but not hit-testing, so a straddling row is now
invisible above the list and still tappable through the header.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:38:43 -04:00
irisandClaude Fable 5.1 ade572973a enrolment carries the CA, and one store holds it on every platform
An APK built in this VM pins this VM's CA, so it can never reach the
host's ai-server -- which is exactly the iris Android client's situation
(cross-compiled here, run against the host). So ai-server now puts the CA
in every enrollment link it mints, base64url of its DER under the 'ca'
parameter wg-app-link just learned to add, and client_core parses it back
out as PEM. Nothing has to be built on the machine it talks to.

Refused rather than ignored where 'ca' does not decode: a link that named
a certificate and then pinned nothing is the one outcome nothing
downstream could notice.

EnrollmentStore moves out of desktop-app into client_core::config, since
the Android client needs the same file for the same reason and only the
directory differs by platform (AGENTS.md's sharing rule). desktop-app's
--ca becomes the override for a link that carried none.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:34:12 -04:00
irisandClaude Fable 5.1 9b27e858b5 docs/RUST.md: APK runtime logs in Dev Updater via an on-device ContentProvider (Iris, 2026-09-07); supersedes the ai-server client-log route
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:30:08 -04:00
irisandClaude Fable 5.1 7e4e26a335 iris: resolve fontique's Android monospace generic family ourselves
fontique 0.11.1's Android backend never resolves GenericFamily::Monospace
(mono=None in the startup diagnostic, RUST.md's 2026-09-07 "Platform
fonts" gap): DEFAULT_GENERIC_FAMILIES looks up "monospace" against
name_map before fonts.xml is parsed into it, and even after parsing,
AOSP's fonts.xml names it with a <family name="monospace"> element whose
<font> children the backend's own parser never reads (a TODO left in
place) -- so the name gets a FamilyId with no font data behind it, and
family_by_name("monospace") comes back empty too. Confirmed still present
on linebender/parley's main branch, so there is no newer release to bump
to.

TextData::patch_android_monospace (Android-only, called from
TextData::default) reads fonts.xml's own "monospace" declaration for the
font filename it names, then finds which of fontique's actually-scanned
families owns a font file with that name and registers it as the
Monospace generic directly -- the same authority Compose's
Typeface.MONOSPACE resolves through, without pinning an OEM-specific
family name. Verified on this checkout's emulator:
mono=Some("Droid Sans Mono") in the startup log, and a screenshot showing
the bench-fixture's code block and tool-card values in a visibly
monospaced face beside sans body/heading text. Desktop's fontconfig
backend is unaffected.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:25:27 -04:00
irisandClaude Fable 5.1 84a13e806b iris: a fling starts at Compose's velocity, which is a curve fit and not an average
Iris, from the phone on the 4274b8b build: "flinging now actually works
but is slower than Compose's immediately after releasing the flick (the
slow down seems correct)." The spline was already AOSP's; the initial
velocity was not.

`VelocityTracker` held per-frame pan deltas and answered their sum over
the sample span -- an average, which cannot tell an accelerating flick
from a steady drag. Ported from the `-sources.jar` of
androidx.compose.ui:ui-android:1.12.0 and
androidx.compose.foundation:foundation-android:1.12.0 (the versions the
Compose app builds against) rather than from memory, and the reading
corrected the plan twice:

  * The touch path is not `Strategy.Impulse`. `scrollable`/`draggable`
    release through the 2D `VelocityTracker`, which on Android is two
    `VelocityTracker1D(strategy = Lsq2)` over absolute positions -- a
    degree-2 least-squares fit differentiated at the newest sample.
    Impulse is reached only by `DifferentialVelocityTracker`, whose one
    caller is `NonTouchScrollingLogic`: wheel and trackpad.
  * There is no minimum fling velocity. `ViewConfiguration`'s 50dp/s is
    used only by `NestedScrollInteropConnection`; `DefaultFlingBehavior`
    skips `abs(v) <= 1f`, and says in its own comment that this is to
    dodge a NaN out of the spline. So `List::fling` caps at 8000dp/s
    against its own density and floors at 1px/s, and no threshold
    Compose does not have was added.

So the tracker holds positions rather than deltas (Lsq2 refuses
differential data in Compose too), 20 of them, with Compose's 100ms
horizon and 40ms stopped-gap; `DragGesture` feeds the raw window
coordinate along the drag axis at the press and every `Pan` frame.

`iris/benches/velocity_reference.py` is the independent transcription
the checked-in numbers come from, as `fling_spline_reference.py` is for
the curve. On `flick-120hz.touch`: 11750px/s before, 15250px/s after. On
an accelerating flick -- the shape a real finger makes, which that 16ms
recording is too short to show -- 1080 before, 2445 after. An average
also flings from a standstill (2533px/s where Compose says 0) and flings
from two points that describe no curve.

Negative control: reverting `velocity` to `total / span` fails exactly
seven tests, all of them about the estimator, and leaves the steady
drag, the tap, the selection release, the sixteen arbiter tests and the
rest of phone_screen.rs passing.

`iris drag release:` keeps its info line and gains a debug
`iris drag release samples:` with every held sample as `t_ms:position`,
so a flick that felt wrong on a phone with no logcat can be replayed at
layer 1 or pasted into the reference script.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:24:29 -04:00
irisandClaude Fable 5.1 452c44249f docs/RUST.md: queue -- logging landed; iris app enrolment replaces the build-time log destination; build-apk.sh traps
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:22:29 -04:00
irisandClaude Fable 5.1 238057ad5e docs: the phone-logging decision, how to use it, and two build-apk traps
DECISIONS.md gets the route and both rejected alternatives with what each
would have cost; RUST.md gets a "Phone logging" section with the build
command, where to read it on the phone, the end-to-end verification, and
the two rig traps that cost an hour -- Gradle's merged-native-libs cache
surviving build-apk.sh's `rm -rf jniLibs` (a --abi x86_64 APK packaged
arm64 and aborted with what reads exactly like a Vulkan fault), and the
648 MB debug bench APK that cannot be installed at all. IRIS.md gets the
client-core logging API with a before/after.

Queue item ticked.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:21:23 -04:00
iris 896c93a59a iris: drop bundled Noto Sans, match Compose's platform-font fonts
Iris's call: "remove the font for now; just match what compose does."
Removes the six embedded Noto Sans/Noto Sans Mono TTFs (3.6 MB) that
TextData::default used to register ahead of the platform's own fonts;
fontique's system font discovery was already on by default and now
runs unshadowed (Roboto/Roboto Flex on Android, fontconfig on the
desktop). .so -3,748,136 bytes (11,193,608 -> 7,445,472), matching the
estimate. Verified fallback still lands on visible tofu for CJK/emoji
rather than blank, and flagged (not fixed) a fontique Android backend
gap that leaves Monospace unresolved -- see RUST.md's "Platform fonts
(2026-09-07)" and DECISIONS.md/IRIS.md's dated entries.
2026-09-07 16:14:34 -04:00
iris 690161e5e9 docs: the transcript's edges were three faults, and what the rig found
IRIS_TODO's 2026-09-07 top-edge entry closed with the root cause of
each, the six layer-1 test names, and what was suspected and turned out
not to be it -- no culling test compared a row's top against the
viewport's, and 03c6be8's header duplicate is untouched and still open.
The later report's "you shouldn't be able to scroll below the bottom (or
above top)" is ticked with why the clamp is a correction measured from
the layout walk rather than a clamp inside the scroll setter: nothing at
the moment of a scroll knows where the content ends.

RUST.md gains the same account in "Where things stand", plus the three
things this said about the new test rig -- layer 1 found all of it in
seconds and the emulator was not used; layer 2 is where the missing clip
is visible, with the command; and an assertion that reads the wrong
thing hides the bug it is for, which is how a list resting 1398px past
its own first row passed a test about stopping at that row.

Also the last of the six tests, the bottom end of the clamp
(`scrolling_past_the_last_row_settles_on_it`) -- the same rule at the
edge the top-edge work had no reason to touch.
2026-09-07 16:07:37 -04:00
iris e922b73d7a iris: a transcript row is drawn if it overlaps the viewport, and clipped to it
Iris's phone, 2026-09-07, two screenshots of the transcript at its top
edge wrong in opposite directions: rows already scrolled past still
drawn, over the header bar (`version = "0.1.0"` behind "Run benchmark"),
and a blank band where the row straddling the edge should be. Three
faults, one rule -- `List::intersects_viewport`: a row is drawn if any
part of it is inside the list's own box, and nothing outside that box
reaches the screen.

1. **The walk drew everything between the anchor and the viewport.**
   `scroll` moves the anchor's offset and nothing else, so panning leaves
   the anchor's own row further and further outside the viewport, and
   every row in between was placed *and drawn* on every frame. Measured
   on the bench fixture: 8 scrolls of 3000px left 64 rows drawn for a
   2012px viewport, ~59 of them off screen. `place` now skips a row whose
   height is already known and whose box does not overlap; `rehome_anchor`
   moves the anchor onto a visible row each frame, without moving
   anything drawn, so the walk is O(visible) again whatever distance was
   travelled. `extents` holds only what is on screen, which is what
   `key_at` already claimed of it, asserted at the end of every draw.

2. **Nothing clipped the list.** A straddling row is drawn in full --
   that is the rule -- so the part above the list was on screen. The
   transcript's list is `.masked()` now (the mechanism `examples/
   message_list.rs` and the composer already use, and one that nests as
   of the previous commit), and `List::draw` asserts it has a mask rather
   than leaving that to each caller to remember.

3. **A fling past the first row stayed past it.** `tick_fling` stops a
   fling that has reached an end, wherever the spline's last step had put
   it: `fling_toward_the_start_stops_at_the_first_row` was leaving the
   first row 1398px below a 600px viewport -- a blank screen -- and its
   assertion could not see it, since `extents` then held off-screen rows
   too and `top >= -0.5` is satisfied by +1398. `clamp_to_content` gives
   the gap back from the ends the walk already placed. Only when the
   opposite end is not also in the viewport, so a list shorter than its
   viewport stays bottom-anchored as before.

Layer 1 of the test rig throughout (`transcript-fixture/tests/
top_edge.rs`, the real screen under a bench-app-shaped header): each of
the five fails on its own subject and no other -- culling on the row's
top instead of its bottom fails only `the_row_across_the_top_edge_is_
drawn`, the pre-fix walk fails only the two about what is placed,
dropping `.masked()` fails only `the_list_is_clipped_to_its_own_box`,
dropping the clamp fails only `scrolling_past_the_first_row_settles_on_
it`. The bottom edge and a list shorter than the viewport are the ends
none of this had a reason to touch and are covered too.
2026-09-07 16:05:31 -04:00
iris d507ae4c96 iris-core: masks nest instead of aborting, and a widget can ask to be drawn again
`Painter::set_mask` refused a widget any mask of its own once an
ancestor had set one -- `assertion failed: self.mask == MaskIdx::NONE`
-- so clipping was one level deep wherever it was used at all. That is
what stopped the transcript's `List` from being clipped to its own box:
its rows already use `.masked()` themselves (a code fence, a tool card's
one-line title), and giving the list one aborted on the first fence
drawn.

A mask now carries the mask it was set inside (`Mask::parent`) and the
fragment stage walks that chain, so a pixel has to be inside every mask
on it. Chained rather than intersected on the CPU because each mask
moves with its own widget: a fence inside a transcript row carries the
row's scroll and the list's box does not, and one region resolved when
the fence was last drawn gets the second of those wrong as soon as the
row is moved rather than redrawn -- which is every scroll frame. The
child holds one ref on its parent's slot, released where the child's own
slot is, so a chain cannot outlive what it points at. The old assert
survives as the case that is still wrong: the same widget setting two
masks, which since a mask now chains would be a clip loop.

Also `Painter::draw_again`, for a layout that can only discover a
correction to itself by laying out once -- `List::clamp_to_content`, in
the commit after this -- and `Painter::is_masked`, which is how a widget
that draws outside its own box can require something to be clipping it.
2026-09-07 16:05:13 -04:00
irisandClaude Fable 5.1 9ed01e2812 docs: phone report 2026-09-07 later -- overscroll, low initial fling velocity, input/timing report; queued
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:04:34 -04:00
irisandClaude Fable 5.1 5be9f1baac iris-android-app: keep the app's own log, put it in Copy report, upload it
`app_log` is the platform half: `android_logger` as the logger the ring
forwards to, and an optional destination baked in by `build.rs` from
`AI_APP_LOG_HOST`/`_PORT`/`_TOKEN` plus the pinned CA -- the same
build-time trust boundary the transcript config and the Compose APK's CA
already use, so no token is committed and an APK is good for the server
that built it. All three or none: two of the three would be a build with
nowhere to send its log and no way to say so.

`Copy report` now appends the ring to what goes on the clipboard (not to
the pane, which is on screen and would be buried) and flushes the
uploader first, so the lines are on the server by the time the message
describing them arrives. The Diagnostics pane gains two lines: how many
lines are held and when the last arrived, and what the uploader last did
-- "not tried yet", "failing -- <why>", and "no server configured" are
each their own wording, because "nothing is arriving" has three causes
that look identical otherwise.

Also: the re-emitted lines carry the target `ai_server::client_log`, not
a bare `client_log`. `RUST_LOG=ai_server=debug` -- the filter AGENTS.md
tells people to run with -- drops a bare target, so every line a phone
sent vanished with nothing saying so. Found by running it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:01:30 -04:00
irisandClaude Fable 5.1 977bdb9ee0 client-core: the app's own log ring, and POST /client-log to get it off a phone
Iris tests iris builds on a phone with no adb, and Android forbids one
app reading another's logcat, so a `log::info!` in the app can only reach
her if the app carries its own copy and sends it somewhere.

`client_core::log_ring` is that copy: a bounded ring (2000 lines / 256
KiB, whichever bites first) behind a `log::Log` backend that forwards to
whichever real logger the platform installed, so `logcat` and the desktop
terminal see exactly what they saw before. Reading does not consume --
the report and the uploader are two readers of one ring.

`client_core::log_upload` drains it into ai-server's new `POST
/client-log`, which re-emits each line into the server's own tracing
output. Dev Updater already shows that as ai-server's runtime log, so
nothing new is built there. A failed batch is retried from the same
cursor, and nothing in the upload path calls `log!` -- it would land in
the ring it is draining.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 15:56:20 -04:00
irisandClaude Fable 5.1 9cd1263080 docs/RUST.md: queue -- APK size done, the embedded-fonts question left for Iris
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 15:47:53 -04:00
iris 42af780639 iris android-app: strip+LTO+cgu1+opt-level=s halve libmain.so, no feature trim needed
Baseline had panic=abort only. Measured each setting in order (docs/RUST.md's
new "APK size (2026-09-07)" subsection has the full table and crate
breakdown): strip=true, lto="fat", codegen-units=1, opt-level="s" take
libmain.so from 18,546,488 to 11,193,608 bytes (-39.7%) and the release APK
from 20,678,956 to 13,326,076 bytes (-35.5%), arm64-v8a. opt-level="z" was
measured (another ~800KB) but not adopted without a frame-time check.

Investigated naga/wgpu backend features and tabs-ui/tabs-screen as trim
candidates; both are already fully eliminated by the linker on Android
(0 symbols in `llvm-nm` on the baseline .so), so no Cargo feature change
would shrink the binary -- left as documented findings rather than a diff.

Embedded Noto Sans fonts (3.6 MB) and the wgpu/naga/font-shaping stack
account for most of what remains vs. Compose, which borrows the platform's
own renderer and fonts for free; recorded honestly in the doc rather than
trimmed, since subsetting fonts or dropping a backend would change what
iris can render.
2026-09-07 15:46:40 -04:00
115 changed files with 16903 additions and 3415 deletions

No files matched your search

+27 -3
View File
@@ -72,9 +72,10 @@ Module-by-module intent is in `docs/PLAN.md`'s "Backend layout".
conditions. Read it before touching anything under that branch.
- `docs/IRIS.md`, `docs/IRIS_TODO.md`, `docs/DECISIONS.md`,
`docs/LAYOUT.md`, `docs/TEXTURES.md`, `docs/CLIENT_CORE.md` — iris's
own public API log, working list, decisions log, layout/render design,
and texture-atlas design, and the client-core crate's design,
respectively.
own build log (**any major addition or design decision, not only
public API** -- Iris, 2026-09-08), working list, decisions log,
layout/render design, and texture-atlas design, and the client-core
crate's design, respectively.
- `.dev-updater.ron` — what Dev Updater builds here: the server (run as
`service: Managed(…)`, supervised by Dev Updater's own implementation
rather than a script kept here) and the APK, in parallel. It points at
@@ -100,6 +101,18 @@ the **Mono** face, where every glyph is one em square, which is what makes
two icon buttons the same width without either being given one — and why
`GLYPH_SIZE` is smaller than it looks like it should be.
**The Rust app does the same, from its own subset**:
`iris/core/build-icon-font.sh` -> `iris/core/assets/fonts/nerd_icons.ttf`,
with the codepoints named in `iris/core/src/icon.rs` and drawn as text
with `Family::Icons`. Same rule about the two lists agreeing (there is a
test, `every_icon_is_in_the_bundled_font`), same Mono face, same Material
Design family so an icon means the same thing in both apps. Its subset is
separate rather than shared because subsetting only what one app draws is
the point. This is the **only** font iris bundles — body and monospace
text come from the platform (docs/DECISIONS.md, 2026-09-07), and an icon
is the opposite case: a small closed set of codepoints no system font is
guaranteed to have.
## Checking your work
- **Server**: `./run-tests.sh` from the repo root (or `cargo test` from
@@ -297,6 +310,17 @@ Each exists because something was invisible without it.
into it. The emulator is for JNI, the IME, insets, the surface
lifecycle and one verification run before a build goes to the phone --
not for iterating on layout.
- **The emulator is a GLES rig, deliberately** (Iris, 2026-09-08;
docs/DECISIONS.md). Its guest has no hardware Vulkan -- only SwiftShader
in software -- while its GLES *is* the host's real GPU through virgl at
ES 3.1, so an ordinary build's runtime fallback lands there by itself
and nothing should pass `force-gles` to arrange it. The Vulkan path is
verified on the desktop build and on Iris's phone. Do not boot the
emulator with SwiftShader Vulkan to "test the Vulkan path": that
measures a software rasteriser and steers iris away from the one
hardware-accelerated backend it has there. Every run says which adapter
drew it (`iris renderer:` in logcat, printed by `run-bench.sh`); read
that line before reading a number.
### Driving the UI
+8 -6
View File
@@ -82,7 +82,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
name = "client-core"
version = "0.1.0"
dependencies = [
"base64",
"event-model",
"log",
"pulldown-cmark",
"serde",
"serde_json",
@@ -588,9 +590,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.43"
version = "0.23.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba"
dependencies = [
"log",
"once_cell",
@@ -844,9 +846,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "3.4.0"
version = "3.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d"
checksum = "af5546be8f5378d5414f83733f5c9a2526f4645829edbc1c41790aeef1b38e8b"
dependencies = [
"base64",
"cookie_store",
@@ -864,9 +866,9 @@ dependencies = [
[[package]]
name = "ureq-proto"
version = "0.6.1"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613"
checksum = "fabc3e92916c89c95b20eef7b06b00b066bc217ef9ea3a4ac9bf1a7e35261e10"
dependencies = [
"base64",
"http",
+16 -1
View File
@@ -404,12 +404,27 @@ done
# Percent-encoded because the app URL-decodes the deep link's query: a
# token with '+' in it enrols as one with a space, and nothing reports it.
enc=$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$TOKEN")
# The CA rides in the link (`wg_app_link::enroll::ca_param`: base64url of
# the DER, which needs no percent-encoding). The Compose app ignores it and
# pins the copy its APK was built with; the iris app has no baked copy at
# all -- it is cross-compiled and could be pointed at any machine -- so
# without this it enrols and then trusts nothing. Minted here rather than by
# `--enroll-link` because this token is the sandbox's own, carried across
# restarts so the emulator stays enrolled (see the top of this file).
ca=$(python3 - "$CERTS/ca.pem" <<'CA'
import base64, sys
pem = open(sys.argv[1]).read()
body = pem.split("-----BEGIN CERTIFICATE-----")[1].split("-----END CERTIFICATE-----")[0]
der = base64.b64decode("".join(body.split()))
print(base64.urlsafe_b64encode(der).decode().rstrip("="))
CA
)
cat <<INFO
sandbox: server $pid on 127.0.0.1:$PORT, log $LOG
sandbox: 9 invented Claude Code sessions under $PROJECTS (one of them ${BIG_MB}MB)
enrol the emulator (once; it survives sandbox restarts):
adb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=$PORT&token=$enc'"
adb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=$PORT&token=$enc&ca=$ca'"
drive it:
./ui-sandbox.sh spawn [title] an echo session; prints its id
+8 -6
View File
@@ -46,7 +46,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
name = "client-core"
version = "0.1.0"
dependencies = [
"base64",
"event-model",
"log",
"pulldown-cmark",
"serde",
"serde_json",
@@ -498,9 +500,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.43"
version = "0.23.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba"
dependencies = [
"log",
"once_cell",
@@ -716,9 +718,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "3.4.0"
version = "3.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d"
checksum = "af5546be8f5378d5414f83733f5c9a2526f4645829edbc1c41790aeef1b38e8b"
dependencies = [
"base64",
"cookie_store",
@@ -736,9 +738,9 @@ dependencies = [
[[package]]
name = "ureq-proto"
version = "0.6.1"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613"
checksum = "fabc3e92916c89c95b20eef7b06b00b066bc217ef9ea3a4ac9bf1a7e35261e10"
dependencies = [
"base64",
"http",
+9
View File
@@ -37,6 +37,15 @@ ureq = { version = "3", features = ["json"] }
# the same parser at the same version, rather than a hand-written splitter
# that would drift from it.
pulldown-cmark = "0.13.4"
# The enrollment link's `ca` parameter is base64url of the CA's DER
# (`config::parse_link`). Same version `wg-app-link` already pins for the
# minting half, so a workspace that has both resolves one copy.
base64 = "0.23"
# The logging facade only -- `log_ring` implements a `log::Log` backend and
# wraps whichever real one the platform installed (`android_logger` on the
# phone, `env_logger` on the desktop), which is why neither of those is a
# dependency here. See `log_ring`'s module doc.
log = { version = "0.4.34", features = ["std"] }
[dev-dependencies]
+232 -16
View File
@@ -6,33 +6,57 @@
//! the same text a phone would scan as a QR, with no second format
//! invented for it (RUST.md's E4).
//!
//! What this type deliberately does not decide: where it is persisted, and
//! under what file permissions. A phone seals its token in the Android
//! Keystore; a desktop client has its own `$XDG_CONFIG_HOME/<app>/`
//! directory and its own file-mode conventions (MACHINE.md: owner-only,
//! never in the repo). Both are caller-specific, so they stay out of this
//! crate per the code rules' "ask for the least you need" -- see
//! `iris/desktop-app/src/config.rs` for the desktop instance.
//! [`EnrollmentStore`] persists one of these as JSON, owner-only, in a
//! directory the caller names -- `$XDG_CONFIG_HOME/ai-app-desktop` for the
//! desktop app, the app-private files directory on Android. **Which**
//! directory is the only part left to the platform: the format, the file
//! mode and the "nothing saved yet is not an error" answer are the same on
//! both, and were written twice before this.
//!
//! JSON rather than the project's usual RON: `wg-app-link`'s RON house
//! rules (`format`) are for configs a person hand-edits, and this file
//! never is one -- only the app itself writes or reads it.
use base64::Engine;
use serde::{Deserialize, Serialize};
use std::io;
use std::path::{Path, PathBuf};
/// One enrolled server: reachable at `https://{host}:{port}`, authenticated
/// with `token` as a bearer header. Does not carry the pinned CA -- that is
/// a public certificate rather than a secret, and where to find it differs
/// by caller (a phone pins the one its APK was built against; a desktop
/// client is told a path).
/// with `token` as a bearer header.
///
/// `ca_pem` is the trust anchor to pin, when the link carried one (the
/// `ca` parameter, `wg_app_link::enroll::ca_param`). It is optional
/// because an app built on the machine its server runs on pins the CA at
/// build time and needs nothing from the link; one built elsewhere -- the
/// iris Android client is cross-compiled in a VM and run against the
/// host's server -- has no other way to get it. A public certificate
/// rather than a secret, so it costs the link nothing but length.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EnrolledServer {
pub host: String,
pub port: u16,
pub token: String,
/// `#[serde(default)]` so an enrollment saved before this field
/// existed still loads, as the enrolled server it always was.
#[serde(default)]
pub ca_pem: Option<String>,
}
impl EnrolledServer {
/// Parses `aiapp://enroll?host=H&port=P&token=T` (query order does not
/// matter; unrecognised keys are ignored). `token` is percent-decoded,
/// since `ui-sandbox.sh` encodes it precisely because a raw token can
/// contain `+`, which turns into a space if left to a naive splitter.
/// Parses `aiapp://enroll?host=H&port=P&token=T[&ca=B]` (query order
/// does not matter; unrecognised keys are ignored). `token` is
/// percent-decoded, since `ui-sandbox.sh` encodes it precisely because
/// a raw token can contain `+`, which turns into a space if left to a
/// naive splitter.
///
/// `ca` is base64url of the certificate's DER and is rebuilt into PEM
/// here, because that is what every consumer of it wants
/// (`UreqTransport::new`, and the file a person points `curl --cacert`
/// at). A `ca` that does not decode fails the whole link rather than
/// enrolling a server with no trust anchor: the link said which
/// certificate to pin, and quietly not pinning it is the one outcome
/// nothing downstream could notice.
pub fn parse_link(link: &str) -> Result<Self, String> {
let query = link.split_once('?').map(|(_, q)| q).ok_or_else(|| {
format!(
@@ -44,6 +68,7 @@ impl EnrolledServer {
let mut host = None;
let mut port = None;
let mut token = None;
let mut ca = None;
for pair in query.split('&') {
let Some((key, value)) = pair.split_once('=') else {
continue;
@@ -53,6 +78,7 @@ impl EnrolledServer {
"host" => host = Some(value),
"port" => port = Some(value),
"token" => token = Some(value),
"ca" => ca = Some(value),
_ => {}
}
}
@@ -63,8 +89,14 @@ impl EnrolledServer {
.parse()
.map_err(|e| format!("'{link}''s port ('{port_str}') is not a number: {e}"))?;
let token = token.ok_or_else(|| format!("'{link}' is missing 'token'"))?;
let ca_pem = ca.map(|ca| pem_from_link_param(&ca)).transpose()?;
Ok(Self { host, port, token })
Ok(Self {
host,
port,
token,
ca_pem,
})
}
/// Where a `client_core::api::UreqTransport` reaches this server.
@@ -73,6 +105,80 @@ impl EnrolledServer {
}
}
/// The `ca` parameter (base64url of DER, unpadded) as a PEM certificate.
fn pem_from_link_param(ca: &str) -> Result<String, String> {
let der = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(ca.as_bytes())
.map_err(|e| format!("the link's 'ca' is not base64url ({e})"))?;
let body = base64::engine::general_purpose::STANDARD.encode(&der);
let mut pem = String::from("-----BEGIN CERTIFICATE-----\n");
for line in body.as_bytes().chunks(64) {
pem.push_str(std::str::from_utf8(line).expect("base64 is ASCII"));
pem.push('\n');
}
pem.push_str("-----END CERTIFICATE-----\n");
Ok(pem)
}
/// Where one client keeps the enrollment it should not have to be told
/// about a second time. `dir` is the caller's, because that is the only
/// part that differs by platform -- see this module's doc.
pub struct EnrollmentStore {
dir: PathBuf,
}
impl EnrollmentStore {
pub fn new(dir: impl Into<PathBuf>) -> Self {
Self { dir: dir.into() }
}
pub fn dir(&self) -> &Path {
&self.dir
}
fn file(&self) -> PathBuf {
self.dir.join("enrollment.json")
}
/// Writes `server` under `dir`, creating it if needed, and sets the
/// file owner-only -- it carries a bearer token, the same reason
/// `server/`'s own token store is 0600.
pub fn save(&self, server: &EnrolledServer) -> io::Result<()> {
std::fs::create_dir_all(&self.dir)?;
let path = self.file();
let json = serde_json::to_vec_pretty(server)
.expect("EnrolledServer holds nothing that fails to serialise");
std::fs::write(&path, json)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
/// `Ok(None)` when nothing has been enrolled yet, rather than an error
/// -- "not enrolled" is an ordinary first-run state, not a failure
/// (UI_RULES' "a deliberate choice is not a problem to report" applies
/// just as well to a file that simply hasn't been written yet).
pub fn load(&self) -> io::Result<Option<EnrolledServer>> {
let path = self.file();
match std::fs::read(&path) {
Ok(bytes) => {
let server = serde_json::from_slice(&bytes).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("{} is not a valid enrollment ({e})", path.display()),
)
})?;
Ok(Some(server))
}
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
}
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
@@ -108,6 +214,7 @@ mod tests {
host: "127.0.0.1".to_string(),
port: 8547,
token: "abcDEF123".to_string(),
ca_pem: None,
}
);
assert_eq!(server.base_url(), "https://127.0.0.1:8547");
@@ -141,6 +248,115 @@ mod tests {
);
}
/// The CA travels as base64url of the DER and comes back out as the
/// PEM every consumer of it wants -- the same round trip
/// `wg_app_link::enroll::ca_param` mints.
#[test]
fn a_ca_in_the_link_comes_back_as_pem() {
let der = [0x30u8, 0x82, 0x01, 0xfb, 0x3e, 0x7f];
let param = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(der);
let server =
EnrolledServer::parse_link(&format!("aiapp://enroll?host=h&port=1&token=t&ca={param}"))
.unwrap();
let pem = server.ca_pem.expect("the link carried a CA");
assert!(pem.starts_with("-----BEGIN CERTIFICATE-----\n"), "{pem}");
assert!(
pem.trim_end().ends_with("-----END CERTIFICATE-----"),
"{pem}"
);
assert_eq!(
base64::engine::general_purpose::STANDARD
.decode(
pem.lines()
.filter(|l| !l.starts_with("-----"))
.collect::<String>()
)
.unwrap(),
der
);
}
/// A link with no `ca` is an ordinary link, not a broken one: an app
/// that pins at build time mints and reads exactly these.
#[test]
fn no_ca_parameter_is_none_not_an_error() {
let server = EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=t").unwrap();
assert_eq!(server.ca_pem, None);
}
/// The half that cannot be noticed later: a `ca` that does not decode
/// must fail the link rather than enrolling with nothing pinned.
#[test]
fn a_ca_that_does_not_decode_fails_the_link() {
let err =
EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=t&ca=not!base64url")
.unwrap_err();
assert!(err.contains("ca"), "{err}");
}
#[test]
fn a_saved_enrollment_reads_back_the_same() {
let dir = tempfile::tempdir().unwrap();
let store = EnrollmentStore::new(dir.path());
let server = EnrolledServer {
host: "127.0.0.1".to_string(),
port: 8547,
token: "tok".to_string(),
ca_pem: Some("-----BEGIN CERTIFICATE-----\nQUJD\n-----END CERTIFICATE-----\n".into()),
};
store.save(&server).unwrap();
assert_eq!(store.load().unwrap(), Some(server));
}
#[test]
fn nothing_saved_yet_is_none_not_an_error() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(EnrollmentStore::new(dir.path()).load().unwrap(), None);
}
/// An enrollment written before `ca_pem` existed still loads.
#[test]
fn an_enrollment_without_a_ca_still_loads() {
let dir = tempfile::tempdir().unwrap();
let store = EnrollmentStore::new(dir.path());
std::fs::create_dir_all(dir.path()).unwrap();
std::fs::write(
dir.path().join("enrollment.json"),
br#"{"host":"h","port":1,"token":"t"}"#,
)
.unwrap();
assert_eq!(store.load().unwrap().unwrap().ca_pem, None);
}
#[test]
#[cfg(unix)]
fn the_saved_file_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let store = EnrollmentStore::new(dir.path());
store
.save(&EnrolledServer {
host: "h".to_string(),
port: 1,
token: "t".to_string(),
ca_pem: None,
})
.unwrap();
let mode = std::fs::metadata(dir.path().join("enrollment.json"))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
}
#[test]
fn a_corrupt_file_is_named_in_the_error() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("enrollment.json"), b"not json").unwrap();
let err = EnrollmentStore::new(dir.path()).load().unwrap_err();
assert!(err.to_string().contains("enrollment.json"));
}
#[test]
fn a_non_numeric_port_is_named_in_the_error() {
let err = EnrolledServer::parse_link("aiapp://enroll?host=h&port=x&token=t").unwrap_err();
+1
View File
@@ -8,6 +8,7 @@ pub mod config;
pub mod durations;
pub mod event_stream;
pub mod highlight;
pub mod log_ring;
pub mod markdown_blocks;
pub mod notifications;
pub mod sse;
+812
View File
@@ -0,0 +1,812 @@
//! The app's own recent log, held in memory so it can be read back
//! without `logcat`.
//!
//! **Why this exists**: Iris tests iris builds on a GrapheneOS phone with
//! no `adb`, and Android forbids one app reading another's logcat, so
//! nothing outside the process can recover what it wrote. The only way a
//! line reaches her is for the app to carry its own copy. This is that
//! copy: a bounded ring every `log::info!` in the process lands in, on top
//! of whichever platform logger was already installed (`android_logger`,
//! `env_logger`) rather than instead of it -- see [`RingLogger`].
//!
//! Two consumers, both reading the same ring rather than each keeping
//! their own: the bench app's `Copy report`/`Diagnostics` (which reads
//! [`LogRing::tail_text`] and [`LogRing::summary`]) and whatever hands the
//! log out of the process -- on Android, the `DevLogProvider` Dev Updater
//! queries, which reads [`LogRing::since`] and [`LogRing::newest_seq`].
//! That is why reading does not consume: a line already handed over must
//! still be in the report, and a report taken twice must say the same
//! thing.
use std::collections::VecDeque;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
/// How many lines a default ring holds, and how many bytes of message.
///
/// Both bounds apply -- whichever bites first -- because the two failure
/// modes are different: a flood of short lines exhausts the count, and one
/// pathological line (a stack trace, a pretty-printed JSON body) exhausts
/// the bytes. A ring bounded only by lines can hold megabytes; one bounded
/// only by bytes can be emptied by a single line.
pub const DEFAULT_MAX_LINES: usize = 2000;
pub const DEFAULT_MAX_BYTES: usize = 256 * 1024;
/// How many of the ring's newest lines [`LogRing::tail_text`] includes.
/// Sized for a phone's share sheet rather than for the ring itself: 150
/// lines of `HH:MM:SS.mmm LEVEL target: message` is a few KiB, comfortably
/// short of whatever made pasting the full (up to 2000-line) ring into a
/// chat's message box laggy on Iris's phone. The full ring is still
/// reachable through `devlog`'s provider, so this only bounds what a
/// report inlines.
pub const COPY_REPORT_TAIL_LINES: usize = 150;
/// One recorded line. `seq` is assigned by the ring and only ever
/// increases, so a reader that remembers where it got to can ask for what
/// came after -- and a gap in the sequence is exactly the lines the bound
/// dropped.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogLine {
pub seq: u64,
/// Milliseconds since the unix epoch, from the app's own clock. The
/// app's rather than the receiver's: a line is timestamped when it
/// happened, and an upload can be minutes later or never.
pub at_ms: u64,
pub level: log::Level,
pub target: String,
pub message: String,
}
impl LogLine {
/// Roughly what the line costs the ring. The two `String`s dominate;
/// the fixed fields are counted as a flat overhead so a ring of empty
/// messages still has a bound.
fn weight(&self) -> usize {
self.target.len() + self.message.len() + 32
}
/// `12:34:56.789 INFO iris::android: the message`, the shape a
/// person skims. Time of day only -- the date is in the report's own
/// header, and a ring never spans one.
pub fn format(&self) -> String {
format!(
"{} {:<5} {}: {}",
clock_time(self.at_ms),
self.level,
self.target,
self.message
)
}
}
/// `HH:MM:SS.mmm` in UTC from a unix millisecond count, without a date
/// library: the only field this needs is the time of day, and dividing out
/// the day is the whole calculation. Deliberately not local time -- the
/// phone's offset is not knowable here, and a report that says UTC is
/// comparable with the server's log, which is what it gets read against.
fn clock_time(at_ms: u64) -> String {
let ms = at_ms % 1000;
let secs_of_day = (at_ms / 1000) % 86_400;
format!(
"{:02}:{:02}:{:02}.{:03}",
secs_of_day / 3600,
(secs_of_day % 3600) / 60,
secs_of_day % 60,
ms
)
}
/// Now, in unix milliseconds. Saturating rather than panicking on a clock
/// before the epoch: a wrong timestamp in a diagnostic is not worth taking
/// the app down for.
pub fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[derive(Debug)]
struct Inner {
lines: VecDeque<LogLine>,
bytes: usize,
max_lines: usize,
max_bytes: usize,
next_seq: u64,
/// How many lines the bounds have discarded since the ring was made.
/// Reported rather than inferred, so "the log starts here" and "the
/// log was cut off here" are distinguishable -- the unknown state the
/// UI rules ask for.
dropped: u64,
}
/// A bounded, shareable ring of recent log lines. Cloning shares the ring;
/// there is one per process and every holder sees the same lines.
#[derive(Debug, Clone)]
pub struct LogRing(Arc<Mutex<Inner>>);
impl LogRing {
pub fn new(max_lines: usize, max_bytes: usize) -> Self {
assert!(
max_lines > 0 && max_bytes > 0,
"a ring with no room holds nothing"
);
Self(Arc::new(Mutex::new(Inner {
lines: VecDeque::new(),
bytes: 0,
max_lines,
max_bytes,
next_seq: 0,
dropped: 0,
})))
}
/// The bounds this project ships with: [`DEFAULT_MAX_LINES`] and
/// [`DEFAULT_MAX_BYTES`].
pub fn with_defaults() -> Self {
Self::new(DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES)
}
/// A poisoned lock is a bug in a panicking logger, not a reason to
/// take the app down a second time -- the ring is a diagnostic, and
/// losing it must not be worse than the fault it was recording.
fn with<R>(&self, f: impl FnOnce(&mut Inner) -> R) -> R {
let mut guard = match self.0.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
f(&mut guard)
}
/// Records a line, evicting the oldest until both bounds hold again.
pub fn push(&self, level: log::Level, target: &str, message: String) {
self.with(|inner| {
let line = LogLine {
seq: inner.next_seq,
at_ms: now_ms(),
level,
target: target.to_string(),
message,
};
inner.next_seq += 1;
inner.bytes += line.weight();
inner.lines.push_back(line);
// `!is_empty()` rather than `len() > 1`: one line larger than
// the whole byte bound is kept, because dropping it would
// leave the ring silently empty while lines were arriving.
while inner.lines.len() > inner.max_lines
|| (inner.bytes > inner.max_bytes && inner.lines.len() > 1)
{
if let Some(evicted) = inner.lines.pop_front() {
inner.bytes -= evicted.weight();
inner.dropped += 1;
}
}
})
}
/// Every line held, oldest first.
pub fn snapshot(&self) -> Vec<LogLine> {
self.with(|inner| inner.lines.iter().cloned().collect())
}
/// The lines with a sequence number at or after `seq`, oldest first,
/// and the sequence to ask from next time. Does not consume: see this
/// module's doc for why.
pub fn since(&self, seq: u64) -> (Vec<LogLine>, u64) {
self.with(|inner| {
let lines: Vec<LogLine> = inner
.lines
.iter()
.filter(|line| line.seq >= seq)
.cloned()
.collect();
let next = lines.last().map(|line| line.seq + 1).unwrap_or(seq);
(lines, next)
})
}
pub fn len(&self) -> usize {
self.with(|inner| inner.lines.len())
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn dropped(&self) -> u64 {
self.with(|inner| inner.dropped)
}
/// The sequence number of the newest line held, or `None` for a ring
/// nothing has been written to.
///
/// What a reader needs to notice that this process **restarted**: the
/// ring is in memory, so a new process starts again at zero, and a
/// reader holding a cursor from the previous one would otherwise ask
/// for lines after a number nothing will reach for hours and see
/// nothing at all -- silently, which is worse than seeing the log
/// begin again. Answering `None` rather than 0 for an empty ring is
/// the same distinction [`Self::summary`] draws: "nothing has been
/// logged" is not a sequence number.
pub fn newest_seq(&self) -> Option<u64> {
self.with(|inner| inner.lines.back().map(|line| line.seq))
}
/// When the newest line was written, in unix milliseconds, or `None`
/// for a ring nothing has been written to.
pub fn last_at_ms(&self) -> Option<u64> {
self.with(|inner| inner.lines.back().map(|line| line.at_ms))
}
/// Every line held, formatted one per line -- what `Copy report`
/// appends.
pub fn to_text(&self) -> String {
self.snapshot()
.iter()
.map(LogLine::format)
.collect::<Vec<_>>()
.join("\n")
}
/// The newest `max_lines` lines, formatted, with a first line naming
/// how many older ones were left out of *this* text when the ring held
/// more than that -- what `Copy report` appends instead of
/// [`Self::to_text`].
///
/// Iris's own report: pasting the full ring (over a thousand lines on
/// a session that ran with tracing on) into a phone's message box was
/// what "causes a lot of lag" meant (docs/IRIS_TODO.md, 2026-09-07
/// night) -- nothing is actually lost, since `devlog`'s provider still
/// hands Dev Updater's Runtime tab the whole ring; this only caps what
/// gets inlined into a share.
pub fn tail_text(&self, max_lines: usize) -> String {
let lines = self.snapshot();
if lines.len() <= max_lines {
return lines
.iter()
.map(LogLine::format)
.collect::<Vec<_>>()
.join("\n");
}
let omitted = lines.len() - max_lines;
let tail = lines[lines.len() - max_lines..]
.iter()
.map(LogLine::format)
.collect::<Vec<_>>()
.join("\n");
format!("{omitted} earlier lines omitted; full log in Dev Updater's Runtime tab\n{tail}")
}
/// The newest `max_lines` lines, formatted, or `None` if the ring is
/// locked at this instant.
///
/// For the one caller that must not block: **the panic hook**. A panic
/// raised while this ring's own lock was held -- an allocation failing
/// inside [`Self::push`], an assertion in a `log::Log` on the way here
/// -- would deadlock the hook against the thread that is panicking,
/// and the process would hang instead of aborting, with nothing
/// written anywhere. Losing the context lines is the right trade
/// against that, and `None` says which happened rather than looking
/// like an empty log.
pub fn try_tail_text(&self, max_lines: usize) -> Option<String> {
let guard = match self.0.try_lock() {
Ok(guard) => guard,
// A poisoned lock is uncontended, so its contents are still
// readable -- the same judgement as `with`.
Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
Err(std::sync::TryLockError::WouldBlock) => return None,
};
let lines = &guard.lines;
let from = lines.len().saturating_sub(max_lines);
Some(
lines
.iter()
.skip(from)
.map(LogLine::format)
.collect::<Vec<_>>()
.join("\n"),
)
}
/// One line for a diagnostics pane: how much is held, how much was
/// dropped, and when the last line arrived. "no lines yet" is its own
/// wording rather than a count of zero with a made-up time, because
/// "nothing has been logged" and "logging is not running" would
/// otherwise look the same.
pub fn summary(&self) -> String {
let (len, dropped, last) = self.with(|inner| {
(
inner.lines.len(),
inner.dropped,
inner.lines.back().map(|line| line.at_ms),
)
});
match last {
None => "app log: no lines yet".to_string(),
Some(at) => {
let dropped = if dropped > 0 {
format!(", {dropped} dropped")
} else {
String::new()
};
format!(
"app log: {len} lines held{dropped}, last {}",
clock_time(at)
)
}
}
}
}
/// Whether a target belongs to this app's own crates (`iris` or
/// `client_core`) rather than a dependency's -- `starts_with` guarded by an
/// exact match or a `::` so an unrelated crate that merely begins with the
/// same letters (there is no such crate today, but the check should not
/// rely on that) is never mistaken for one of ours.
fn is_own_target(target: &str) -> bool {
target == "iris"
|| target.starts_with("iris::")
|| target == "client_core"
|| target.starts_with("client_core::")
}
/// Whether a line at `level` from `target` belongs in the ring, given
/// whether tracing is on right now.
///
/// This is the one filter docs/IRIS_TODO.md's "logs way too big" entry
/// asked for, applied once here rather than at each `debug!` call site:
/// Info and above always ring, from anything, because a real warning or
/// error from a dependency is worth keeping. Debug and Trace ring only
/// from this app's own targets, and only while tracing is switched on --
/// otherwise `naga::front`/`wgpu_core`/`jni` log at Debug unconditionally
/// (the process logger's own level, set once at install and unrelated to
/// tracing), which is what filled the ring with 1339 lines of it and
/// dropped 4050 more before this existed. `iris`'s own Debug lines already
/// self-gate on `iris::diagnostics::trace_enabled` at their call sites
/// (commit 992c472); this is the backstop for lines this crate does not
/// control.
fn ring_accepts(level: log::Level, target: &str, trace_enabled: bool) -> bool {
level <= log::Level::Info || (trace_enabled && is_own_target(target))
}
/// A `log` backend that records into a [`LogRing`] **and** forwards to the
/// logger the platform already installs, so nothing that reads the
/// platform's log (`logcat`, a terminal) changes.
///
/// The inner logger is passed in rather than chosen here: `client-core`
/// has no business depending on `android_logger` or `env_logger`, and
/// which one is right is exactly what differs between the two platforms
/// (the sharing rule in AGENTS.md).
pub struct RingLogger {
ring: LogRing,
inner: Box<dyn log::Log>,
/// Whether `iris::input`/`iris::frame`-style tracing is switched on
/// right now, consulted by [`ring_accepts`]. A plain fn pointer rather
/// than a dependency on `iris::diagnostics::trace_enabled` directly:
/// `client-core` sits below `iris` (AGENTS.md's "dependencies flow one
/// direction"), so the platform crate that depends on both is the one
/// that wires this closure through, the same way it already supplies
/// `inner`.
trace_enabled: fn() -> bool,
}
impl RingLogger {
pub fn new(ring: LogRing, inner: Box<dyn log::Log>, trace_enabled: fn() -> bool) -> Self {
Self {
ring,
inner,
trace_enabled,
}
}
}
impl log::Log for RingLogger {
/// True for anything `log`'s own max level lets through: the ring
/// wants everything the *inner* logger might also want, even where the
/// platform logger would filter it out. Which lines the ring itself
/// keeps is decided in [`Self::log`] by [`ring_accepts`].
fn enabled(&self, _metadata: &log::Metadata) -> bool {
true
}
fn log(&self, record: &log::Record) {
if ring_accepts(record.level(), record.target(), (self.trace_enabled)()) {
self.ring
.push(record.level(), record.target(), record.args().to_string());
}
if self.inner.enabled(record.metadata()) {
self.inner.log(record);
}
}
fn flush(&self) {
self.inner.flush();
}
}
/// Installs a [`RingLogger`] as the process logger and answers the ring it
/// records into.
///
/// Fails only if a logger is already installed, which is a programmer
/// error (two initialisation paths) rather than a recoverable condition --
/// the caller is named in the error so it is findable.
pub fn install(
ring: LogRing,
inner: Box<dyn log::Log>,
max_level: log::LevelFilter,
trace_enabled: fn() -> bool,
) -> Result<(), log::SetLoggerError> {
log::set_boxed_logger(Box::new(RingLogger::new(ring, inner, trace_enabled)))?;
log::set_max_level(max_level);
Ok(())
}
/// The one ring this process records into.
///
/// **A deliberate process-global, where this project's rules otherwise say
/// pass context explicitly.** What is being modelled is already one: `log`
/// has exactly one backend per process, set once, and every `log::info!`
/// anywhere in the binary goes to it. A ring handed around as a parameter
/// would be a *second* answer to "which lines exist" -- the report would
/// show one ring while the logger filled another, and which one a caller
/// got would depend on how far down the call tree it was. The tests above
/// all use their own [`LogRing`], so nothing here needs this to be
/// testable.
static PROCESS_RING: OnceLock<LogRing> = OnceLock::new();
/// The process's ring, created on first use with the default bounds.
/// Safe to call before [`install_process_logger`] -- it will simply be
/// empty.
pub fn process_ring() -> &'static LogRing {
PROCESS_RING.get_or_init(LogRing::with_defaults)
}
/// Installs [`process_ring`] as the recording half of the process logger,
/// forwarding to `inner` (the platform's own logger, already configured).
/// The platform half of AGENTS.md's sharing rule is `inner`; everything
/// else is shared. `trace_enabled` is the platform's own trace toggle
/// (`iris::diagnostics::trace_enabled` on Android) -- see
/// [`ring_accepts`] and the field doc on `RingLogger` for why it is
/// passed in rather than called directly.
pub fn install_process_logger(
inner: Box<dyn log::Log>,
max_level: log::LevelFilter,
trace_enabled: fn() -> bool,
) -> Result<(), log::SetLoggerError> {
install(process_ring().clone(), inner, max_level, trace_enabled)
}
#[cfg(test)]
mod tests {
use super::*;
use log::Level;
fn fill(ring: &LogRing, count: usize) {
for n in 0..count {
ring.push(Level::Info, "test", format!("line {n}"));
}
}
#[test]
fn lines_come_back_oldest_first() {
let ring = LogRing::new(10, 1 << 20);
fill(&ring, 3);
let text: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
assert_eq!(text, ["line 0", "line 1", "line 2"]);
}
#[test]
fn the_line_bound_drops_the_oldest_and_says_how_many() {
let ring = LogRing::new(3, 1 << 20);
fill(&ring, 5);
let text: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
assert_eq!(text, ["line 2", "line 3", "line 4"], "the newest survive");
assert_eq!(ring.len(), 3);
assert_eq!(ring.dropped(), 2, "and the loss is reported, not silent");
}
#[test]
fn the_byte_bound_bites_before_the_line_bound_when_lines_are_large() {
// Room for 1000 lines but only a few hundred bytes.
let ring = LogRing::new(1000, 300);
for n in 0..10 {
ring.push(Level::Info, "t", format!("{n}{}", "x".repeat(100)));
}
assert!(
ring.len() < 10,
"the byte bound evicted: {} held",
ring.len()
);
assert!(ring.dropped() > 0);
assert!(
ring.snapshot().last().unwrap().message.starts_with('9'),
"and it evicted from the old end"
);
}
/// The case the `len() > 1` guard exists for: one line larger than the
/// whole bound must still be readable, or a ring that is over budget
/// reads as a ring nothing was written to.
#[test]
fn one_oversized_line_is_kept_rather_than_leaving_the_ring_empty() {
let ring = LogRing::new(100, 64);
ring.push(Level::Error, "t", "y".repeat(5000));
assert_eq!(ring.len(), 1);
assert_eq!(ring.dropped(), 0);
}
#[test]
fn sequence_numbers_only_increase_and_survive_eviction() {
let ring = LogRing::new(2, 1 << 20);
fill(&ring, 5);
let seqs: Vec<u64> = ring.snapshot().into_iter().map(|l| l.seq).collect();
assert_eq!(seqs, [3, 4], "a gap is exactly what was dropped");
}
#[test]
fn since_returns_only_what_is_new_and_the_next_cursor() {
let ring = LogRing::new(100, 1 << 20);
fill(&ring, 3);
let (first, cursor) = ring.since(0);
assert_eq!(first.len(), 3);
assert_eq!(cursor, 3);
let (none, cursor) = ring.since(cursor);
assert!(none.is_empty(), "nothing new yet");
assert_eq!(cursor, 3, "and the cursor does not move");
ring.push(Level::Warn, "test", "later".into());
let (more, cursor) = ring.since(cursor);
assert_eq!(more.len(), 1);
assert_eq!(more[0].message, "later");
assert_eq!(cursor, 4);
}
/// The restart signal: a reader that saw sequence 4 and is now told
/// the newest is 0 knows the process is not the one it was reading.
#[test]
fn the_newest_sequence_says_where_the_ring_is_and_nothing_for_an_empty_one() {
let ring = LogRing::new(100, 1 << 20);
assert_eq!(ring.newest_seq(), None, "an empty ring has no newest line");
fill(&ring, 5);
assert_eq!(ring.newest_seq(), Some(4));
let restarted = LogRing::new(100, 1 << 20);
fill(&restarted, 1);
assert_eq!(
restarted.newest_seq(),
Some(0),
"a fresh ring starts again, which is exactly what a reader has to notice"
);
}
#[test]
fn reading_does_not_consume() {
let ring = LogRing::new(100, 1 << 20);
fill(&ring, 2);
let (sent, _) = ring.since(0);
assert_eq!(sent.len(), 2);
assert_eq!(ring.len(), 2, "the report still has them after an upload");
assert_eq!(ring.to_text().lines().count(), 2);
}
#[test]
fn tail_text_is_the_whole_ring_untouched_when_under_the_cap() {
let ring = LogRing::new(100, 1 << 20);
fill(&ring, 5);
assert_eq!(ring.tail_text(150), ring.to_text());
}
#[test]
fn tail_text_trims_to_the_newest_lines_and_says_how_many_were_left_out() {
let ring = LogRing::new(1000, 1 << 20);
fill(&ring, 200);
let tail = ring.tail_text(150);
let mut lines = tail.lines();
assert_eq!(
lines.next().unwrap(),
"50 earlier lines omitted; full log in Dev Updater's Runtime tab"
);
let rest: Vec<&str> = lines.collect();
assert_eq!(rest.len(), 150, "exactly the cap, after the header line");
assert!(
rest[0].ends_with("line 50"),
"the oldest line kept is the 50th, not line 0: {}",
rest[0]
);
assert!(rest.last().unwrap().ends_with("line 199"));
}
#[test]
fn try_tail_text_gives_the_newest_lines_with_no_header() {
let ring = LogRing::new(1000, 1 << 20);
fill(&ring, 200);
let tail = ring.try_tail_text(80).expect("nothing holds the lock");
let lines: Vec<&str> = tail.lines().collect();
assert_eq!(lines.len(), 80, "the cap, and no header: this is a file");
assert!(lines[0].ends_with("line 120"), "{}", lines[0]);
assert!(lines[79].ends_with("line 199"), "{}", lines[79]);
}
/// The whole point of the `try_`: the panic hook calls this from a
/// thread that may already hold the ring's lock, and a blocking read
/// there would hang the process instead of aborting it.
#[test]
fn try_tail_text_answers_none_rather_than_blocking_on_a_held_lock() {
let ring = LogRing::new(10, 1 << 20);
fill(&ring, 3);
let held = ring.0.lock().expect("fresh ring");
assert_eq!(ring.try_tail_text(80), None);
drop(held);
assert!(ring.try_tail_text(80).is_some());
}
#[test]
fn an_empty_ring_says_so_rather_than_reporting_a_time() {
let ring = LogRing::with_defaults();
assert_eq!(ring.summary(), "app log: no lines yet");
assert_eq!(ring.last_at_ms(), None);
assert!(ring.is_empty());
}
#[test]
fn the_summary_names_dropped_lines_only_when_there_are_some() {
let ring = LogRing::new(2, 1 << 20);
fill(&ring, 2);
assert!(!ring.summary().contains("dropped"), "{}", ring.summary());
fill(&ring, 2);
assert!(ring.summary().contains("2 dropped"), "{}", ring.summary());
}
#[test]
fn a_line_formats_as_time_level_target_message() {
let line = LogLine {
seq: 0,
// 1970-01-01T12:34:56.789Z, so the arithmetic is checkable by
// hand rather than against another clock.
at_ms: (12 * 3600 + 34 * 60 + 56) * 1000 + 789,
level: Level::Info,
target: "iris::android".into(),
message: "surface created".into(),
}
.format();
assert_eq!(line, "12:34:56.789 INFO iris::android: surface created");
}
/// The forwarding half: a line reaches the ring *and* the logger the
/// platform already had, and one the inner logger filters out is still
/// in the ring.
#[test]
fn the_ring_logger_forwards_to_the_inner_logger() {
use log::Log;
struct Collect(Arc<Mutex<Vec<String>>>, log::Level);
impl Log for Collect {
fn enabled(&self, metadata: &log::Metadata) -> bool {
metadata.level() <= self.1
}
fn log(&self, record: &log::Record) {
self.0.lock().unwrap().push(record.args().to_string());
}
fn flush(&self) {}
}
let seen = Arc::new(Mutex::new(Vec::new()));
let ring = LogRing::with_defaults();
// Own target, tracing on: this is the case where the ring and the
// inner logger disagree, which is the thing under test -- a
// foreign target is covered separately below.
let logger = RingLogger::new(
ring.clone(),
Box::new(Collect(seen.clone(), Level::Info)),
|| true,
);
logger.log(
&log::Record::builder()
.args(format_args!("kept"))
.level(Level::Info)
.target("iris::test")
.build(),
);
logger.log(
&log::Record::builder()
.args(format_args!("filtered"))
.level(Level::Debug)
.target("iris::test")
.build(),
);
assert_eq!(
*seen.lock().unwrap(),
["kept"],
"the inner logger's own filter still applies"
);
let held: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
assert_eq!(
held,
["kept", "filtered"],
"own-target debug still rings while tracing is on"
);
}
/// The bug this filter fixes: `naga`/`wgpu_core`/`jni` log at Debug
/// unconditionally, and used to flood the ring even though nothing in
/// this app asked for their Debug output. A foreign target's Debug
/// line must not ring even while tracing is on -- tracing controls
/// this app's own diagnostics, not a dependency's chatter.
#[test]
fn a_foreign_targets_debug_line_never_rings_even_while_tracing_is_on() {
use log::Log;
struct Discard;
impl Log for Discard {
fn enabled(&self, _: &log::Metadata) -> bool {
true
}
fn log(&self, _: &log::Record) {}
fn flush(&self) {}
}
let ring = LogRing::with_defaults();
let logger = RingLogger::new(ring.clone(), Box::new(Discard), || true);
logger.log(
&log::Record::builder()
.args(format_args!("naga debug spam"))
.level(Level::Debug)
.target("naga::front")
.build(),
);
logger.log(
&log::Record::builder()
.args(format_args!("naga warning"))
.level(Level::Warn)
.target("wgpu_core::device")
.build(),
);
let held: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
assert_eq!(
held,
["naga warning"],
"Info-and-above always rings; foreign Debug never does"
);
}
#[test]
fn ring_accepts_is_own_target_debug_only_while_tracing() {
assert!(
ring_accepts(Level::Info, "wgpu_core::device", false),
"Info+ from anything, tracing off"
);
assert!(
ring_accepts(Level::Warn, "jni", true),
"Info+ from anything, tracing on"
);
assert!(
!ring_accepts(Level::Debug, "jni", true),
"foreign Debug, tracing on: still excluded"
);
assert!(
!ring_accepts(Level::Debug, "iris::sense", false),
"own Debug, tracing off: excluded"
);
assert!(
ring_accepts(Level::Debug, "iris::sense", true),
"own Debug, tracing on: included"
);
assert!(
ring_accepts(Level::Trace, "client_core::api", true),
"own Trace, tracing on: included"
);
}
#[test]
fn is_own_target_matches_the_crate_or_its_modules_only() {
assert!(is_own_target("iris"));
assert!(is_own_target("iris::sense"));
assert!(is_own_target("client_core"));
assert!(is_own_target("client_core::log_ring"));
assert!(!is_own_target("iris_something_else"));
assert!(!is_own_target("naga::front"));
assert!(!is_own_target("jni"));
}
}
+374
View File
@@ -5,6 +5,328 @@ they can be judged and reversed later. Detail lives in RUST.md (and IRIS.md
for iris API changes); this file is only the summary. Newest first. Items
marked **DEFERRED** are ones the agent chose not to decide alone.
## 2026-09-08 (last: scrolling moves out of the list)
Agreed with Iris in the exchange that followed, so most of this is her
call rather than mine. IRIS.md has the account. What I decided along the
way, and would flag for reversal:
- **A third `Widget` method, `scroll_offset`**, beyond the two we agreed.
`apply_scroll`'s remainder is exact only when the wall was already in
view, and a lazy span usually cannot see its wall until it has walked
there -- so `Scroll` reads the child's accumulated movement after the
placing draw instead of adding remainders up, which would drift.
- **One scroll-delta convention, the finger's.** The two widgets had
opposite ones under the same name; `LazySpan::scroll` is now private and
the single negation lives in its `apply_scroll`. Call sites that passed
`-dy`/`-v` pass them straight through, and one fixture's expected
velocity flipped sign with its magnitude unchanged.
- **The transcript builds its `Scroll` by hand rather than through
`.scrollable_to_end()`**, because that helper registers a finger drag
and `Selection` is already the arbiter for those frames -- two
`DragGesture`s seeing one gesture is what `DragGesture`'s own doc rules
out. The wheel is registered identically; only the drag differs.
- **DEFERRED: the pin stays in each widget.** Iris asked for `amt` and
the at-end control to live in `Scroll`; `amt` does, the pin does not,
because applying a pin happens when a row is appended -- between frames,
with no painter -- so moving it needs a fourth `Widget` method or a
parameter on `apply_scroll`. Nothing external edits a pin today.
docs/IRIS_TODO.md carries it.
## 2026-09-08 (later still: the list's overscroll clamp, in frame)
Finishes the item the previous entry deferred. IRIS.md has the account.
- **`List` lays out a second time within the frame** when its walk lands
off the end of the content, instead of writing the correction to the
anchor and asking for another frame. The extra walk is paid only on an
overscrolled frame, and it is mostly O(1) moves.
- **`Painter::draw_again` is removed**, `List` having been its only
caller -- so the framework no longer offers a way to ask for a
corrective frame at all.
- **`List::place`'s top-known and bottom-known cases are one path**
(`Placement::edges`), which is the "write the logic once" rule applied
to two symmetric directions rather than a behaviour change.
## 2026-09-08 (later: a scroll area measures and places in one frame)
From Iris's phone report about the composer's padding while typing
newlines, and the rule she stated when she read the first fix: layout is
a pure function of the state, nothing self-heals, and two draws to place
something happen in the same frame. IRIS.md's entry has the account.
- **`Scroll::draw` draws its child twice** -- once at last frame's length
to measure it, once at the measured length to place it -- instead of
placing against the stale length and leaving a wrong frame on screen.
The second draw is free unless the content's length changed.
- **An end-anchored `Scroll` is at its end on its first drawn frame**, a
consequence of the above. Two layout tests now build their area with
`at_end: false`, which is what they meant: they scroll down from the
top.
- **`List::clamp_to_content`'s next-frame correction is left in place**
and written down in docs/IRIS_TODO.md instead of fixed here, because
`List::place` is a larger piece of machinery and deserves its own
before/after on the phone.
## 2026-09-08 (every crate to its latest version, wgpu 28 -> 30)
At Iris's request. RUST.md's "Every crate to its latest version" box has
the full list and the migration.
- **wgpu 30 taken now rather than pinned at 28.** Two majors of API
change, all mechanical (instance descriptor, optional bind-group and
vertex-buffer slots, `Queue::present`, a `CurrentSurfaceTexture` enum),
and one that would have been a startup abort on a device rather than a
compile error: naga now demands `@interpolate(flat)` on the shader's
integer varyings. Verified on both backends before this was called
done, since a renderer that compiles proves nothing.
- **The desktop instance now carries winit's display handle.** wgpu 30
asks for it when a GLES surface will be presented on Wayland, which is
what this machine's Vulkan-to-GLES fallback produces. Android passes
none: its surface comes from a `NativeWindow`.
- **`syn` 2 -> 3, `pollster` 0.4 -> 1.0** with no source change in
`iris/macro` or anywhere else.
## 2026-09-08 evening (the fling is shared; a cancel is not a release)
From Iris's four-item phone report; RUST.md's "2026-09-08 (evening)" box
has the reasoning and the tests, IRIS.md the summary.
- **A `Flinger` that does not know which way the content moves.** Every
scroll area flings now, on either axis, as Iris asked -- and the
physics is one type shared by `List` and `Scroll` rather than a copy
each. The choice worth reviewing is the seam: `Flinger` owns the curve
and the clock, and the *caller* owns the sign convention and where the
content ends. Rejected: teaching `Flinger` a direction, which would
have to be told to it -- and being told is the same thing as not
knowing, with an extra field to get wrong.
- **A cancel is a first-class end to a gesture, not an early release.**
`CursorState::cancelled` is new state on the pointer sample, set by
Android's `ACTION_CANCEL` and the harness's `TouchAction::Cancel`.
Rejected: mapping a cancel to `PressEnd` and having each widget decide
what to suppress, which is what shipped and is why leaving the app
flung the transcript.
- **A `DragGesture` ignores a `Cancel` it caused.** One gesture is
driven by several widgets, so the widget that was pressed can be a
"loser" on the frame its own gesture won. The test is whether the
gesture's own capture id is the holder. This is what makes it safe for
every widget driving a gesture to register the whole `drag_senses()`
set, which is now the rule without exception.
- **`List::place` draws a resized row twice in one frame.** The old
comment accepted a one-frame lag by analogy with `Scroll`'s content
length. That analogy was wrong: a stale *length* only misplaces the
next thing, while a stale *box* is drawn, because a background fills
whatever box it is handed. The extra draw is bounded to frames where a
row's height actually changed.
## 2026-09-08 (iris ships an icon font, and the drawn mark is deleted)
- **Directed by Iris.** Her question on seeing `widget::mark`: "why does
mark exist? The font should be working if it's working for compose and
nerd fonts are bundled." It was not: the Compose app draws its icons
from **its own committed Nerd Fonts subset**, while iris was setting
the disclosure mark with bare Unicode geometric codepoints
(U+25B8/25BE/25B4) out of whatever face the platform resolved -- an
empty box on her phone, a dot on this VM. The 2026-09-07 entry below,
which said "iris had no equivalent icon font to keep", is what left
that gap: iris had no icon font because it had never had one, not
because it needed none.
- **So iris now bundles the same kind of subset**:
`iris/core/build-icon-font.sh` writes
`iris/core/assets/fonts/nerd_icons.ttf` (992 bytes, three Material
Design glyphs today), `iris::icon` names the codepoints, and
`Family::Icons` draws them. This does **not** reopen the platform-fonts
decision: body and monospace text still come from the platform, and an
icon is the opposite case -- a small, closed, known set of codepoints,
which is exactly the division the Compose app already makes.
- **`iris::widget::mark` is deleted** (added earlier the same day). It
drew a correct triangle, but only a triangle, and every further icon
would have been another bespoke rasteriser. An icon as text also takes
the size, colour and baseline of the line it sits in for free.
## 2026-09-08 (the emulator is a GLES machine, and Vulkan is verified elsewhere)
- **Directed by Iris, carried out here**: "make sure the setup uses GL for
the android emulator and remove any vulkan requirements. That'll be
tested through both the desktop version as well as my phone." So the
emulator is settled as a GLES rig and nothing chases hardware Vulkan in
it any more; the Vulkan path is covered by the desktop build and by her
phone.
- **Nothing had to be forced to make that true.** Measured in the guest
the same day: the emulator has no hardware Vulkan at all (its only
Vulkan is SwiftShader, in software) and its GLES is the host's real RX
7900 XT through virgl at ES 3.1. iris's existing runtime fallback --
`Backends::PRIMARY`, no adapter, rebuild on `Backends::GL` -- already
lands there, verified end to end with an ordinary (no `force-gles`)
debug APK.
- **The emulator and the phone therefore run the same binary**, differing
only in what that binary finds. That is deliberate and worth not
undoing: a build flag that changed the backend would mean the thing
measured on the emulator is not the thing shipped. `force-gles` stays,
but only for pinning the backend on a machine that *does* have Vulkan
(the desktop), and never for a phone build.
- **Every run now says which adapter drew it.** The Android renderer logs
the full adapter line at startup the way the desktop already did -- only
the backend enum was logged before, which cannot separate `Gl` on the
host's GPU from `Gl` on SwiftShader, or a phone's real Vulkan from a
software one. `run-bench.sh` prints that line before any number.
- **No Vulkan requirement was found in iris to remove.** `device_limits()`
asks for nothing beyond wgpu's defaults (and zeroes the compute fields),
neither backend requires a feature, and both probe rather than
`.expect()` an adapter. What was removed was the *documentation* telling
people to boot the emulator with SwiftShader Vulkan.
## 2026-09-07 (platform fonts, not bundled ones)
- **Iris's own decision, carried out as directed**: removed the 3.6 MB of
bundled Noto Sans/Noto Sans Mono TTFs from `iris-core` and load text
from the platform's own font collection instead (`fontique`'s system
discovery, already on by default). Matches what the Compose app does --
it takes body text from `FontFamily.Default` and code text from
`FontFamily.Monospace`, both platform-resolved, and ships no text font
of its own. Rejected alternative (the one this pass had left open
2026-09-06): subsetting the bundled Noto Sans to Latin/common
punctuation instead of removing it outright, which would have kept
identical rendering across devices for a smaller (not zero) size cost;
Iris chose to match Compose instead.
- `.so` **-3,748,136 bytes** (11,193,608 -> 7,445,472), matching the
original 3.6 MB estimate. Fallback still lands on the platform's own
tofu for a codepoint no resolved face has (checked with CJK + emoji on
desktop) rather than blank space, so the UI_RULES unknown-glyph rule
still holds.
- **Gap found, then closed same day**: this fontique version's Android
backend never resolved the `Monospace` generic family at all (confirmed
on this checkout's emulator, `mono=None` in the startup diagnostic) --
two pre-existing bugs in fontique's own `fonts.xml` parsing stacked (an
ordering bug, and a `<family name="monospace">` declaration whose
`<font>` children the backend's parser never reads), not something this
change introduced, but this change is what stopped masking it (the
bundled mono font used to be registered ahead of the broken platform
lookup, so it always won). Checked `linebender/parley`'s `main` branch
on GitHub: neither bug is fixed there, so there was no newer release to
bump to. Fixed instead in `iris-core` itself
(`TextData::patch_android_monospace`, Android-only): reads
`/system/etc/fonts.xml`'s own `"monospace"` declaration for the font
filename it names, then registers whichever of fontique's actually-
scanned families owns that file as the `Monospace` generic -- the same
authority Compose's `Typeface.MONOSPACE` resolves through, without
pinning an OEM-specific family name. Verified on this checkout's
emulator: `mono=Some("Droid Sans Mono")`, and a screenshot showing the
bench-fixture's code block and tool-card values in a visibly monospaced
face beside sans body text; the desktop `fontconfig` backend is
unaffected (still resolves monospace correctly, confirmed unchanged).
docs/RUST.md's "Platform fonts (2026-09-07)" has the full account.
## 2026-09-07 (a phone log reaches Iris through Dev Updater's own tab)
**Supersedes the "how a phone log reaches Iris" entry below, same day.**
Iris's call once the route was working: put it in Dev Updater properly
rather than smuggling the lines through `ai-server`'s log.
- **The app exposes its own log on the device, and Dev Updater reads it
there.** A `ContentProvider` at `<applicationId>.devlog`, one table of
lines queried with `?since=<seq>` so a poll is incremental, plus a
`status` row (`held`, `dropped`, `newest_seq`). Dev Updater's phone app
polls it while the component's **Runtime** tab is open and forwards what
is new to its own build machine, into that APK component's runtime log
-- so the same tab renders both kinds and the history outlives the
phone. No tunnel, no token, no second enrolment: the two apps are on the
same phone.
**It is a contract, not a feature for iris.** Written down in
dev-updater's `README.md` ("An app's own log"), so any app that server
delivers gets the tab by implementing it; the Compose app in `app/` can
do the same later. That is the reason it beat the route below on its
second look -- the earlier one only ever worked for the one project that
had a server, and put a phone's lines under a *different component* than
the one they came from.
- **Read access is `protectionLevel="normal"`, and that is a real trade.**
`signature` is what this wants and is not available: Dev Updater and the
apps it delivers are built on one machine but signed with different
locally generated keys, so a signature permission would be held by
nothing at all. What `normal` costs is that any app on that phone which
requests `dev.updater.permission.READ_DEVLOG` by name can read another
app's dev log. Accepted because these are development builds on a
development phone and the alternative was no log; stated in the manifest
beside the declaration and in dev-updater's README so it is not
rediscovered as a surprise.
- **The provider polls rather than notifying.** `notifyChange` was not
implemented: the ring is filled by a `log::Log` backend on whatever
thread logged, and giving that a route to a `ContentProvider` means
plumbing a callback through `client-core` for every platform. Dev
Updater's contract therefore says it polls (about a second, only while
the tab is open), which is what keeps implementing the contract cheap --
a provider that does notify loses nothing.
- **What was deleted, so there is one mechanism**: `client-core`'s
`log_upload` module, `POST /client-log` on `ai-server`, the
`AI_APP_LOG_HOST`/`_PORT`/`_TOKEN` baking in `iris/android-app/build.rs`
(which left that file with nothing to do, so it is gone too), and the
uploader fields on both Android clients. Kept: the ring, `RingLogger`,
`install_process_logger`, and the Diagnostics line counting what is
held. The upload-status line there is now **"devlog provider:
content://<authority>"** -- named from what the provider registered
rather than composed from the package here, so a screenshot of that pane
is evidence the contract is live and says which package's log it is.
## 2026-09-07 (how a phone log reaches Iris) -- superseded, see above
- **The app sends its own log to `ai-server`, and Dev Updater shows it as
`ai-server`'s runtime log.** Iris has no `adb`/`logcat` on her phone, and
Android forbids one app reading another's logcat, so the app has to carry
its own copy and post it somewhere. `POST /client-log` on `ai-server`
re-emits each line into that server's own `tracing` output; Dev Updater
already runs `ai-server` as a `Managed` component, whose stdout its own
service script redirects to a file and reports through
`GET /apps/{key}/components/{name}/logs?kind=runtime`, which the phone
app's log dialog already offers as a **Runtime** tab for a `server`
component. So **no change to Dev Updater at all** -- one route on
`ai-server`, and the client in `client-core`.
**Rejected: posting to Dev Updater's own server** (the first candidate,
and what the entry above went on to build -- the estimate below was
right about the work and wrong about it being too much).
It would need a new authenticated *write* route on a TLS surface whose
module doc says every route on it "is, or decides, the bytes that get
handed to `REQUEST_INSTALL_PACKAGES` next"; a per-app device-log store;
a change to `component_logs` so an APK component can have a runtime log;
a change to the phone app's `hasBothKinds = component.kind == "server"`
gate and to what `hasRuntimeLogs` means on the wire; and -- the real
cost -- a **second** enrollment for the iris app, since it has no CA or
token for Dev Updater and Dev Updater mints tokens per device by QR.
Five changes across two repos against one route, for the same line
landing in the same viewer.
**Rejected: a share intent from a debug button** (a log file in the app's
external files dir, shared by hand). It works today and needs no server,
but every line costs Iris a manual export and a message, which is the
round trip through a person this was meant to remove. It is still the
fallback when the tunnel is down, and GrapheneOS's own per-app log export
already covers the crash case (that is how the `ToolInput.highlighted`
crash was reported).
- **The ring is in `client-core`, not in the Android crate.** A bounded
in-memory ring (2000 lines or 256 KiB, whichever bites first) behind a
`log::Log` backend that *forwards* to whichever logger the platform
already installed, so `logcat` and a desktop terminal see exactly what
they saw before. The platform supplies only its own logger and its
destination. `Copy report` appends the ring to what goes on the
clipboard, and flushes the uploader first.
- **The destination is baked in at build time, from the build machine's
own files** (`AI_APP_LOG_HOST`/`_PORT`/`_TOKEN` plus the pinned CA) --
*gone; the provider above replaced it.* What is worth keeping from it is
the reason it went: an APK good only for the server that built it cannot
be built in this VM for Iris's phone, which is the case that mattered.
all three or none, never two. The same trust boundary the transcript
config and the Compose APK's CA already use: nothing secret is
committed, and an APK is good for the server that built it. A build told
nothing still keeps its ring and still copies it; the diagnostics pane
says which of "not tried yet", "failing -- <why>" and "no server
configured" it is, because otherwise all three look like silence.
## 2026-09-06 (how a tool call looks, P1b)
- **A card that never got a result says "no result", in yellow, and it is
@@ -442,3 +764,55 @@ marked **DEFERRED** are ones the agent chose not to decide alone.
pass," and "The three remaining I5 verifications, closed 2026-09-05,"
have the full account. The iris-vs-Masonry choice itself is still
Iris's to make.
## 2026-09-07: the enrolment link carries the CA, so an APK need not be built where its server runs
**Problem.** Every phone build pinned the CA of the machine that compiled
it -- the Compose app from `GeneratePinnedCert`, the iris app from
`build.rs` reading `$XDG_CONFIG_HOME/ai-app/certs/ca.pem`. That is fine
while the two are the same machine and impossible when they are not, which
is exactly the iris client's situation: cross-compiled in this VM,
delivered to a phone, run against `ai-server` on the host. Baking the
host/port/token as well made it worse -- a token in a built artifact.
**Decided: the CA rides in the enrolment link**, as `&ca=<base64url of the
DER>` (`wg_app_link::enroll::ca_param`), optional and per mint. The app
that opens the link pins what the link said, and an APK built anywhere
works against whatever server it is pointed at.
Two alternatives were worked out and rejected.
- **A CA *fingerprint* in the link, pinned at the TLS handshake.** The
smallest link (43 more characters) and the strongest shape, but `ureq`
3.4 exposes no hook for a custom `rustls` `ServerCertVerifier`: its
`TlsConfig` builds the `ClientConfig` itself, so this needs a hand-written
`Connector` on the `unversioned` API and `rustls` as a direct dependency
of `client-core`. A lot of machinery in the one crate that must stay
light.
- **A fingerprint in the link plus an unauthenticated `GET /ca.pem`.**
Small code, but it needs a first connection with verification disabled,
and it breaks a documented, tested posture -- `auth.rs`'s "gates every
route with zero unauthenticated endpoints", which is a load-bearing
decision rather than an implementation detail. Not something to change
silently for this.
**What it costs**, measured rather than guessed: on this project's P-256
CA the link goes from 89 bytes to 652, and `print_enrollment`'s terminal
QR from 45x23 to 93x47 characters. That is why the parameter is the
minter's choice per call: `ai-server` passes it (its iris client needs it),
`dev-updater` passes `None` (its app is built on the machine it talks to,
and its QR stays scannable in an 80-column terminal). The URI printed under
the QR is the fallback either way, and is the path Dev Updater's Enroll
button already uses -- it opens the link with `ACTION_VIEW`, so Android
offers whichever apps registered the scheme, which needed no change here.
The CA is a public certificate, so putting it in the QR leaks nothing the
token did not already: photographing the terminal still costs exactly the
token, which is rotatable.
**The log upload's destination is moot**, so it is not wired to this. On
the same day Iris decided Dev Updater will read an APK's runtime log from
an on-device ContentProvider instead, which removes `log_upload`,
`POST /client-log` and the `AI_APP_LOG_*` baking altogether -- so the
enrolment landed without touching any of them, for that change to delete
whole.
+761 -5
View File
@@ -1,13 +1,527 @@
# iris: notable public API changes
# iris: the log of how it is being built
For Iris to read on her own time. Each entry is a change to iris's public
surface that a widget author or app author would notice: a trait method
added, removed or re-shaped; a type that callers construct differently; a
capability that moved. Small and trivial changes do not go here.
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 (last): `List` is `LazySpan`, and scrolling belongs to `Scroll`
From the design exchange after the overscroll fix, where you asked
whether `List` could just be `Span::scrollable()`. It cannot -- a lazy
layout is a real thing a `Span` is not, for reasons measured below -- but
almost everything you named as out of place was, and it has all moved.
**`List` -> `LazySpan`** (`ListRow` -> `LazyItem`, `RowKey` unchanged),
living beside `Span` under `widget/position/`. It is what `Span` is, laid
out lazily from an anchor rather than eagerly from the start, and the name
says so. It also stops colliding with `BlockKind::List` in the markdown
code.
**It takes a `Dir` instead of an `Axis`**, meaning what it means in `Span`:
which end item 0 sits at. That is a **different question from which end
the view is pinned to**, and conflating them would stand a transcript on
its head -- its oldest message is item 0 and sits at the top (`Dir::DOWN`)
while the view clings to the bottom. So the pin is its own argument:
`LazySpan::new(dir, at_end)`, spelled like `Scroll::new`'s. `Dir::UP` is
real rather than nominal: the walk works in direction-relative pixels from
the leading edge, with `abs_region` flipping the box and `flip_pos`
converting the screen-space positions the hit-testing helpers speak in.
**Everything about scrolling left the list.** Its `Flinger`, its
`density`, its `Arc<dyn RequestRedraw>` (which had no business existing in
a single-threaded frame loop), its `tick`, and the whole
`fling`/`cancel_fling`/`tick_fling`/`is_scrolling`/`fling_velocity`
surface are gone. `Scroll` was the only other `Flinger` user, so there is
now exactly one implementation of the physics and `sense.rs` keeps the
parts both ever shared. A transcript is `list.scrollable_to_end()` like
anything else.
### The new public surface: three `Widget` methods
```rust
fn scrolls_itself(&self) -> bool { false }
fn apply_scroll(&mut self, delta: &mut f32) {}
fn scroll_offset(&self) -> f32 { 0.0 }
```
`Scroll` asks the first, and if the child says yes it stops sliding the
child about as a lump and starts handing it deltas. Each method is `&self`
or `&mut self` for a reason worth keeping: reaching a widget through
`Widgets::get_dyn_mut` *marks it dirty*, so asking the capability question
through `apply_scroll` would dirty every ordinary child on every scroll
tick and cost exactly the O(1) move the whole scheme exists for.
`Scroll::draw` is then measure, apply, place -- the same measure-then-place
idiom it already used for its own content length. The measuring draw is
free in the common case (unchanged region, nothing dirty, so `draw_inner`
returns immediately and the child's stored walls are still correct) and
really walks exactly when the content changed, which is when they need
re-reading. **Nothing is marked by hand**: reaching the child to hand it
the delta is itself what dirties it, so the placing draw really draws.
That is why `Painter::draw_again` could stay deleted.
### Why `scroll_offset` exists
`apply_scroll` leaving a remainder was meant to be the whole story, and it
is not quite. A lazy span usually **cannot say where its content ends
until it has walked there**, so it takes a delta in full whenever the wall
is not already in view, and the walk that follows gives part of it back.
The remainder is exact only when the wall was already visible. `Scroll`
adding remainders up would over-count by every overshoot and never
correct, so it reads the child's accumulated movement after the placing
draw instead, and `amt` is set from that. `amt` therefore always equals
what is on screen.
For a self-positioning child `amt` is **movement, not position**: paging
rows in above moves the origin and the child cannot say by how much,
never having measured them. The direction is the same as an ordinary
child's; the absolute value is not comparable, and a scrollbar would need
a real content length before it could use either.
### One convention for a scroll delta
There were two, and they read alike: `Scroll::scroll(+)` moved toward the
*start* while `LazySpan::scroll(+)` moved toward the *end*, with the
latter's doc claiming to mirror the former. Every call site had to
remember which it was talking to, and `Selection::drag` negated on the way
in. There is one now -- the finger's, which is `Scroll`'s -- and
`LazySpan::scroll` is private with the single negation inside
`apply_scroll`. `a_negative_delta_moves_toward_the_end` pins it across the
whole handoff, since no type can catch a scroll running backwards.
### What the measurements said, for the record
- A `Span` is skipped entirely in the steady state (`(0,0,0)` counters),
but **when it is redrawn it costs two draws per child** -- 21 draws for
10 children -- because phase 1 offers each child the ambient region to
learn its length and phase 2 offers it its real share. Any mutation of a
`Span` therefore redraws all of it: 24 draws for 11 children after one
prepend. That is why a transcript cannot be one.
- A settled scroll tick of the lazy span with 31 rows on screen is
**1 real draw and 31 move-slot writes**, no primitive rewrites and no
text reshaped; an idle frame is `(0,0,0,0)`. That is the number against
which "store the edges and only recompute what changed" would be
judged, and it is why the walk was left alone.
- The framework's own `ActiveData::size` cannot serve as the row-height
cache: `remove_rec` frees it the moment a row is virtualised away,
which is exactly when the walk needs it. The cache stays in the
container, keyed by `RowKey` -- which is also right for the reason you
gave, that a widget may one day render in two places and a size keyed
by `WidgetId` would break.
## 2026-09-08 (later still): a `List` clamps its overscroll in the same frame, and `draw_again` is gone
The last place in iris that corrected itself on a later frame. `List`'s
walk outward from its anchor could end up off the end of its content --
a fling stops wherever the spline's last step left it, and a `scroll` is
deliberately unclamped because nothing at the moment of the call knows
where the content ends. `clamp_to_content` measured that gap from the
edges the walk had just placed, wrote it to the anchor, and asked for
another frame. So one frame was drawn with the content past its own end,
and on Iris's phone a hard fling to the top left the whole screen blank
until something asked for that frame -- which a fling that has stopped no
longer does.
It is the same shape as `Scroll`'s fix. The walk is now `List::lay_out`,
and `List::draw` runs it, asks `overscroll_gap` whether the layout landed
off the end, and on a gap moves the anchor and runs the walk **again,
inside the same frame**. `overscroll_gap` is a pure measurement -- no
painter, no redraw handle -- and the decision to lay out again is `draw`'s.
Three properties make the second pass cheap and correct:
- **It runs only on a frame that actually overscrolled.** An ordinary
scroll tick still walks once.
- **One further pass always settles it.** The gap is measured from the
edges the first walk placed, so moving the anchor by it puts that edge
exactly on the viewport's; the opposite end can only open a new gap if
the content is shorter than the viewport, which `overscroll_gap`
declines to touch at all (a short list is bottom-anchored on purpose).
- **The second walk is mostly moves.** Every row keeps the box its cached
height gives it and only its offset changes, which is `draw_inner`'s
O(1) `mov` path.
**Public surface: `Painter::draw_again` is removed.** `List` was its only
caller, so with this there is no "ask for a corrective frame" mechanism in
the framework -- which is the point, since reaching for one is the sign a
placement should have been redone inside the draw that discovered the
problem.
`List::place` also lost half its body to the same simplification the rule
suggests: a placement is one pinned edge plus a height, so `Placement::
edges(height)` gives the box and the top-known and bottom-known cases stop
being two copies of the same arithmetic.
Tests that draw no settling frame on purpose, and fail without the change:
`fling_toward_the_start_stops_at_the_first_row` and the new
`scrolling_past_the_start_is_given_back_in_the_same_frame` in `list.rs`,
and `scrolling_past_the_first_row_settles_on_it` /
`scrolling_past_the_last_row_settles_on_it` at layer 1
(`transcript-fixture/tests/top_edge.rs`).
## 2026-09-08 (later): a `Scroll` measures and places its content in one frame
Iris's phone: "when typing with the keyboard up and entering enough
newlines ... the text drops down close to the bottom and seems to ignore
the padding. If I close (and optionally reopen) the keyboard it seems to
fix itself."
`Scroll::draw` used to place its child against **last** frame's content
length. Every newline therefore drew the field in a box one line short of
its text, and since that text is centred in its box it hung half a line
past each end -- putting the caret's line box a full 12dp below the bar's
inside edge, flush with its bottom, with the padding eaten. The comment
there said the lag "self-corrects the next frame". There was no next
frame: a keystroke dirties the field, not the scroll area, and after that
frame the tree is clean, so the stale placement was simply the last one
drawn -- until the keyboard closed, whose inset rewrite dirtied the bar
and forced the redraw. That is the "it fixes itself" half of the report.
The rule Iris stated when she saw the first fix, and which the code now
follows: **layout is a pure function of the state, never of how many
frames have been drawn.** Nothing should heal itself, because nothing
should be drawn wrong in the first place; where two draws are genuinely
needed to place something, both happen in the same frame.
So `Scroll::draw` now draws its child twice: once at last frame's length
purely to measure it, then once at the length it just measured, with the
end-pin and the clamp applied only to that second placement. The same
measure-then-place idiom `Span::draw` and `List::place` already use.
**The second draw is free unless the content's length actually changed**
-- an ordinary scroll tick offers the same size at a new offset, so the
first call is `draw_inner`'s O(1) `mov` and the second, with an identical
region, returns at its first line. Growing a bottom-anchored area is
still O(1) in the sense that mattered; what it is not is free to place
its child against a length already known to be wrong. Last frame's length
survives only as a *hint* that keeps the common case cheap; nothing drawn
depends on it.
Two consequences worth knowing:
- **An end-anchored `Scroll` now sits at its end on its first drawn
frame**, not its second. It could not before: the end-pin needs the
content's length, which was a frame behind, so a fresh area showed its
start and jumped. Two layout tests that scrolled *down* from what they
assumed was the top now build their area with `at_end: false`, which is
what they always meant.
- **`List::clamp_to_content` is now the only place left that corrects on
the next frame** -- it finds a fling has run past the content's end and
marks itself for a redraw. Same defect, larger machinery; recorded in
docs/IRIS_TODO.md rather than folded into this change.
Covered by `a_newline_leaves_the_caret_inside_the_composers_padding`
(layer 1, `transcript-fixture/tests/phone_screen.rs`), which draws no
settling frame on purpose and fails on the old code with the caret
exactly on the bar's edge. On the emulator the caret's bottom moved from
1535 -- the bar's own bottom edge -- to 1509, 26px inside a 31px padding;
the remainder is parley's line box standing ~6px taller than its line
height. `phone.rs` grew a `--typed TEXT` argument beside `--message`,
since laying the composer out from scratch and growing one already drawn
are different cases and only the second reproduces this.
## 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".
@@ -1067,3 +1581,245 @@ and per-block-row work (RUST.md's "Verification pass over Tasks A and B").
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`).
+438 -29
View File
@@ -7,6 +7,121 @@ order and what "done" looks like. Tick and date them in place.
## Fix
- [ ] **In progress (2026-09-08): scrolling moves out of the list.**
Agreed with Iris over the design exchange that followed the overscroll
clamp. The list stays -- a lazy layout is a real thing that `Span`
cannot be -- but everything about *scrolling* leaves it, so that
`.scrollable()` is the one way anything in iris scrolls. Three steps,
each independently verifiable:
1. **Rename and `Dir`.** `List` -> `LazySpan` (it is what `Span` is,
laid out lazily from an anchor; it also stops colliding with
`BlockKind::List` in the markdown code), `ListRow` -> `LazyItem`,
`RowKey` kept, `Axis` -> `Dir`. Direction (which end item 0 sits at)
and pin (which end the view clings to) are **separate**: a
transcript is `Dir::DOWN` with the pin at the end, and conflating
them would stand it on its head.
2. **Delete the physics from `LazySpan`.** Its `Flinger`, `density`,
`Arc<dyn RequestRedraw>`, `tick` and the whole `fling`/
`cancel_fling`/`tick_fling`/`is_scrolling`/`fling_velocity` surface
go; `Scroll` is then the only `Flinger` user and `sense.rs` already
holds the genuinely shared parts. Add to `Widget`:
`fn scrolls_itself(&self) -> bool` (a `&self` capability flag read
through `get_dyn`, which does **not** mark dirty) and
`fn apply_scroll(&mut self, delta: &mut f32)` (takes what it can,
leaves the rest).
3. **`Scroll` wraps it**, owning `amt` and the pin: measure the child,
`apply_scroll`, place it again -- the same measure-then-place idiom
`Scroll::draw` and `LazySpan::place` already use. The measuring call
is free in the common case (unchanged region, not dirty, so
`draw_inner` skips it) and really walks exactly when the content
changed, which is when its walls need re-reading. Reaching the child
through `get_dyn_mut` marks it dirty by itself, so the second call
really draws -- no `Painter::draw_again` and nothing marked by hand.
`transcript-ui`'s `Selection` retargets to the `Scroll`.
Decisions taken along the way, with their reasons, so they are not
re-litigated: the **height cache stays in the container** (Iris:
widgets may render to two places at once, so a size keyed by
`WidgetId` would break; and the framework's own `ActiveData::size` is
freed by `remove_rec` the moment a row is virtualised away, which is
exactly when it is needed). **No `redraw_on_move` flag** -- the child
returning from `apply_scroll` is already the signal. **`amt` for a lazy
child is accumulated actual movement, not a distance from the top of
the content**, since paging rows in above shifts the origin; that is
honest for every current use and must be written at the field so
nobody builds a scrollbar on it.
Done 2026-09-08, in two commits (the rename, then steps 2 and 3
together -- deleting the fling before `Scroll` could drive it would
have left the app unable to scroll at all).
**Two things the plan did not anticipate, both settled in the code:**
- **`apply_scroll`'s remainder is not enough on its own, so `Widget`
gained a third method, `scroll_offset`.** A lazy span usually cannot
say where its content ends until it has walked there, so it takes a
delta in full whenever the wall is not already in view, and the walk
that follows gives part of it back. The remainder is therefore right
only when the wall was already visible, and `Scroll` adding
remainders up would over-count by every overshoot and never correct.
`scroll_offset` is the child's accumulated movement, read `&self`
after the placing draw, and `Scroll::amt` is set from it -- so `amt`
equals what is on screen rather than what was asked for. There is a
test, `amt_counts_only_what_the_child_could_take`.
- **There were two opposite scroll-delta conventions**, and the
handoff made keeping both impossible. `Scroll::scroll(+)` moved
toward the *start* while `LazySpan::scroll(+)` moved toward the
*end*, and `LazySpan::scroll`'s own doc claimed to mirror `Scroll`'s.
There is one now -- the finger's, which is `Scroll`'s -- and
`LazySpan::scroll` is private, with the single negation inside
`apply_scroll`. Call sites that used to pass `-dy`/`-v` pass them
through, and the fixture recordings' expected velocity flipped sign
with its magnitude unchanged.
**Still open, and the one thing to decide:** the *pin* ("stay at the
end as rows are appended") is still each widget's own -- `Scroll` has
`snap_end` for an ordinary child, `LazySpan` has one for itself, and
the constructor argument sets each. Iris asked for `amt` and "other
controls (iirc only at end for now)" to live in `Scroll` so a caller
always edits the `Scroll`; that half is done for `amt` and not for the
pin, because a pin has to be *applied* when a row is appended --
between frames, with no painter in hand -- so moving it needs either a
fourth `Widget` method or a parameter on `apply_scroll`. Nothing
external edits a pin today (the transcript sets it once at
construction and calls `jump_to_end` on the span for the rest), so
this is a design question rather than a missing capability.
- [x] **`List::clamp_to_content` still corrects on the next frame
(2026-09-08).** Iris's rule, stated while the composer's caret was
being fixed: "nothing in the framework should ever self heal because
it should not be drawn incorrectly in the first place. If you need 2
draws to get something into the correct position then that should
happen within the same frame. Layout should never be frame dependent,
it should be a pure function of the state." `Scroll::draw` was brought
to that rule the same day (it measures its content and places it
again in the one frame, IRIS.md's entry). Done for `List` later the
same day: the walk outward from the anchor is now `List::lay_out`, and
`draw` runs it, asks `overscroll_gap` (a pure measurement, no painter
and no redraw handle) whether the layout landed off the end of the
content, and on a gap moves the anchor and runs the walk a second time
**inside the same frame**. `Painter::draw_again` had no other caller
and is gone with it, so there is now no "ask for a corrective frame"
mechanism in the framework at all. One further pass always settles it:
the gap is measured from the edges the walk actually placed, so moving
the anchor by it puts that edge exactly on the viewport's, and the
opposite end cannot open a new gap without the content being shorter
than the viewport, which `overscroll_gap` declines to touch. The extra
walk is paid only on an overscrolled frame and re-offers every row the
same box at a new offset, which `draw_inner` dispatches as an O(1)
move. Three tests draw no settling frame on purpose and fail without
the change: `fling_toward_the_start_stops_at_the_first_row`,
`scrolling_past_the_start_is_given_back_in_the_same_frame` (both in
`list.rs`) and `scrolling_past_the_first_row_settles_on_it` /
`scrolling_past_the_last_row_settles_on_it` (layer 1,
`transcript-fixture/tests/top_edge.rs`).
- [x] **`request_device` asked for compute-shader limits it never uses
(2026-09-05).** `Limits::default()` (both `iris/src/android/render.rs`
and `iris/src/default/render.rs`) requests desktop-tier compute limits
@@ -701,12 +816,15 @@ Iris's report, verbatim, with a screenshot. Phone: Mali-G715 (Vulkan),
has no rules for stays plain rather than being coloured by the
nearest one's.
- [ ] **Masks defined relative to each other.** Wanted: mask A multiplies
by something *and also* applies mask B — a mask can reference a parent
mask, the way the move chain references a parent offset. Today masks
are independent regions. Design it beside the move chain (same shape:
a parent index and a bounded walk in the shader); do it when a real
widget needs it, not before.
- [x] **Masks defined relative to each other. (Done: chaining
2026-09-07 in d507ae4, the multiply 2026-09-08.)** Built exactly
beside the move chain, as this asked: `Mask::parent` is a slot index
and the fragment stage walks it under the same bound the move chain
uses. Each step multiplies the referenced primitive's coverage into
the pixel's alpha, so a pixel inside two feathered corners is dimmed
by both — the "multiplies by something *and also* applies mask B" half.
The real widget that needed it was the transcript's code fence inside
the list. See docs/LAYOUT.md's "Masks with a shape".
- [ ] **Positions as a single float per scroll.** Iris raised, and half
rejected, letting a scroll update one float rather than positions:
input handling cares about most elements in a list, so absolute
@@ -782,45 +900,139 @@ Iris's report, verbatim, with a screenshot. Phone: Mali-G715 (Vulkan),
## Found by P1b (2026-09-06), all with a headless repro
Each was found by looking at `iris/run-headless.sh transcript -- -p
transcript-ui` rather than at a diff, and each is worked around in
`transcript-ui/src/tool.rs` rather than fixed here. docs/RUST.md's P1b box
has the fuller account.
transcript-ui` rather than at a diff. docs/RUST.md's P1b box has the
fuller account.
- [ ] **A `Span` of `Pad`ded children inside another `Span` places those
**No entry here is worked around any more** (Iris, 2026-09-08: "All of
those should be fixed. There should never be workaround code. Do the
same for those; fix them if they're trivial, diagnose and report if
not."). Two are fixed and ticked; the two that are left are missing
*capabilities* rather than defects being dodged, and each carries its
diagnosis and what building it actually costs.
- [x] **A `Span` of `Pad`ded children inside another `Span` places those
children a slot out of step.** Each child drew its content one sibling's
height below its own box. Repro: `IRIS_TOOLS_EXPANDED=1
height below its own box. Repro was: `IRIS_TOOLS_EXPANDED=1
iris/run-headless.sh transcript --shot /tmp/x.png -- -p transcript-ui`
with `tool.rs`'s group built as `Span(DOWN)[header, Pad(Span(DOWN)
[cards]), bar]` instead of the single `Span` it uses now. Bisected:
removing the inner `Span` fixes it, and so does removing the children's
own `Pad`; the background `Stack`, the `Sized` wrappers and the
`WidgetPtr` per child make no difference. **Not** the `mov`-vs-
`reposition` fault f5b8893 fixed -- it survives that commit. The
workaround costs the group the 4dp inset its Compose counterpart holds
its cards off the edge by, so this is worth fixing.
- [ ] **`scrollable_on(Axis::X)` on a non-editable `Text` draws nothing.**
[cards]), bar]` instead of the single `Span` it used. Bisected at the
time: removing the inner `Span` fixed it, and so did removing the
children's own `Pad`; the background `Stack`, the `Sized` wrappers and
the `WidgetPtr` per child made no difference. **Not** the `mov`-vs-
`reposition` fault f5b8893 fixed -- it survived that commit.
**Not reproducible on 2026-09-08.** Both spans are nested again and the
group has its 4dp inset back; that same headless render puts every
card's content in its own box, and `iris`'s
`a_span_of_padded_children_inside_a_span_draws_each_where_its_box_is`
(`layout_tests.rs`, the same shape inside a `List`, which is the
context the real one is in) pins it at layer 1. Something between
09-06 and 09-08 fixed it -- most likely the nested-mask pass or the
`mov` work after f5b8893. Left ticked with the original symptom
recorded rather than deleted, in case it comes back.
- [x] **`scrollable_on(Axis::X)` on a non-editable `Text` draws nothing.**
The panel is drawn and the text inside it is not. A markdown fence does
the same to a `TextEdit` and is fine, so it is the widget kind rather
than the chain. `tool.rs`'s `raw_block` is `masked()` only until this is
fixed, which means a long command is clipped rather than pannable.
**Not reproducible on 2026-09-08**: `raw_block` was changed to
`.scrollable_on(Axis::X).pad(..).masked_by(..)` and the command draws
normally (`IRIS_TOOLS_EXPANDED=1 iris/run-headless.sh transcript --shot`,
the `rm -rf target` card). Something between 09-06 and 09-08 fixed it --
the shaped-mask work (`.masked_by`, 38bf630) is the likeliest, since the
old chain was `.masked()` *inside* the padding. Left ticked with the
original symptom recorded rather than deleted, in case it comes back.
- [ ] **No overflow ellipsis.** `TextAttrs` can wrap or not wrap; there is
no "one line, ellipsised" the way `maxLines = 1` + `TextOverflow.
Ellipsis` gives Compose. A tool card's summary is clipped instead, so
nothing on screen says it was cut. Whichever end is cut has to be a
choice when this lands: a path is identified by its tail, a command by
its head.
- [ ] **A drawn chevron.** `Chevron.kt` draws its own strokes precisely
because a chevron from a font is a glyph a system font may not have --
and the bundled `NotoSans-Regular.ttf` indeed has no U+25B8/25BE/25B4,
while `NotoSansMono-Regular.ttf` does. `tool.rs` sets the mark in the
monospace face as a result. A real fix needs a line/path primitive;
iris has rects, text and textures only.
**Diagnosed 2026-09-08, and it is not trivial.** parley has no
ellipsis of its own (checked: nothing in the vendored crates), so iris
would build it, and the shape that looks easy is the one that breaks
something. The easy half really is easy: shape at
`max_advance = width - ellipsis_advance` with wrapping on, take line
0's `text_range()`, and re-shape `text[..end].trim_end() + "…"` with
wrapping off -- parley's own line breaker finds the cut, so nothing
here counts glyph advances by hand. The hard half is that
`TextBuffer` has exactly one string and everything addresses it by
byte offset: the inline spans that carry a fence's colours and a
link's range, `TextEditCtx::byte_at` (which turns a tap into a byte to
match a link against), `Selection`'s `select`/`selected_text`, and
`RowBlocks::apply_delta`. Truncating the buffer moves every one of
those. So the real work is giving `TextBuffer` a **displayed** string
distinct from its source, with one mapping from display byte to source
byte that all of those go through -- worth doing, and not a
by-the-way. Doing it only for text that is neither editable nor
selectable would avoid all of that and is exactly the kind of
exemption that comes back later.
It also wants an API change while it is open: `TextAttrs::wrap: bool`
cannot say three states. Something like `Overflow::{Wrap, Clip,
Ellipsis(End)}` replaces it, with `End::{Head, Tail}` making
UI_RULES's "choose which end to truncate" a thing a caller must
answer rather than a default nobody reads.
- [x] **A chevron the platform cannot fail to have.** **Done
2026-09-08**, twice. First as `iris::widget::mark(dir, dp, colour)`,
which rasterised an antialiased triangle into the ordinary texture path
-- correct, but one bespoke shape, and it built a texture *per widget*,
which is what crashed the bench (RUST.md's 2026-09-08 evening entry).
Then, on Iris's question -- "why does mark exist? The font should be
working if it's working for compose and nerd fonts are bundled" -- as
what the Compose app has always done: **iris ships its own Nerd Fonts
subset** (`iris/core/build-icon-font.sh` -> `iris/core/assets/fonts/
nerd_icons.ttf`, 992 bytes, three Material Design glyphs), named in
`iris::icon` and drawn with `Family::Icons`. `mark` is deleted. That
serves every future icon rather than one triangle, and an icon is text,
so it takes the size, colour and baseline of the line it sits in for
free. The original entry, for the record: *the bundled fonts were
removed on 2026-09-07 in favour of the platform collection, so the mark
is a codepoint the phone's own faces may not have -- Iris's 2026-09-08
screenshot shows an empty box where it should be, and the desktop render
draws it as a small dot. UI_RULES: "don't rely on characters the
platform might not have."*
- [ ] **A tool card's text is not selectable.** `Selection` is keyed
`(RowKey, block index)` and a card has no markdown blocks, so nothing in
a card registers. Compose's `SelectionContainer` covers tool output,
which is the text people most want to copy. Needs a key for "the nth
text of this row" that a card can mint without colliding with a
message's blocks.
which is the text people most want to copy.
**Diagnosed 2026-09-08: mechanical, but more than a sitting.** There
is no key collision to design around, which was the open question:
a `TranscriptRow::Tools` has *only* cards and no markdown blocks at
all, so a card is free to number its own texts from 0 in reading
order. What it costs is the registration lifecycle rather than the
key. Each card's `TextEdit`s have to `Selection::register` as they are
built and `unregister` when they are not -- and a card is rebuilt from
several directions (`redraw_card` when a result arrives,
`Shared::set_content` when the group is toggled or a call joins the
run, and the per-card `WidgetPtr` swap), each of which frees widgets
the map would otherwise still point at. That is the exact shape of the
crash `Selection::clear`'s doc records from
docs/REVIEW-2026-09-06.md: a handle in that map outliving the widget
panics on the *next* long press, somewhere else entirely. So the work
is a per-card base index with a stride (and a `debug_assert` that a
card stays inside it), one register/unregister path that every rebuild
route goes through, and a test per route that a rebuilt card leaves no
stale handle behind.
## Warnings standing in the bench build (2026-09-08)
Seen while checking `cargo ndk -t arm64-v8a check -p iris-android-app
--features bench`, pre-existing rather than added by this pass, and left
rather than silenced because each is a decision:
- [ ] **`PlatformHandle::show_diagnostics_overlay` has no caller.** It
and the ~60 lines of `IrisView.showDiagnosticsOverlay` behind it are a
plain-`TextView` overlay with Copy and Close, drawn over whatever iris
is doing -- built so a report can be read *even if iris itself has
stopped drawing*, which is the one case the in-iris diagnostics pane
that replaced it cannot cover. So this is a live escape hatch nobody
calls, not dead code: deleting both halves clears the warning and
removes the fallback, and wiring it back to something is a product
decision (Iris has no `logcat` on her phone). Ask before doing either.
- [ ] **`unused dependency: tabs-ui`.** Already explained in
`iris/android-app/Cargo.toml`'s own comment at the `tabs-ui` line.
## Build (for the port)
@@ -867,6 +1079,31 @@ do not duplicate it there.
both ways and keeping the one that is shorter to explain; delete the
other rather than keeping two ways.
- [ ] **A `Stack` that chooses its mask the way it chooses its size
(Iris, 2026-09-08).** She asked whether `masked_by` deserves to exist:
"a method that just does 2 separate things you can already easily do
does not deserve to exist." For a square-cornered surface it is indeed
redundant -- `.background(rect(BAR_FILL)).masked()` was measured
against `.masked_by(rect(BAR_FILL))` on the composer at the phone's own
size and density and the two are identical to the pixel. What the pair
cannot express is a clip that is not a box: `Painter::set_mask` writes
a `RectPrimitive::color(Color::NONE)` at the widget's own region, with
no radius, so `.background(rect(fill).radius(r)).masked()` draws a
rounded panel and then cuts its content square. Both other call sites
(`row.rs`'s fence, `tool.rs`'s raw output) are rounded, which is why
the method stands for now.
Her suggestion for removing it properly: **`Stack` already names where
its size comes from (`StackSize::Child(n)`); let it name where its
*mask* comes from the same way.** Then `.background(x)` is the one way
to put a surface behind something, and clipping to that surface is a
property of the stack rather than a second wrapper -- `masked_by` goes,
and `Masked::shape` with it. Worth checking while designing it: what a
stack with no mask child means (today's behaviour), whether the mask
child must also have been *drawn* first (`set_mask_to_widget` requires
it, and `Stack` draws in order, so naming child 0 is safe and naming a
later one is not), and what happens when the named child is the same
one the size comes from.
## Build (asked for by Iris, 2026-09-06): a density-independent length unit
- [x] **A third length kind beside relative and pixels, so display scales
@@ -934,7 +1171,7 @@ do not duplicate it there.
## From the phone, 2026-09-07 (build from ed04d4c)
- [ ] **"Some transcript blocks will be hidden until I uncover enough of
- [x] **"Some transcript blocks will be hidden until I uncover enough of
them."** Two screenshots of the bench app's transcript at the top
edge, both wrong in opposite directions: in one, rows scrolled above
the viewport are still drawn and bleed *through* the header bar
@@ -955,3 +1192,175 @@ do not duplicate it there.
the header's bottom must be masked. Fix both with one rule: a row is
drawn if any part of it intersects the viewport, and the viewport is
the list's own region.
**Done, e922b73 + d507ae4.** Three causes, and the rule above is what
they are all fixed with (`List::intersects_viewport`).
`iris/transcript-fixture/tests/top_edge.rs` is the layer-1
reproduction -- the real screen under a bench-app-shaped header --
and each test was confirmed to fail on its own subject and no other.
1. *Drawn over the header*: **nothing was clipping the list at all**,
and a row straddling an edge is drawn in full, so the part above
the list was on screen. It could not be `.masked()` before, either:
`Painter::set_mask` aborted when an ancestor already had a mask,
and the list's own rows use `.masked()` (a code fence, a tool
card's title). So masks nest now -- `Mask::parent`, walked in the
fragment stage, chained rather than intersected on the CPU because
each mask moves with its own widget. `the_list_is_clipped_to_its_
own_box`.
2. *Rows already scrolled past still drawn*: the layout walk runs from
the anchor, `scroll` moves the anchor's offset and nothing else, so
panning leaves the anchor's row further and further outside the
viewport and **every row between it and the viewport was drawn,
every frame** -- measured at 64 rows for a 2012px viewport after 8
scrolls of 3000px. `place` skips a row whose known box does not
overlap, and `rehome_anchor` puts the anchor back on a visible row
each frame without moving anything drawn.
`rows_that_have_left_the_viewport_are_not_drawn`.
3. *The blank band*: not a culling rule at all -- the list could rest
**past its own first row** (`fling_toward_the_start_stops_at_the_
first_row` was leaving it 1398px below a 600px viewport, a blank
screen, and that test's own assertion could not see it).
the overscroll clamp gives the gap back. Both ends:
`scrolling_past_the_first_row_settles_on_it`,
`scrolling_past_the_last_row_settles_on_it`. This is also the first
item of the later report below.
What was suspected and is *not* what happened: the visible-range test
never compared a row's top against the viewport's top (there was no
culling test at all), and `03c6be8`'s header duplicate is untouched by
any of this -- it stays open. A row straddling the top edge is drawn
both before and after; the test that would catch that mistake
(`the_row_across_the_top_edge_is_drawn`) is in place, and fails if the
rule is written against the row's top instead of its bottom.
## From the phone, 2026-09-07, later (build from 4274b8b, ai-app-bench b47eb73)
- [x] **"You shouldn't be able to scroll below the bottom (or above
top)."** Done in e922b73, as a clamp in `List::draw` rather than as a
clamp inside the scroll setter: nothing at the moment of a `scroll`
call knows where the content ends (that is what walking the rows finds
out), so the correction is measured from the ends the layout walk
already placed and written to the anchor. In the app that lands in the
same frame -- a scrolled list is dirty, and `redraw_updates` drains
the mark the correction sets before the frame is submitted -- so
nothing displaced is displayed; only a full-tree redraw (a resize)
could show one frame of it. A fling that reaches an end already ends
there (`tick_fling`'s `hit_bound`), and now stops *on* the end rather
than wherever the spline's last step had put it. Layer-1 tests at both
ends, listed in the item above. The list's offset is not clamped to its content range while
dragging and/or flinging. Compose's `LazyColumn` never moves content
past its ends -- the overscroll *effect* on Android 12+ is a stretch
drawn over clamped content, not a displacement. Clamp the offset in
one place (`List`'s scroll setter, so drag, fling, page-in and
programmatic scroll all go through it) and end a fling that hits the
clamp. Test at layer 1: a drag past either end leaves the offset at
the end; a fling into the end stops there.
- [x] **"Flinging now actually works but is slower than Compose's
immediately after releasing the flick (the slow down seems
correct)."** Done; RUST.md's "The fling started too slow" has the
derivation and the table. On `flick-120hz.touch` the release velocity
goes from **12250px/s to 15250px/s**, and on an accelerating flick --
the shape a real finger makes, and what the recording is too short to
show -- from 1080 to 2445px/s. The curve was right; `VelocityTracker`
was averaging total motion over the sample span, which cannot tell an
accelerating flick from a steady drag.
**Two things the plan for this item had wrong, both found by reading
the sources rather than remembering them.** Compose's touch path is
**not** `Strategy.Impulse`: `scrollable`/`draggable` release through
the 2D `VelocityTracker`, which on Android is two
`VelocityTracker1D(strategy = Lsq2)` over absolute *positions* -- a
degree-2 least-squares fit, differentiated at the newest sample.
Impulse is reached only by `DifferentialVelocityTracker`, for mouse
wheel and trackpad. And there is **no minimum** fling velocity on that
path: `ViewConfiguration.minimumFlingVelocity`'s 50dp/s is used only by
`NestedScrollInteropConnection`, while `DefaultFlingBehavior` skips
`abs(v) <= 1f` to dodge a NaN from the spline. So iris ports Lsq2, caps
at 8000dp/s, and floors at 1px/s -- no 50dp/s threshold Compose does
not have. `iris/benches/velocity_reference.py` is the independent
transcription the checked-in numbers come from; the negative control
(reverting to the average) fails exactly the seven tests about the
estimator and none of the rest. The release log gains a debug
`iris drag release samples:` line so a flick reported from the phone can
be replayed at layer 1.
- [~] **Input-event and timing report from the phone.** Iris: "add
another button to copy input event info so that I can do some stuff
manually and then send the event log to you ... instrument a lot of
the code with timings so I can give you time reports through the
same button." **Built on the log ring, 2026-09-07** (docs/RUST.md's
own section): `iris::sense::log_input_event` (one line per platform
pointer sample -- Android's `MotionEvent`, historical samples inline;
winit's `WindowEvent`; the harness's `TouchScript` line) and
`iris::diagnostics::log_frame` (one line per frame: frame number,
the frame clock, time since the last input, layout/draw durations,
`redraw_all`/`redraw_updates`/neither, primitives on screen,
whether something is animating), both under
`iris::diagnostics::trace_enabled()`, off by default because the ring
is only 2000 lines / 256 KiB and both targets at 120Hz fill that in
seconds. `iris/benches/report_to_touch.py` turns a report's
`iris::input` lines back into a `.touch` file for layer 1/2 replay --
round-tripped in `iris/transcript-fixture/tests/
input_log_roundtrip.rs`. **Not wired to a button**: the Diagnostics
pane is `iris/android-app/src/bench_client.rs`, open under another
agent at the time this landed; `set_trace(bool)` is the whole surface
a control needs. `docs/REVIEW-2026-09-07.md`'s D1 (the ring already
drowned in per-frame `debug!` lines that predated this pass) is fixed
in the same change -- see RUST.md's section for which four call
sites.
## From the phone, 2026-09-07, night (build 92985ba, ai-app-bench bf2088b)
Iris pasted a full Copy report (Mali-G715 Vulkan, 2.55, 120Hz). What it
showed, beyond her words:
- [x] **"Sometimes when I try to catch it while it's still moving
(particularly if I drag) then it fails to stop & snap to where finger
is." (done 2026-09-07, b87f5a5.)** Built as described below.
`DragArbiter::press_start` takes a `PressState` -- what the target
looked like at the moment the press landed -- rather than asking the
list later, because by then the fling has already been cancelled and
the answer is no. The defect layer 1 found doing it: one touch-down
reaches every sensor under the finger, so a block and the tool row
containing it deliver the same `PressStart` twice, and re-reading the
state on the second delivery turned every catch back into an ordinary
slop-waiting press. Tests in
`iris/transcript-fixture/tests/catch_a_fling.rs`, with
`the_same_small_drag_on_a_settled_list_moves_nothing` as the half the
change had no reason to touch. **Not yet confirmed from the phone.**
The original reading follows. The report's release lines show catches ending as
`v=-41`/`v=-274` pans, so the gesture *does* reach `Panning`, but the
content under the finger does not follow it while the fling is still
running and the slop has not been crossed. Compose: a down while
`isScrollInProgress` stops the fling *at the down* and starts the
drag immediately with no touch slop (`scrollable`'s
`startDragImmediately = isScrollInProgress`); the content is pinned to
the finger from the first sample. Port that: `PressStart` on a list
with a live fling ends the fling on that sample and enters `Panning`
without waiting for `DRAG_SLOP`; a release with no movement is then a
`Released(None)`, not a tap (Compose does not deliver a click either).
Layer-1 test on a flick followed by a down + small drag 150 ms later:
offset tracks the finger sample-for-sample from the down.
- [x] **"The copy report button seemed impossible to hit until I hit the
diagnostics one." (done 2026-09-07, b8ea723).** Not hit-testing: the
button logged `iris bench report: nothing to copy -- run the benchmark
first` six times and did nothing on screen. A control that silently
declines is the UI_RULES failure "a failure is reported where it
happened": `copy_report` now always copies something -- the
diagnostics pane's own text (with a first line saying no benchmark has
run) when nothing has run yet, or the last report otherwise -- and
never depends on another button having been pressed first.
- [x] **"The logs seem way too big to send in this message box, causes a
lot of lag." (done 2026-09-07, 7485d78 + b8ea723).** Two causes. (1)
The ring was 1339 lines of `naga::front` / `wgpu_core` / `jni` DEBUG
output with 4050 dropped: the ring logger accepted every crate at
Debug, and the trace gate (992c472) only covered iris's own lines.
`client_core::log_ring::ring_accepts` is the one filter now, applied at
the ring rather than per callsite: Debug/Trace only from `iris`/
`client_core` targets when tracing is on, Info and above from
everything else. (2) Copy report appended the whole ring; it now
appends `LogRing::tail_text(COPY_REPORT_TAIL_LINES)` (150, named at the
constant) with a first line saying how many older lines were left out
-- the full ring is still what the devlog provider hands Dev Updater.
- [x] Keyboard: the report shows `ime_bottom=891 ime_visible=true` then
back to 0 on the phone, so the insets now arrive with a height; the
push-up was not reported broken this time.
+77 -2
View File
@@ -883,7 +883,7 @@ set once from `DisplayMetrics.density` in `android::view::new_peer`; the
desktop backend has no per-monitor density wired up yet and stays at
`1.0`. Every layout call site that used to call `.apply_rest()`/
`.to_uivec2()` now passes `painter.density()` (nine call sites — `Span`,
`Sized`, `MaxSize`, `Aligned`, `Scroll`, `List::place`, and
`Sized`, `MaxSize`, `Aligned`, `Scroll`, `LazySpan::place`, and
`UiRenderState::reposition` itself). This also meant the Android
boundary's global logical-space stopgap could come out entirely: window
size, touch coordinates and insets are physical pixels again, matching
@@ -948,7 +948,7 @@ When this lands, copy this entry into `IRIS.md` (newest first):
> design, the move-offset mechanism this shipped alongside, and the file
> list.
## Masks with a shape (decided 2026-09-07, not yet built)
## Masks with a shape (decided 2026-09-07, built 2026-09-08)
Iris, on the code block's scrolling: "the code block scrolling currently
masks in an inner rectangle. Ideally masks should have a shape
@@ -1039,3 +1039,78 @@ and that the CPU SDF and the shader agree at a grid of points; a
`run-headless.sh --phone` screenshot of a scrolled code block shows
rounded corners with no square pixels poking out at the top and bottom
of the scrolled content. Record the commands in RUST.md when it lands.
### What was built (2026-09-08), and where it differs
The commands and the screenshot are in docs/RUST.md's queue entry. Four
places the code is narrower than the design above, each deliberate:
- **No `kind` and no `flags` on `Mask`.** It is `{ primitive, parent }`.
The referenced instance already carries its own `binding`, so a copy
of it in the mask is a second thing to keep in step; *alpha only* is
the only mode there is, so there is nothing to select. Both are a
field away if a second mode appears.
- **A mask's shape must be a rect.** `Painter::set_mask_to` asserts it,
by name, rather than leaving the shader to read a `rects` entry that
is not there. A glyph would need a CPU-side alpha plane before the
hit test could agree with the shader, and a standalone image needs a
bind-group switch the fragment stage cannot make (`masks_layout`'s own
comment on why an image's bind group must not name the masks buffer).
So **the texture-mask pass condition is not met and no texture mask
exists** — the point of the reference design is that adding one is a
binding check and a sampled alpha, with no new shader path, and the
shader's `mask_coverage` already has the branch where it would go.
- **The shape is a primitive of its own, not always a drawn one.** A
plain `.masked()` writes an undrawn `RectPrimitive` at its region
(`Drawn::No`, `NOT_DRAWN`) and points the mask at that, so "clip to my
box" and "clip to that widget's rounded background" are one mechanism
and square-cornered clipping did not become a special case.
`.masked_by(shape)` draws `shape` behind the content — in its own
layer, the way `Stack` puts a background under its content — and
clips to the first primitive it drew.
- **The CPU/shader agreement is a GPU test**, `iris/tests/mask_sdf.rs`,
the only test in the workspace that needs an adapter. It lifts
`distance_from_rect` and `rounded_rect_coverage` out of
`iris_core::SHAPE_SHADER` *by name* and runs them in a compute pass,
so the thing under test is the shader itself rather than a copy of it
that would be edited alongside.
## What a widget's *offered* box may and may not be (2026-09-08)
Two rules that were each true in one place and missing from a sibling,
found together by Iris's 2026-09-08 phone report.
**Padding works in whatever container it is placed in, and is an inset or
an outset depending on how tight that container's region is.** Iris's
own words, 2026-09-08: "padding should work no matter what container a
widget is placed in, and acts as both inset and outset depending on how
tight the parent region is." `Pad` offers its child the region it was
handed, inset on each side, and reports `used + padding` — so given a
generous box it insets the child inside it, and given a box already the
size of the content it reports a larger size and the parent grows. What
this rules out is any container that offers a padded child a box and then
ignores what it reported, and any caller that reshapes its tree to avoid
a `Pad` (which `transcript-ui/src/tool.rs` did until 2026-09-08, at the
cost of a tool group's 4dp inset).
**A widget offered a box it does not fit is drawn again at the box its
own reported size implies, in the same frame.** Not next frame. The
temptation to defer is real — `LazySpan::place` offers a row its *cached*
height precisely so that an unchanged row hits `draw_inner`'s cheap
skip-or-move path, and `Scroll` sizes its child region from last frame's
content length for the same reason. But a `Rect` fills whatever region it
is given (`Size::REST`, and `rect.rs`'s `is_size_independent` doc says
why it must), and `.background(rect(..))` is the ordinary way to style
anything — so a one-frame-stale box is a background drawn at the wrong
size while the text inside it is already right. On screen that is a tool
card that looks closed while its text is there and open while it is not.
A `reposition` is not the fix and cannot be: it writes an offset, never a
size.
The cost is bounded and worth stating, because it is what makes the rule
safe to apply everywhere: the second draw happens only on the frame a
widget's own size actually changes, which is a frame that was already
redrawing it. A widget whose reported size is a function of the box it
was *offered* would disagree every frame and redraw every frame — which
is why `LazySpan` requires content-sized rows, and has since long before
this.
+459
View File
@@ -0,0 +1,459 @@
# Review, 2026-09-07 — `ba2afba..origin/rustify`
Read-only review of the day's 24 commits: the glyph-atlas fix, the fling
spline and Lsq2 velocity estimator, keyboard/IME insets and `targetSdk`,
historical touch samples and the input clock, list culling / clamp /
anchor re-homing, nested masks and `draw_again`, the headless harness +
`transcript-fixture` + `rig-input`, desktop density, the release profile,
platform fonts + the Android monospace patch, and the client-core log ring
with `POST /client-log`.
**Verified while reviewing** (working tree, which also carries three other
agents' uncommitted edits — `iris/src/sense.rs`, `iris/core/src/ui/render_state.rs`,
`iris/src/lib.rs`, `iris/core/src/orientation/axis.rs`, and an untracked
`iris/src/diagnostics.rs`): `cargo fmt --check` clean in `iris/`,
`client-core/` and `server/`; `cargo clippy --all-targets` clean in `iris/`
and `client-core/`; `cargo test --lib -p iris` 101 passed, `cargo test -p
transcript-fixture` 10 passed. The `iris` doctest target fails to link
(`extern location for iris_core does not exist`) — a stale build artefact,
not a code fault, but worth knowing before trusting `cargo test -p iris`
as a whole.
The work is unusually well documented and the two "a test that compared
the code with itself" findings the authors made themselves are real and
were fixed correctly. What follows is what is left.
Counts: **5 defects, 7 risks, 3 tests that cannot fail in the bug's
direction, 7 rule findings, 2 nits.**
## Fix pass, 2026-09-07 evening
Every finding below carries a **Status** line. In summary: **13 fixed**
(D1, D4, D5, R1, R5, R7, T1, T2, T3 and four of the rule findings and both
nits), **6 moot or deferred** (D2, D3, R3, R4 and two rule findings, all
of them in the phone-logging route that `06b8a1f` deleted or in files the
devlog agent held open), and **2 not done on purpose** (R2, which waits on
docs/LAYOUT.md's mask redesign, and R6, which needs Iris's own phone).
The commits are `2ec0fee` (D4), `7e79ec1` (D5), `551c013` (R1), `e10582a`
(T1-T3), `ff1d6ea` (R5, R7) and `a6a100e` (the rename and the nits). Each
fix that the rig can express carries a test, and each of those was
confirmed by breaking its subject on purpose -- the break is recorded
beside the assertion, so the next reader does not have to re-derive it.
---
## Defects
### D1 — the app's own log ring is drowned by the same day's per-frame `debug!` lines, so the route built to get Iris's logs to her carries almost none of them
`iris/android-app/src/lib.rs:132` installs the ring at `LevelFilter::Debug`,
and `client-core/src/log_ring.rs:279` (`RingLogger::enabled`) returns
`true` unconditionally by design, so **every `log::debug!` in the process
lands in a 2000-line / 256 KiB ring**. In the same commit range that ring
became the only way a line reaches Iris, three ungated per-frame `debug!`
callsites are live:
- `iris/src/android/view.rs:446` and `:509` — two lines *per rendered frame*.
- `iris/src/widget/list.rs:576``iris fling tick:`, one line per fling tick.
- `iris/src/widget/text/mod.rs:81` — one per text shape (many per frame while rows compose).
**Failure scenario.** Iris flicks the transcript on a 120 Hz phone. That is
~240360 debug lines a second; the ring's 2000-line bound is exhausted in
**under ten seconds**, so by the time she presses `Copy report` every
`log::info!` about what she was actually investigating has been evicted.
The uploader makes it worse: it sends at most the ring per 10 s wake
(2000 lines ≈ 200 lines/s) against ~350 lines/s produced, so it also runs
permanently behind and pushes tens of KB/s of frame spam over the tunnel.
Note that another agent has already built the right mechanism — the
untracked `iris/src/diagnostics.rs` has `set_trace`/`trace_enabled`, a
default-off gate, and its module doc states this exact problem in as many
words. It gates `iris::input`/`iris::frame`; it does **not** gate the four
callsites above.
*Fix*: put `List::tick_fling`'s line and `view.rs`'s two `render():` lines
behind `iris::diagnostics::trace_enabled()` (the mechanism that already
exists for exactly this), and/or record into the ring at `Info` while
leaving `android_logger` at `Debug`.
**Status:** fixed in `992c472` (verified 2026-09-07: all four callsites, plus `sense.rs`'s drag-release samples line, now sit behind `iris::diagnostics::trace_enabled`, and `input_log_roundtrip` proves both directions).
### D2 — `POST /client-log` can make `ai-server` write an unbounded runtime log at an authenticated client's request
`server/src/routes.rs:1473` bounds the **line count** (500) and nothing
else. The route sits inside the router that applies
`DefaultBodyLimit::max(32 * 1024 * 1024)` at `server/src/routes.rs:179`
(raised for phone photos), so one request may carry 500 lines of ~64 KiB
each, and each is re-emitted verbatim into `tracing`. There is no
per-message cap on the server, no rate limit, and the runtime log
`ai-server` writes is the file Dev Updater tails and never rotates.
`MAX_MESSAGE_BYTES` (4096) exists only in the *client*
(`client-core/src/log_upload.rs:33`), i.e. the server trusts a value the
attacker controls.
**Failure scenario.** A buggy client (a `log::debug!` in a loop is enough —
see D1) or one holding a leaked bearer token posts 32 MiB every 10 s; the
host's disk fills and every other component's log goes with it.
*Fix*: give the route its own `DefaultBodyLimit` (the attachments route at
`:175` is the precedent for a per-route limit) and truncate each `message`
server-side to the same 4096 bytes rather than assuming the client did.
**Status:** moot -- `POST /client-log` was deleted with the whole upload route (`06b8a1f`), the app hands its log to Dev Updater through an on-device ContentProvider instead. Nothing to bound.
### D3 — lines the ring drops before the uploader sends them vanish with nothing saying so
`LogRing::since` (`client-core/src/log_ring.rs:169`) filters `seq >= cursor`
and silently returns fewer lines when eviction has passed the cursor;
`LogUploader::flush_once` (`:94`) then advances to whatever came back.
`dropped` is counted (`log_ring.rs:109`) and shown in the *local*
diagnostics pane, but it is never put in the upload body, and
`ClientLogBody` has no field for it.
**Failure scenario.** The tunnel is down for two minutes; the ring wraps.
When it comes back, the server log jumps from `#812` to `#5106` with no
line saying anything was lost. This is precisely the "unknown state
sharing a value with the empty state" UI_RULES asks to design first, and
the module doc for `dropped` claims it is "reported rather than inferred"
— it is, but only on the half of the path nobody is reading.
*Fix*: carry `dropped` (or `firstSeq`) in the batch and have `client_log`
emit one `warn!` when the sequence is not contiguous with the last batch
from that `source`.
**Status:** moot -- `client-core/src/log_upload.rs` was deleted with the route (`06b8a1f`). Whatever the ContentProvider does about eviction is that design's question, not this one's.
### D4 — the input clock anchors on the first event's *own* time, so that event's historical samples are dated before the anchor: the ordering assert fires, and release silently collapses them onto one instant
`iris/src/android/view.rs:628` takes the anchor as
`(Instant::now(), event.event_time_nanos())` from the first `MotionEvent`
the view ever sees, and `at()` computes
`anchor_at + (sample_time - anchor_nanos).max(0)`. Historical samples of
that same event are by definition **earlier** than its own `event_time`.
**Failure scenario.** The first event this view receives is an
`ACTION_MOVE` (the `DOWN` was delivered to another view, or the view was
attached mid-gesture). Its historical samples are, say, 12 ms before
`anchor_nanos`; `at()` clamps all of them to `anchor_at`, so the tracker
receives three samples with identical timestamps, the Lsq2 fit is
degenerate, and the flick reads 0 px/s. In a debug build the
`debug_assert!(ht >= previous)` at `:653` fires first — but `previous`
starts at `anchor_nanos` (`:651`), which is a value from a *different*
event, so that assert is also the wrong comparison for the first sample of
every later event.
*Fix*: anchor on the earliest sample of the first event
(`historical_event_time_nanos(0)` when `history_size() > 0`, else
`event_time`), and seed `previous` from the previous event's last sample
rather than from the anchor.
**Status:** fixed in `2ec0fee`. The arithmetic moved into `sense::PointerClock`, which anchors at `now - (event_time - oldest_sample)` and carries the last sample seen *across* events, so the ordering assert compares against the previous event's last sample rather than the anchor. It lives in `sense` because `iris::android` is `cfg`'d out everywhere but the device: `sense_tests.rs`'s `the_first_events_batched_samples_are_dated_apart` reports `[0ns, 0ns, 0ns]` against the old anchoring.
### D5 — the "before" velocity quoted in four places is not what the reference script prints
`iris/benches/velocity_reference.py`, run today, prints **12250 px/s** for
`flick-120hz.touch`'s average and **12500 px/s** for "press and one move
frame". Four places say 11750 for both:
- `docs/RUST.md:900` (`flick-120hz.touch | 11750 px/s`)
- `docs/RUST.md:905` (`press + one move frame | 11750 px/s`)
- `docs/IRIS_TODO.md:1026`
- `iris/transcript-fixture/tests/phone_screen.rs:55`
`iris/src/sense.rs:1406` has the correct 12250, so the two halves of the
same change disagree. The file that carries the wrong number is the one
that says "every number below is printed by `velocity_reference.py` … do
not 'fix' one by running the Rust and copying what it said". One of the
two rows also being 11750 for a completely different sample set is the
tell.
*Fix*: replace 11750 with the script's own 12250 / 12500 in those four
places, or say which run produced 11750.
**Status:** fixed in `7e79ec1`. All four places now say 12250 / 12500, the 1.30x ratio becomes 1.24x, and RUST.md records where 11750 half came from (196 px over a 16.68 ms **60 Hz** frame rather than the recording's own 16 ms -- which explains the flick row and not the other one, so that one was copied).
---
## Risks
### R1 — every new invariant guard is a `debug_assert!`, and the phone runs release
The five guards added today —
`iris/src/widget/list.rs:1156` (a `List` must be inside a `.masked()`),
`:1218` (`extents` holds only on-screen rows),
`iris/src/android/view.rs:653` (historical sample ordering),
`iris/src/sense.rs:1076` (`poly_fit_least_squares` sample count), and
`iris/core/src/ui/painter.rs`'s doubled-`set_mask` check — are all
`debug_assert!`. `docs/RUST.md` records that the bench APK **must** be
installed as `release` on the emulator (the debug `libmain.so` is 325 MB
and will not install) and Iris's phone gets release too. So none of these
can fire on any build anybody actually runs; in release a `List` drawn
without a mask silently paints over its surroundings again — the exact
fault e922b73 was written to fix.
*Fix*: for the two that are cheap and once-per-draw (`is_masked`, the
extents check), consider a plain `assert!` or a one-shot `log::error!`, so
the guard survives into the build the defect was found in.
**Status:** fixed in `551c013`. `is_masked`, the `extents` check, `set_mask`'s doubled-call check, `Painter::glyphs`'s atlas generation and `List::fling`'s finiteness are `assert!`/`assert_eq!` now; `List::place`'s slot precondition, `poly_fit_least_squares`'s two, and `PointerClock::sample`'s ordering stay `debug_assert!` and say in a comment why. The layer-1 suites pass in `--release` as well as debug, which is what says the promoted ones do not fire on a real replayed flick.
### R2 — a straddling row is now invisible above the list and still tappable through the header
Masks are applied in the fragment shader
(`iris/core/src/render/shader.wgsl:203`); the CPU hit path
(`UiRenderState::resolved_region`, `iris/core/src/ui/render_state.rs:709`)
does not consult `masks` at all. Before today the top of a straddling row
was drawn over the header *and* hit-testable there; now it is clipped away
but still hit-testable, which is worse — a tap on "Run benchmark" can land
on an invisible link in the row behind it. `docs/LAYOUT.md:1012` ("Hit-
testing applies the shape") is design, not code.
*Fix*: until LAYOUT.md's mask redesign lands, intersect a widget's hit
region with its mask chain in `resolved_region`; the chain walk already
exists on the GPU side.
**Status:** not done, deliberately -- docs/LAYOUT.md's mask redesign ("masks reference a drawn primitive instead of copying a shape", `1121d7c`) is where hit-testing gets the shape, and intersecting a chain in `resolved_region` now would be a second mechanism to unpick. Pointer left here rather than a fix.
### R3 — three copies of one wire contract, none of them linked
`client-core/src/log_upload.rs:28` (`MAX_LINES_PER_BATCH = 500`) and
`server/src/routes.rs:1418` (`CLIENT_LOG_MAX_LINES = 500`) must agree, in
different crates, with only a comment saying so; the body itself is built
by hand with `serde_json::json!` on one side and parsed by a
`#[serde(deny_unknown_fields)]` struct on the other. This project already
has the mechanism for exactly this — `event-model`, a crate both `server`
and `client-core` depend on precisely so "the app hand-mirroring it" stops
happening (`server/Cargo.toml:16` says so).
**Failure scenario.** Somebody raises the client's batch to 1000. Every
upload now returns 400, the uploader retries the *same* batch from the same
cursor forever, and the only sign is one line in a diagnostics pane on a
phone.
*Fix*: move `ClientLogLine`/`ClientLogBody` and the batch constant into a
shared crate.
**Status:** moot -- both copies went with the route (`06b8a1f`). If a client/server contract comes back, `event-model` is still the answer.
### R4 — `build.rs` bakes in a CA it never asks Cargo to watch, and the bench build now has no rebuild trigger at all
`emit_log_config` (`iris/android-app/build.rs:92`) calls `read_pinned_ca()`
but emits only `rerun-if-env-changed` for `AI_APP_LOG_HOST/_PORT/_TOKEN`
no `rerun-if-changed` for the CA *file*, and (because the bench build
returns at `:65`, before the transcript path's declarations) no
`rerun-if-env-changed=AI_APP_CA`/`XDG_CONFIG_HOME` either. Emitting any
`rerun-if-*` directive turns off Cargo's default "rerun when anything in
the package changes" heuristic, so the bench build lost the only trigger it
had.
**Failure scenario.** `~/.config/ai-app` is wiped (AGENTS.md calls this the
one-way door), `ai-server` mints a new CA, the APK is rebuilt — and
`build.rs` does not re-run, so the APK still pins the dead CA and every
upload fails with a TLS error nobody can attribute.
*Fix*: `println!("cargo:rerun-if-changed={}", ca_path.display())` inside
`read_pinned_ca`, and move the `AI_APP_CA`/`XDG_CONFIG_HOME` declarations
above the bench early-return.
**Status:** moot -- `iris/android-app/build.rs` was deleted (`06b8a1f`/`d8562d9`): the destination comes from the enrolment link now, so nothing is baked in at build time and there is nothing for Cargo to watch.
### R5 — desktop density is read once and never updated
`iris/src/default/mod.rs:254` reads `content_scale(window)` at startup and
sets it on both `rsc.ui.text.density` and `render`. `WindowEvent::
ScaleFactorChanged` is not handled, and `UiRenderer::resize` deliberately
no longer consults `scale_factor`. Dragging the window to a monitor with a
different scale leaves every `dp(...)` and every rasterised glyph at the
old density — the same class of disagreement the commit removed elsewhere.
It is invisible here (every display on this machine is 1.0), which is why
it needs writing down.
**Status:** fixed in `ff1d6ea`. `WindowEvent::ScaleFactorChanged` re-reads `content_scale` -- through that function, so `IRIS_SCALE` still pins `--phone`'s density instead of following the monitor -- and `UiRenderState::set_density` marks the tree for a full redraw when the value actually changes, since `Text::shape` keys its cache on `(attrs, width, density)`.
### R6 — removing the bundled fonts removed the guard for a fault that was found on the phone, and the check was run on the desktop
`iris/core/src/primitive/text.rs`'s `register_bundled_fonts` existed
because "bold spans on a real phone rendered as blank gaps of the correct
advance width" — the deleted doc says so. Its removal is Iris's own call
and is recorded properly in `docs/DECISIONS.md`, but the verification
recorded there is "checked with CJK + emoji **on desktop**", which is the
half that cannot fail: the fault was Android's font enumeration resolving
a weight/style. `iris/transcript-ui/src/tool.rs:110`'s comment is honest
that `CLOSED_MARK`/`OPEN_MARK`/`UP_MARK` (U+25B8/BE/B4) are now "a bet"
that the platform monospace face has them — which is UI_RULES' "don't rely
on characters the platform might not have", stated and then accepted.
*Fix*: before the next phone build, look at a bold run and the three
chevrons on Iris's device specifically; the emulator's font set is not
evidence for hers.
**Status:** not done here -- it is a *look at it on Iris's phone* item, and no build in this VM is evidence about her device's font set. Carried forward as the review said: before the next phone build, look at a bold run and at `CLOSED_MARK`/`OPEN_MARK`/`UP_MARK` (U+25B8/BE/B4) on her device specifically.
### R7 — the least-squares fit clamps a degenerate norm instead of detecting it
`iris/src/sense.rs:1105`: `1.0 / dot(...).sqrt().max(1e-6)`. Compose's
`polyFitLeastSquares` treats `norm < 1e-6` as "vectors are linearly
dependent, no solution" and bails; clamping instead produces a `q` row of
zeros, a zero on `r`'s diagonal, and a `0/0` that the `is_finite` check at
`:1059` happens to catch. It works, but it works by accident and the escape
is not the one the source it is transcribed from takes.
**Status:** fixed in `ff1d6ea`. `poly_fit_least_squares` returns `Option` and bails at `DEGENERATE_NORM` (Compose's `0.000001f`) instead of clamping; `velocity()` answers 0 on `None`. `a_fit_through_linearly_dependent_points_has_no_solution` reports `Some([NaN, NaN, NaN])` with the clamp back in place.
---
## Tests that cannot fail in the direction the bug would go
### T1 — `iris/transcript-fixture/tests/phone_screen.rs:64` computes the expected fling duration with the calculator under test, and asserts it one-sidedly
`let expected = FlingCalculator::new(PHONE_SCALE).duration(velocity);` then
`assert!(ran_for <= expected + 2 frames)`. This is the same
"calculator compared with itself" shape the fling-spline commit
(73f956f) identified and fixed elsewhere, and the direction it can fail in
is "the fling ran too long" — never "the fling stopped dead", which is
literally Iris's reported symptom. The companion
`assert_ne!(before, after)` passes on one pixel of travel. A fling that
settles on the first tick passes this test.
*Fix*: add a lower bound from `velocity_reference.py`'s number (a fling at
-15250 px/s at density 2.55 must run ≥ ~1.4 s and travel ≥ ~6000 px), not
from `FlingCalculator`.
**Status:** fixed in `e10582a`. Both bounds come from `fling_spline_reference.py`, which gained this case's own line (`density=2.55 v=15250.0: distance=11057.424px duration=2.0716s`), and travel is measured in pixels from a row's own on-screen extent (10527px measured). Scaling `tick_fling`'s elapsed by 1000 reports "stopped after 8ms"; scaling its delta by 0.01 reports "travelled 111px".
### T2 — `top_edge.rs:150` checks a row *count* on the leg where the culling bug appeared, and the box only on the other leg
`rows_that_have_left_the_viewport_are_not_drawn` asserts `rows.len() <= 24`
on the outbound leg and the per-row `inside the box` predicate only on the
return leg. The doc explains why (an unmeasured row must be drawn to be
measured), which is correct — but it means the test's name is only true of
half of it, and a regression that draws 20 rows in the wrong *place* on the
outbound leg passes.
**Status:** fixed in `e10582a`. The first leg still cannot assert the box (an unmeasured row has to be drawn to be measured), so there is a third leg -- back again, every height known. Widening `intersects_viewport` downwards passes all 40 forward steps and fails at "back 6".
### T3 — `top_edge.rs:116` checks that a mask exists and where it is, not that it reaches anything
`the_list_is_clipped_to_its_own_box` asserts `active.mask != MaskIdx::NONE`
and that the mask's region lies within the list's box. It never checks the
row primitives actually reference that mask, so a broken `Mask::parent`
chain — the thing d507ae4 introduced — would leave this green while a code
fence inside a row drew unclipped again.
*Fix*: assert that a row primitive's mask chain contains the list's mask
slot.
**Status:** fixed in `e10582a`. It walks every row primitive's mask chain and requires the list's own slot on it, and rejects a chain that loops. Forcing `Painter::set_mask`'s `parent` to `NONE` fails it with "clips to [Id(1)], a chain that never reaches the list's own mask Id(0)".
---
## Rules
- **`iris/src/widget/list.rs:576` is a second mechanism for per-frame
instrumentation.** `iris::diagnostics::trace_enabled` exists for exactly
"a default-off `debug!` in a hot path" and this line does not use it.
(Cause of D1; the gate is in the untracked `diagnostics.rs`, so at the
reviewed commit the line is simply ungated.)
- **`server/src/routes.rs:1518` (`client_log_time`) duplicates
`client-core/src/log_ring.rs:76` (`clock_time`)** — the same arithmetic
written twice in two crates, with a comment noting they must agree. Same
shared-crate answer as R3.
- **`client-core/src/log_ring.rs:301`'s doc claims more than the code
delivers**: "the caller is named in the error so it is findable" —
`log::SetLoggerError` names nobody. `iris/android-app/src/app_log.rs:44`
repeats the claim.
- **Stale comment: `iris/src/android/view.rs:624`** cites
`VelocityTracker::add_sample`'s debug assert; the method was renamed to
`add_position` in the same commit range.
- **`MOVE_CHAIN_LIMIT` now bounds two different chains** (move offsets and
masks) under a name that says one, in both
`iris/core/src/ui/render_state.rs:63` and `shader.wgsl:97`. The shader's
comment already calls it "the bound on the parent walk"; the constant
should say that too, or masks should get their own.
- **`iris/src/sense.rs:1434`'s stated negative control is not reproducible
as written.** "Reverting `velocity` to `total / span` fails exactly this
one, the flick recording, and `phone_screen.rs`" — but `samples` now
holds *positions*, so `total / span` over them gives 2750 for the steady
drag too, and the commit message for the same change says "exactly seven
tests". Two numbers for one experiment.
- **`iris/android-app/src/bench_client.rs:393`'s `ime_visible` is right and
its sibling one line up is not.** `set_bottom_inset(rsc,
insets.bottom.max(insets.ime_bottom))` still infers "make room" from a
`max`, so during the slide-in the composer is padded by the system-bar
inset while `ime_visible` already says the keyboard is up. Harmless
today; it is the same conflation the comment beside it warns about.
**Status of the rule findings, 2026-09-07 evening.**
- `list.rs:576`'s ungated per-frame line -- **fixed in `992c472`** with
the rest of D1.
- `routes.rs:1518`'s `client_log_time` duplicating `log_ring.rs`'s
`clock_time` -- **moot**: the route was deleted (`06b8a1f`).
- `log_ring.rs:301`'s "the caller is named in the error" -- **deferred to
the devlog agent**; `client-core/src/log_ring.rs` is its file this pass,
and `app_log.rs` no longer repeats the claim.
- `view.rs:624`'s stale `VelocityTracker::add_sample` -- **fixed in
`2ec0fee`**; the paragraph was rewritten for the anchoring change and
now names `PointerClock` rather than a method that no longer exists.
- `MOVE_CHAIN_LIMIT` naming two chains -- **fixed in `a6a100e`**: renamed
to `PARENT_CHAIN_LIMIT` in `render_state.rs` and `shader.wgsl` at once
(it had no other users), with the doc naming both chains it governs.
- `sense.rs:1434`'s unreproducible negative control -- **fixed in
`7e79ec1`**. Rerun with `velocity` reverted to `(newest - oldest) /
span`: seven fail in `-p iris` (the flick recording, the accelerating
flick, the horizon, the stopped finger, the minimum sample count, both
`drag_gesture` flick tests) plus `phone_screen.rs`'s flick. RUST.md's
"exactly seven" was right; the doc comment's "exactly this one, the
flick recording, and `phone_screen.rs`" was not, and now says the same
thing RUST.md does.
- `bench_client.rs:393`'s `set_bottom_inset(.., max(..))` -- **deferred to
the devlog agent**; `iris/android-app/**` was open under it this pass.
## Nits
- `iris/src/sense.rs:798` computes `self.velocity.velocity()` twice on a
release when `info` logging is on (once for the outcome, once for the
log line) — a full Lsq2 fit each.
- `iris/transcript-ui/src/selection.rs:303` calls `ui.ui_mut().animate(id)`
even when `fling()` bailed (`|v| <= 1.0`, or no anchor). Harmless — the
first `tick` unregisters — but it registers an animation that is known
not to exist.
---
**Status of the nits, both fixed in `a6a100e`.** `DragGesture`'s release
computes `velocity()` once into a local both the outcome and the
`iris drag release:` line read. `selection.rs`'s `animate(id)` is behind
`is_scrolling()`, which is the same answer `List::fling` itself reached --
and `phone_screen.rs`'s recorded flick still flings, which is the half
that says the guard did not turn a working release off.
## Commits reviewed
```
7e4e26a iris: resolve fontique's Android monospace generic family ourselves
84a13e8 iris: a fling starts at Compose's velocity, which is a curve fit and not an average
452c442 docs/RUST.md: queue -- logging landed; iris app enrolment ...
238057a docs: the phone-logging decision, how to use it, and two build-apk traps
896c93a iris: drop bundled Noto Sans, match Compose's platform-font fonts
690161e docs: the transcript's edges were three faults, and what the rig found
e922b73 iris: a transcript row is drawn if it overlaps the viewport, and clipped to it
d507ae4 iris-core: masks nest instead of aborting, and a widget can ask to be drawn again
9ed01e2 docs: phone report 2026-09-07 later -- overscroll, low initial fling velocity ...
5be9f1b iris-android-app: keep the app's own log, put it in Copy report, upload it
977bdb9 client-core: the app's own log ring, and POST /client-log to get it off a phone
9cd1263 docs/RUST.md: queue -- APK size done, the embedded-fonts question left for Iris
42af780 iris android-app: strip+LTO+cgu1+opt-level=s halve libmain.so, no feature trim needed
4274b8b Merge remote-tracking branch 'origin/rustify' into worktree-agent-ace98b0bdaf33ffff
73f956f iris: the fling curve was the identity function, and the keyboard was a targetSdk
038f6a3 docs: the test rig's layers 1 and 2, with their commands and their limits
1121d7c docs/LAYOUT.md: masks reference a drawn primitive instead of copying a shape ...
232de0e iris: a phone-shaped desktop window, driven by the same touch recordings
e430880 docs: phone report 2026-09-07, rows at the transcript's top edge culled early ...
a999bd1 docs: masks with a shape (LAYOUT.md, decided 2026-09-07) and the orchestrator queue
6840edf iris-android-app: the bench's fixture half comes from transcript-fixture
3332201 iris: a headless in-process harness, and the bench fixture as a shared crate
7f4ea7e docs/TODO.md: Compose app crash from Iris's phone log export, reversed AnnotatedString range
591128e AGENTS.md: the phone app and the planned desktop app share widgets and styling
```
+1908 -38
View File
File diff suppressed because it is too large. Load diff
+664 -534
View File
File diff suppressed because it is too large. Load diff
+53 -16
View File
@@ -19,7 +19,7 @@ tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread"] }
# app crate installs) -- this crate never installs one itself. Not in the
# android-only block below any more: the lines that matter most are in
# shared widget code, which the host backend compiles too.
log = "0.4.28"
log = "0.4.34"
# winit everywhere except Android; android-view (below) is what stands in
# for it there. Both backends live in this crate (see `src/android/mod.rs`'s
@@ -60,15 +60,20 @@ accesskit_android = "0.8.0"
send_wrapper = "0.6.0"
[features]
# RUST.md's I5 "Where iris's frame time goes" diagnosis: forces the Android
# `wgpu::Instance` to `Backends::GL` instead of `Backends::PRIMARY`, so the
# same build can be measured against SwiftShader's software Vulkan ICD (the
# default) or virgl's GLES path, without a second env-var plumbing path that
# nothing on this machine can hand to an already-launched Android process
# (there is no `am start` environment and no system-property reader here to
# add one). Read by `android/render.rs` and, so the GLES path can be
# reproduced on a machine with a real GPU rather than only in the emulator,
# by `default/render.rs`:
# RUST.md's I5 "Where iris's frame time goes" diagnosis: pins the
# `wgpu::Instance` to `Backends::GL` instead of `Backends::PRIMARY`, so one
# build can be measured on either backend. A compile-time feature rather
# than an env var because nothing on this machine can hand an env var to an
# already-launched Android process (there is no `am start` environment and
# no system-property reader here to add one).
#
# **Not needed to get GLES in the emulator**, whatever the history here
# says: the emulator's guest has no hardware Vulkan at all, so an ordinary
# build's runtime fallback lands on GLES by itself (docs/RUST.md, "What the
# emulator gives a GPU app"). Keeping the emulator on the same binary the
# phone runs is the point. What this feature is still for is forcing GLES
# on a machine that *does* have Vulkan -- the desktop -- which is why
# `default/render.rs` reads it too:
# ./run-headless.sh transcript --shot /tmp/x.png -- -p transcript-ui \
# --features iris/force-gles
force-gles = []
@@ -79,6 +84,9 @@ tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread", "time"]
# package is fine -- cargo excludes dev-dependencies from the graph used
# to build the library itself, so this only matters for `--examples`.
tabs-ui = { path = "tabs-ui" }
# `tests/mask_sdf.rs` only: the grid it hands the GPU and the coverages it
# reads back. wgpu and pollster are ordinary dependencies already.
bytemuck = { workspace = true }
# Plain Instant-timed binaries, not criterion -- see benches/message_list.rs's
# header for why. `harness = false` opts out of the unstable `#[bench]`
@@ -110,12 +118,41 @@ exclude = ["android-app"]
version = "0.1.0"
edition = "2024"
# Debug info is the reason a `cargo test --workspace` here was taking half
# an hour, and it is worth the paragraph. Measured 2026-09-08: with rustc's
# default `debug = true`, linking this workspace's test binaries wrote
# **~54 GB** (one single test binary's linker wrote 16.9 GB) and left an
# **88 GB** `target/`. Eight test binaries each statically link the whole
# wgpu + naga + winit + parley graph, and at the default every one of them
# gets a full copy of that graph's DWARF written into it. On a btrfs at 83%
# full the linkers then sat in `handle_reserve_ticket` -- uninterruptible,
# waiting on space reservation -- at about 20 MB/s between them, which is
# what "the tests are slow" actually was. Not CPU: the machine was 87% idle
# throughout.
#
# `line-tables-only` keeps what is actually read from a backtrace -- the
# file and line of every frame, which is what a panicking test prints and
# what gdb needs to name the frames of a segfault. What it gives up is
# inspecting variables in a debugger; when that is wanted, ask for it on
# the command line for that one run rather than paying for it on every
# build:
#
# RUSTFLAGS="-C debuginfo=2" cargo test -p iris --test whatever
[profile.dev]
debug = "line-tables-only"
# The tests are what this is really for; `cargo test` uses `dev` for
# dependencies and `test` for the test targets themselves, so setting only
# `dev` leaves the eight big binaries at the default.
[profile.test]
debug = "line-tables-only"
[workspace.dependencies]
pollster = "0.4.0"
winit = "0.30.12"
wgpu = "28.0.0"
bytemuck = "1.23.1"
image = "0.25.6"
pollster = "1.0.1"
winit = "0.30.13"
wgpu = "30.0.1"
bytemuck = "1.25.2"
image = "0.25.10"
parley = "0.11.1"
swash = "0.2.10"
fxhash = "0.2.1"
@@ -123,7 +160,7 @@ arboard = "3.6.1"
accesskit = "0.25.0"
iris-core = { path = "core" }
iris-macro = { path = "macro" }
tokio = "1.49.0"
tokio = "1.53.1"
# Current stable as of 2026-09-05 (`cargo search`) -- I5's markdown block
# model, the same crate E2's uncommitted `e2-transcript` experiment used for
# the identical job (RUST.md), rather than reimplementing a CommonMark
+151 -153
View File
@@ -566,18 +566,18 @@ checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "bit-set"
version = "0.8.0"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d"
dependencies = [
"bit-vec",
]
[[package]]
name = "bit-vec"
version = "0.8.0"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
[[package]]
name = "bit_field"
@@ -606,12 +606,6 @@ dependencies = [
"no_std_io2",
]
[[package]]
name = "block"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
[[package]]
name = "block2"
version = "0.5.1"
@@ -621,6 +615,15 @@ dependencies = [
"objc2 0.5.2",
]
[[package]]
name = "block2"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
"objc2 0.6.4",
]
[[package]]
name = "blocking"
version = "1.7.0"
@@ -744,7 +747,9 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
name = "client-core"
version = "0.1.0"
dependencies = [
"base64",
"event-model",
"log",
"pulldown-cmark",
"serde",
"serde_json",
@@ -762,9 +767,9 @@ dependencies = [
[[package]]
name = "codespan-reporting"
version = "0.12.0"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81"
checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
dependencies = [
"serde",
"termcolor",
@@ -835,16 +840,6 @@ dependencies = [
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
@@ -858,8 +853,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
dependencies = [
"bitflags 1.3.2",
"core-foundation 0.9.4",
"core-graphics-types 0.1.3",
"core-foundation",
"core-graphics-types",
"foreign-types",
"libc",
]
@@ -871,18 +866,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
dependencies = [
"bitflags 1.3.2",
"core-foundation 0.9.4",
"libc",
]
[[package]]
name = "core-graphics-types"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.13.1",
"core-foundation 0.10.1",
"core-foundation",
"libc",
]
@@ -1071,7 +1055,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -1388,9 +1372,9 @@ dependencies = [
[[package]]
name = "glow"
version = "0.16.0"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08"
checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5"
dependencies = [
"js-sys",
"slotmap",
@@ -1421,26 +1405,6 @@ dependencies = [
"windows",
]
[[package]]
name = "gpu-descriptor"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca"
dependencies = [
"bitflags 2.13.1",
"gpu-descriptor-types",
"hashbrown 0.15.5",
]
[[package]]
name = "gpu-descriptor-types"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91"
dependencies = [
"bitflags 2.13.1",
]
[[package]]
name = "half"
version = "2.7.1"
@@ -1506,12 +1470,6 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hexf-parse"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
[[package]]
name = "http"
version = "1.5.0"
@@ -1798,7 +1756,7 @@ version = "0.1.0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 3.0.5",
]
[[package]]
@@ -2042,15 +2000,6 @@ dependencies = [
"imgref",
]
[[package]]
name = "malloc_buf"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
dependencies = [
"libc",
]
[[package]]
name = "maybe-rayon"
version = "0.1.1"
@@ -2085,21 +2034,6 @@ dependencies = [
"autocfg",
]
[[package]]
name = "metal"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7047791b5bc903b8cd963014b355f71dc9864a9a0b727057676c1dcae5cbc15"
dependencies = [
"bitflags 2.13.1",
"block",
"core-graphics-types 0.2.0",
"foreign-types",
"log",
"objc",
"paste",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2132,9 +2066,9 @@ dependencies = [
[[package]]
name = "naga"
version = "28.0.0"
version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "618f667225063219ddfc61251087db8a9aec3c3f0950c916b614e403486f1135"
checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130"
dependencies = [
"arrayvec",
"bit-set",
@@ -2143,11 +2077,11 @@ dependencies = [
"cfg_aliases",
"codespan-reporting",
"half",
"hashbrown 0.16.1",
"hexf-parse",
"hashbrown 0.17.1",
"indexmap",
"libm",
"log",
"naga-types",
"num-traits",
"once_cell",
"rustc-hash",
@@ -2156,6 +2090,18 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "naga-types"
version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49"
dependencies = [
"hashbrown 0.17.1",
"indexmap",
"rustc-hash",
"thiserror 2.0.20",
]
[[package]]
name = "ndk"
version = "0.9.0"
@@ -2305,15 +2251,6 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "objc"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
dependencies = [
"malloc_buf",
]
[[package]]
name = "objc-sys"
version = "0.3.5"
@@ -2346,13 +2283,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
dependencies = [
"bitflags 2.13.1",
"block2",
"block2 0.5.1",
"libc",
"objc2 0.5.2",
"objc2-core-data",
"objc2-core-image",
"objc2-foundation 0.2.2",
"objc2-quartz-core",
"objc2-quartz-core 0.2.2",
]
[[package]]
@@ -2374,7 +2311,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
dependencies = [
"bitflags 2.13.1",
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-core-location",
"objc2-foundation 0.2.2",
@@ -2386,7 +2323,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
dependencies = [
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
]
@@ -2398,7 +2335,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
dependencies = [
"bitflags 2.13.1",
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
]
@@ -2433,10 +2370,10 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
dependencies = [
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
"objc2-metal",
"objc2-metal 0.2.2",
]
[[package]]
@@ -2445,7 +2382,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
dependencies = [
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-contacts",
"objc2-foundation 0.2.2",
@@ -2474,7 +2411,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
dependencies = [
"bitflags 2.13.1",
"block2",
"block2 0.5.1",
"dispatch",
"libc",
"objc2 0.5.2",
@@ -2508,7 +2445,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
dependencies = [
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
@@ -2521,11 +2458,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
dependencies = [
"bitflags 2.13.1",
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
]
[[package]]
name = "objc2-metal"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794"
dependencies = [
"bitflags 2.13.1",
"block2 0.6.2",
"objc2 0.6.4",
"objc2-foundation 0.3.2",
]
[[package]]
name = "objc2-quartz-core"
version = "0.2.2"
@@ -2533,10 +2482,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
dependencies = [
"bitflags 2.13.1",
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
"objc2-metal",
"objc2-metal 0.2.2",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [
"bitflags 2.13.1",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
"objc2-metal 0.3.2",
]
[[package]]
@@ -2556,7 +2519,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
dependencies = [
"bitflags 2.13.1",
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-cloud-kit",
"objc2-core-data",
@@ -2564,7 +2527,7 @@ dependencies = [
"objc2-core-location",
"objc2-foundation 0.2.2",
"objc2-link-presentation",
"objc2-quartz-core",
"objc2-quartz-core 0.2.2",
"objc2-symbols",
"objc2-uniform-type-identifiers",
"objc2-user-notifications",
@@ -2576,7 +2539,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
dependencies = [
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
]
@@ -2588,7 +2551,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
dependencies = [
"bitflags 2.13.1",
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-core-location",
"objc2-foundation 0.2.2",
@@ -2636,7 +2599,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -2860,9 +2823,9 @@ dependencies = [
[[package]]
name = "pollster"
version = "0.4.0"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336"
[[package]]
name = "portable-atomic"
@@ -3141,6 +3104,18 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "raw-window-metal"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135"
dependencies = [
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2",
]
[[package]]
name = "rayon"
version = "1.12.0"
@@ -3307,7 +3282,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -3571,9 +3546,9 @@ dependencies = [
[[package]]
name = "spirv"
version = "0.3.0+sdk-1.3.268.0"
version = "0.4.0+sdk-1.4.341.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844"
checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f"
dependencies = [
"bitflags 2.13.1",
]
@@ -3663,7 +3638,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -3912,7 +3887,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -4265,9 +4240,9 @@ checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "wgpu"
version = "28.0.0"
version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9cb534d5ffd109c7d1135f34cdae29e60eab94855a625dcfe1705f8bc7ad79f"
checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e"
dependencies = [
"arrayvec",
"bitflags 2.13.1",
@@ -4275,7 +4250,7 @@ dependencies = [
"cfg-if",
"cfg_aliases",
"document-features",
"hashbrown 0.16.1",
"hashbrown 0.17.1",
"js-sys",
"log",
"naga",
@@ -4295,9 +4270,9 @@ dependencies = [
[[package]]
name = "wgpu-core"
version = "28.0.1"
version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d23f4642f53f666adcfd2d3218ab174d1e6681101aef18696b90cbe64d1c10f9"
checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14"
dependencies = [
"arrayvec",
"bit-set",
@@ -4306,10 +4281,11 @@ dependencies = [
"bytemuck",
"cfg_aliases",
"document-features",
"hashbrown 0.16.1",
"hashbrown 0.17.1",
"indexmap",
"log",
"naga",
"naga-types",
"once_cell",
"parking_lot",
"portable-atomic",
@@ -4322,66 +4298,70 @@ dependencies = [
"wgpu-core-deps-emscripten",
"wgpu-core-deps-windows-linux-android",
"wgpu-hal",
"wgpu-naga-bridge",
"wgpu-types",
]
[[package]]
name = "wgpu-core-deps-apple"
version = "28.0.0"
version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87b7b696b918f337c486bf93142454080a32a37832ba8a31e4f48221890047da"
checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68"
dependencies = [
"wgpu-hal",
]
[[package]]
name = "wgpu-core-deps-emscripten"
version = "28.0.0"
version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34b251c331f84feac147de3c4aa3aa45112622a95dd7ee1b74384fa0458dbd79"
checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86"
dependencies = [
"wgpu-hal",
]
[[package]]
name = "wgpu-core-deps-windows-linux-android"
version = "28.0.0"
version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ca976e72b2c9964eb243e281f6ce7f14a514e409920920dcda12ae40febaae"
checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f"
dependencies = [
"wgpu-hal",
]
[[package]]
name = "wgpu-hal"
version = "28.0.1"
version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d6cb474beb218824dcc9e1ce679d973f719262789bfb27407da560cac20eeb"
checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58"
dependencies = [
"android_system_properties",
"arrayvec",
"ash",
"bit-set",
"bitflags 2.13.1",
"block",
"block2 0.6.2",
"bytemuck",
"cfg-if",
"cfg_aliases",
"core-graphics-types 0.2.0",
"glow",
"glutin_wgl_sys",
"gpu-allocator",
"gpu-descriptor",
"hashbrown 0.16.1",
"hashbrown 0.17.1",
"js-sys",
"khronos-egl",
"libc",
"libloading",
"log",
"metal",
"naga",
"naga-types",
"ndk-sys",
"objc",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
"objc2-metal 0.3.2",
"objc2-quartz-core 0.3.2",
"once_cell",
"ordered-float",
"parking_lot",
@@ -4390,26 +4370,44 @@ dependencies = [
"profiling",
"range-alloc",
"raw-window-handle",
"raw-window-metal",
"renderdoc-sys",
"smallvec",
"static_assertions",
"thiserror 2.0.20",
"wasm-bindgen",
"wayland-sys",
"web-sys",
"wgpu-naga-bridge",
"wgpu-types",
"windows",
"windows-core",
"windows-result",
]
[[package]]
name = "wgpu-naga-bridge"
version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081"
dependencies = [
"naga",
"wgpu-types",
]
[[package]]
name = "wgpu-types"
version = "28.0.0"
version = "30.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e18308757e594ed2cd27dddbb16a139c42a683819d32a2e0b1b0167552f5840c"
checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286"
dependencies = [
"bitflags 2.13.1",
"bytemuck",
"js-sys",
"log",
"naga-types",
"raw-window-handle",
"static_assertions",
"web-sys",
]
@@ -4419,7 +4417,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -4773,12 +4771,12 @@ dependencies = [
"android-activity",
"atomic-waker",
"bitflags 2.13.1",
"block2",
"block2 0.5.1",
"bytemuck",
"calloop",
"cfg_aliases",
"concurrent-queue",
"core-foundation 0.9.4",
"core-foundation",
"core-graphics",
"cursor-icon",
"dpi",
+15 -3
View File
@@ -18,8 +18,8 @@ crate-type = ["cdylib"]
[dependencies]
iris = { path = "../" }
android-view = { git = "https://github.com/rust-mobile/android-view.git", rev = "bec6c62a96cef8239b0fd7fedeef9b184d02e3a1" }
android_logger = "0.15.0"
log = "0.4.28"
android_logger = "0.15.1"
log = "0.4.34"
# `tabs-screen` (default, I2/I4's demo) and `transcript-screen` (I5's
# Android integration) are mutually exclusive -- one `ActiveClient` type is
# compiled in, never both (`lib.rs`'s doc comment) -- so both sets of deps
@@ -42,7 +42,7 @@ serde_json = { version = "1", features = ["float_roundtrip"], optional = true }
# workspace's own dependency tree transitively (`iris/Cargo.lock`, pinned
# at 0.2.179) -- this makes it a direct dependency at the same version
# rather than a second, possibly-drifting resolution.
libc = { version = "0.2.179", optional = true }
libc = { version = "0.2.189", optional = true }
# P0's bench build only: the scroll animation and the streaming phase are
# both a sequence of `sleep`s inside the async task `rsc.spawn_task` already
# runs on iris's own tokio runtime (`iris/src/task.rs`'s `Tasks::init`), and
@@ -73,6 +73,18 @@ bench = ["transcript-screen", "dep:transcript-fixture", "dep:libc", "dep:tokio"]
[profile.release]
panic = "abort"
# Measured 2026-09-07 (docs/RUST.md's "APK size" subsection): together these
# take libmain.so from 18,546,488 to 11,193,608 bytes (-39.7%) and the APK
# from 20,678,956 to 13,326,076 bytes (-35.5%), arm64-v8a release. `strip`
# also works around AGP's own stripReleaseDebugSymbols failing silently on
# this .so ("packaging them as they are"). `opt-level = "s"` over `"z"`:
# `z` measured another ~800 KB smaller but was not checked against iris's
# own frame-time bench, so it is not worth the unmeasured risk -- see the
# doc for the number and the follow-up this leaves.
strip = true
lto = "fat"
codegen-units = 1
opt-level = "s"
[profile.dev]
panic = "abort"
@@ -24,8 +24,45 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- The enrollment link Dev Updater's Enroll button opens
(what `ai-server` mints), the same one the Compose app
in `app/` registers: which app answers it is the phone
owner's choice at the moment of the tap, and both being
offered is the intended behaviour rather than a clash.
BROWSABLE so a link tapped in another app reaches here,
and `android:host` so this app is not offered for every
aiapp:// URI a future route invents. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="aiapp" android:host="enroll" />
</intent-filter>
<meta-data android:name="android.app.lib_name" android:value="main" />
</activity>
<!-- This app's own recent log, for Dev Updater to read on the
phone. Iris runs these builds with no adb, and Android
forbids one app reading another's logcat, so this is the
only way a log::info! here reaches her. The shape is Dev
Updater's contract (its README.md, "An app's own log"), not
something invented for this app.
The authority carries ${applicationId}, so the bench package
and the ordinary one each get their own and neither can read
the other's log. Exported, because the whole point is
another app reading it, and guarded by a permission Dev
Updater declares at protectionLevel="normal" (a signature
permission is not available: the two apps are signed with
different locally generated keys). Read-only: insert,
update and delete throw. -->
<provider
android:name=".DevLogProvider"
android:authorities="${applicationId}.devlog"
android:exported="true"
android:readPermission="dev.updater.permission.READ_DEVLOG" />
</application>
</manifest>
@@ -0,0 +1,193 @@
package dev.iris.android.demo;
import android.content.ContentProvider;
import android.content.ContentValues;
import android.content.UriMatcher;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.net.Uri;
/**
* This app's own recent log, exposed on the device.
*
* Iris runs these builds on a phone with no {@code adb}, and Android
* forbids one app reading another's {@code logcat} -- so nothing outside
* this process can recover what it wrote. The process already keeps a
* bounded copy of its log (Rust: {@code client_core::log_ring}); this
* hands it to Dev Updater, which is on the same phone, so it needs no
* tunnel, no token and no second enrolment.
*
* <p>The shape is <em>Dev Updater's contract</em>, not something invented
* here -- see that project's {@code README.md}, "An app's own log". Any
* app it delivers can implement the same and get the same Runtime tab.
* Two paths:
*
* <ul>
* <li>{@code lines?since=<seq>} -- every held line with a sequence at or
* after {@code since}, oldest first.
* <li>{@code status} -- one row: how many lines are held, how many the
* ring's own bound has dropped, and the newest sequence ({@code -1}
* for a log nothing has been written to, which is also how a reader
* notices this process restarted).
* </ul>
*
* <p>Read-only: there is nothing here for anyone else to change, so the
* three writing methods throw rather than silently doing nothing.
*
* <p>The authority is {@code <applicationId>.devlog}, filled in from
* Gradle so the bench build and the ordinary one each get their own and
* neither can read the other's. Read access is guarded by
* {@code dev.updater.permission.READ_DEVLOG}, declared in the manifest.
*
* <p>No {@code notifyChange}: the ring is filled by a {@code log::Log}
* backend on whatever thread logged, and giving that a way to reach a
* provider would mean plumbing a callback through {@code client-core} for
* every platform. Dev Updater polls while its tab is open, which its
* contract says it does precisely so implementing this stays cheap.
*/
public final class DevLogProvider extends ContentProvider {
static {
// The provider is created before any activity, so it cannot rely
// on MainActivity's own load. Loading twice is a no-op.
System.loadLibrary("main");
}
/** Matches {@link #nativeLinesSince}'s flat answer. Both sides say it once. */
private static final int FIELDS_PER_LINE = 5;
private static final String[] LINE_COLUMNS = {"seq", "t_ms", "level", "target", "message"};
private static final String[] STATUS_COLUMNS = {"held", "dropped", "newest_seq"};
private static final int LINES = 1;
private static final int STATUS = 2;
private UriMatcher matcher;
/** Every held line, {@link #FIELDS_PER_LINE} strings each, oldest first. */
private static native String[] nativeLinesSince(long since);
/** Three strings: held, dropped, newest sequence. */
private static native String[] nativeStatus();
/**
* Tells the Rust side which authority this build registered under, so
* the diagnostics pane can name somewhere a reader can actually query
* -- and so "declared but never created" is a state it can say. Only
* the provider knows it was instantiated; Android creates one lazily.
*
* <p>The files directory goes with it because <em>this is usually the
* only thing running</em>: after the app has died, Dev Updater's query
* starts the process for the provider alone, with no activity, so
* {@code MainActivity.nativeSetFilesDir} is never called and the line
* the panic hook left on disk is never replayed into the ring. That is
* exactly the run whose log somebody wants.
*/
private static native void nativeReady(String authority, String filesDir);
@Override
public boolean onCreate() {
// The authority is not a constant here: it is derived from this
// build's applicationId, so the bench package and the ordinary one
// do not share one. Read back from the manifest rather than
// recomposed, so there is one answer to what it is.
String authority = getContext().getPackageName() + ".devlog";
matcher = new UriMatcher(UriMatcher.NO_MATCH);
matcher.addURI(authority, "lines", LINES);
matcher.addURI(authority, "status", STATUS);
nativeReady(authority, getContext().getFilesDir().getAbsolutePath());
return true;
}
@Override
public Cursor query(
Uri uri,
String[] projection,
String selection,
String[] selectionArgs,
String sortOrder) {
switch (matcher.match(uri)) {
case LINES:
return lines(sinceOf(uri));
case STATUS:
return status();
default:
// Null rather than an exception: an unknown path is a
// reader asking for something this app does not have, and
// the contract's own answer for that is no cursor.
return null;
}
}
/**
* {@code ?since=} as a number, or 0 for a reader starting from the
* beginning. A value that is not a number is treated as 0 rather than
* refused -- what a caller wants from a malformed cursor is the log,
* not a stack trace about the query string.
*/
private static long sinceOf(Uri uri) {
String since = uri.getQueryParameter("since");
if (since == null) {
return 0;
}
try {
return Long.parseLong(since);
} catch (NumberFormatException ignored) {
return 0;
}
}
private static Cursor lines(long since) {
String[] fields = nativeLinesSince(since);
if (fields == null) {
return null;
}
MatrixCursor cursor = new MatrixCursor(LINE_COLUMNS, fields.length / FIELDS_PER_LINE);
for (int at = 0; at + FIELDS_PER_LINE <= fields.length; at += FIELDS_PER_LINE) {
cursor.addRow(
new Object[] {
Long.parseLong(fields[at]),
Long.parseLong(fields[at + 1]),
fields[at + 2],
fields[at + 3],
fields[at + 4],
});
}
return cursor;
}
private static Cursor status() {
String[] fields = nativeStatus();
if (fields == null || fields.length != STATUS_COLUMNS.length) {
return null;
}
MatrixCursor cursor = new MatrixCursor(STATUS_COLUMNS, 1);
cursor.addRow(
new Object[] {
Long.parseLong(fields[0]), Long.parseLong(fields[1]), Long.parseLong(fields[2]),
});
return cursor;
}
@Override
public String getType(Uri uri) {
// A MIME type is for something meant to be handed to another app
// as data; these rows are read by one reader that knows the
// columns. Saying nothing is the honest answer, not a gap.
return null;
}
@Override
public Uri insert(Uri uri, ContentValues values) {
throw new UnsupportedOperationException("this app's log is read-only");
}
@Override
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
throw new UnsupportedOperationException("this app's log is read-only");
}
@Override
public int delete(Uri uri, String selection, String[] selectionArgs) {
throw new UnsupportedOperationException("this app's log is read-only");
}
}
@@ -1,6 +1,8 @@
package dev.iris.android.demo;
import android.app.Activity;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.view.WindowInsets;
@@ -20,9 +22,28 @@ public final class MainActivity extends Activity {
System.loadLibrary("main");
}
/**
* The app's private directory, where the Rust side keeps its enrollment
* (`src/enrollment.rs`). Handed over before the view is built, because
* the client the view creates reads the enrollment as it starts.
*/
private static native void nativeSetFilesDir(String path);
/**
* One `aiapp://enroll?host=&port=&token=&ca=` link, as Dev Updater's
* Enroll button opens it. Parsed and stored on the Rust side, which is
* where the enrollment lives for the desktop app too -- nothing about
* the link's format is known here.
*/
private static native void nativeEnroll(String uri);
@Override
public void onCreate(Bundle state) {
super.onCreate(state);
// Before the view: creating it starts the Rust client, which asks
// straight away which server it is enrolled with.
nativeSetFilesDir(getFilesDir().getAbsolutePath());
handleEnrollmentIntent(getIntent());
IrisView view = new IrisView(this);
view.setLayoutParams(new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
@@ -99,6 +120,35 @@ public final class MainActivity extends Activity {
});
}
/**
* A link that arrives while the activity is already up. `singleTop` is
* not set, so this is the resumed case only -- the fresh-launch case
* goes through `onCreate`'s `getIntent`. `setIntent` so a later
* `getIntent` reports the one actually being acted on rather than the
* one this activity started with.
*/
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
handleEnrollmentIntent(intent);
}
/**
* Hands a VIEW intent's URI to the Rust side, which decides whether it
* is an enrollment link -- the scheme is checked here only so a launch
* intent (which carries no data) costs nothing.
*/
private static void handleEnrollmentIntent(Intent intent) {
if (intent == null) {
return;
}
Uri data = intent.getData();
if (data != null) {
nativeEnroll(data.toString());
}
}
/** Read one `WindowInsets` and hand it to the Rust side. The only
* place that reads these fields, so the static dispatch and the
* animation callback above cannot come to report different things. */
+35 -13
View File
@@ -12,19 +12,29 @@
# emulator stays on debug" rule -- pass `release` explicitly for a phone
# build). --abi defaults to arm64-v8a (a phone/real device); pass
# x86_64 for this checkout's own AVD. --features defaults to
# "transcript-screen bench" -- deliberately *without* `force-gles`, unlike
# an earlier version of this default. `force-gles` (`iris/Cargo.toml`'s
# own doc) exists only to force the emulator off its default software
# Vulkan and onto GLES for one specific measurement (RUST.md's I5, "Where
# iris's frame time goes") -- it was never meant to reach a real device,
# but this script's old default put it in every arm64 build regardless,
# so the P0 bench APK delivered to Iris's phone forced GLES there too.
# That is the named hypothesis in RUST.md's P0 box ("iris bench crash on
# the phone, 2026-09-06"): a real Vulkan driver is what a phone should
# run, and GLES is the backend the same box's own SwiftShader finding
# already flagged as the fragile one for this shader's storage buffers.
# Pass `--features "transcript-screen force-gles bench"` explicitly for
# an emulator backend-isolation run; never for a build meant for a phone.
# "transcript-screen bench" -- deliberately *without* `force-gles`, and
# nothing should add it back for the emulator's sake.
#
# **The emulator does not need a GLES build, because it has no hardware
# Vulkan to be steered away from** (docs/RUST.md, "What the emulator
# gives a GPU app", 2026-09-08): its guest's only Vulkan is SwiftShader
# in software, its GLES is the host's real GPU through virgl, and iris's
# own runtime fallback -- `Backends::PRIMARY`, no adapter, rebuild on
# `Backends::GL` -- takes an ordinary build there by itself. So the
# emulator and the phone run the *same binary* and differ only in what
# that binary finds, which is the whole point: a build flag that changed
# the backend would mean the thing measured here is not the thing
# shipped.
#
# `force-gles` (`iris/Cargo.toml`'s own doc) pins the backend at compile
# time for a backend-isolation measurement (RUST.md's I5, "Where iris's
# frame time goes"), and the desktop is the better place to run it now
# (`run-headless.sh ... --features iris/force-gles`). It was never meant
# to reach a real device, but this script's old default put it in every
# arm64 build regardless, so the P0 bench APK delivered to Iris's phone
# forced GLES there too -- the named hypothesis in RUST.md's P0 box
# ("iris bench crash on the phone, 2026-09-06"). Never pass it for a
# build meant for a phone.
set -eu
cd "$(dirname "$0")"
@@ -57,6 +67,18 @@ export ANDROID_NDK_HOME="$NDK_DIR"
# finds -- a debug x86_64 emulator build left behind made an arm64 "release"
# 339 MB on 2026-09-06.
rm -rf app/src/main/jniLibs
# ...and Gradle's own copy of them, which `rm -rf jniLibs` does not reach.
# `mergeReleaseNativeLibs` is *up to date* against its cached inputs, so a
# build that switches ABI packages the previous ABI: an `--abi x86_64`
# release APK containing `lib/arm64-v8a/libmain.so` installed fine and
# aborted at startup with `Could not get adapter!: NotFound {
# active_backends: VULKAN }` under libndk_translation -- which reads
# exactly like the phone's own Vulkan problem and is nothing of the kind.
# Scoped to the merge task's directory rather than all of `app/build`, so
# an ABI change costs the native merge and not the whole Gradle build.
rm -rf app/build/intermediates/merged_native_libs \
app/build/intermediates/stripped_native_libs \
app/build/intermediates/merged_jni_libs
echo "build-apk.sh: cargo ndk -t $ABI build ${BUILD_TYPE:+(${BUILD_TYPE})} --features \"$FEATURES\""
if [ "$BUILD_TYPE" = "release" ]; then
cargo ndk -t "$ABI" -P 29 -o app/src/main/jniLibs/ build --release --features "$FEATURES"
-100
View File
@@ -1,100 +0,0 @@
// Only does anything under the `transcript-screen` feature (RUST.md's I5
// Android integration) -- the plain tabs build (I2/I4) needs none of this
// and stays untouched, same reasoning as the feature gate in Cargo.toml.
//
// Bakes the sandbox server's host, port, token and pinned CA in at build
// time, the same way `app/androidApp/build.gradle.kts`'s
// `GeneratePinnedCert` task bakes the CA for the Compose app -- see that
// file's comment for why reading the machine's own certificate at build
// time is the right trust boundary. This build additionally bakes the
// host/port/token, which the Compose app does not: that app enrolls at
// runtime from a scanned QR/deep link, and a from-scratch enrollment UI
// (Keystore-sealed token storage, a QR/link scanner) is real, separate
// scope this integration does not need to build to answer RUST.md's
// question -- there is nothing here yet resembling `ServerConfig.kt`. So
// this is a **deliberate simplification for this rig only**: an APK built
// this way is good for exactly the emulator/server pair that built it, and
// must never be treated as a template for a real enrollment flow. Recorded
// in RUST.md's I5 box rather than left to be rediscovered.
use std::path::PathBuf;
fn main() {
if std::env::var_os("CARGO_FEATURE_TRANSCRIPT_SCREEN").is_none() {
return;
}
// P0's bench build (docs/RUST.md) opens the checked-in fixture with no
// server at all -- `bench_client.rs` never references the `pinned`
// module this generates, so requiring a live server's host/port/token/
// CA to build it (as plain `transcript-screen` does, below) would be a
// pointless requirement for a build that talks to nothing.
if std::env::var_os("CARGO_FEATURE_BENCH").is_some() {
return;
}
println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_HOST");
println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_PORT");
println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_TOKEN");
println!("cargo:rerun-if-env-changed=AI_APP_CA");
println!("cargo:rerun-if-env-changed=XDG_CONFIG_HOME");
let host = require_env(
"AI_APP_TRANSCRIPT_HOST",
"the sandbox server's host as the emulator reaches it, e.g. 10.0.2.2",
);
let port = require_env(
"AI_APP_TRANSCRIPT_PORT",
"the sandbox server's port -- app/ui-sandbox.sh's start banner prints it",
);
let token = require_env(
"AI_APP_TRANSCRIPT_TOKEN",
"the bearer token -- ~/.config/ai-app/sandbox-token, or the start banner's enrollment link",
);
let ca_path = std::env::var_os("AI_APP_CA")
.map(PathBuf::from)
.unwrap_or_else(|| {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| {
let home = std::env::var_os("HOME").expect("HOME must be set");
PathBuf::from(home).join(".config")
});
base.join("ai-app").join("certs").join("ca.pem")
});
let ca_pem = std::fs::read_to_string(&ca_path).unwrap_or_else(|e| {
panic!(
"no CA certificate at {} ({e}).\n\
Start ai-server (or app/ui-sandbox.sh) once on this machine first -- it \
generates the CA this build pins. Set AI_APP_CA=/path/to/ca.pem to build \
against a different one.",
ca_path.display()
)
});
let ca_pem = ca_pem.trim();
if !ca_pem.starts_with("-----BEGIN CERTIFICATE-----") {
panic!("{} is not a PEM certificate.", ca_path.display());
}
let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
let generated = format!(
"// Generated by build.rs from {host}:{port} and {ca}. Do not edit.\n\
pub const HOST: &str = {host_lit:?};\n\
pub const PORT: u16 = {port};\n\
pub const TOKEN: &str = {token_lit:?};\n\
pub const CA_PEM: &str = {ca_lit:?};\n",
host = host,
port = port
.parse::<u16>()
.unwrap_or_else(|e| panic!("AI_APP_TRANSCRIPT_PORT={port:?} is not a u16: {e}")),
ca = ca_path.display(),
host_lit = host,
token_lit = token,
ca_lit = ca_pem,
);
std::fs::write(out_dir.join("pinned_config.rs"), generated).unwrap();
}
fn require_env(name: &str, what: &str) -> String {
std::env::var(name).unwrap_or_else(|_| {
panic!("{name} must be set to build the transcript-screen feature -- {what}")
})
}
+15
View File
@@ -46,6 +46,21 @@ adb -s "$SERIAL" shell am start -n "$PKG/dev.iris.android.demo.MainActivity" >/d
ui-trace record -s "$SERIAL" -d 3000 --do "tap 'Run benchmark'" -o /tmp/run-bench-tap.txt >/dev/null
# Which adapter drew, before any number is printed. The emulator is a GLES
# machine -- its guest has no hardware Vulkan (docs/RUST.md, "What the
# emulator gives a GPU app") -- so iris's runtime fallback lands on `Gl`,
# and `Gl (... virgl ...)` is the host's real GPU while `Gl (...
# SwiftShader ...)` is the CPU. Those two produce frame times an order of
# magnitude apart and are otherwise indistinguishable in this report, so
# the line is printed rather than left in logcat for somebody to think of.
ADAPTER=$(adb -s "$SERIAL" logcat -d -s iris-android-app:I 2>/dev/null \
| sed -n 's/.*\(iris renderer: .*\)/\1/p' | tail -1)
if [ -n "$ADAPTER" ]; then
echo "run-bench.sh: $ADAPTER"
else
echo "run-bench.sh: no 'iris renderer:' line in logcat -- cannot say what drew this run" >&2
fi
# Poll for the report line rather than a fixed sleep -- the run itself is
# a fixed script (RUST.md's "Benchmark v2": 16 flings, a 20s streaming
# phase, ~61s of typing, 10s of keyboard toggles, roughly 2.5 minutes end
+186
View File
@@ -0,0 +1,186 @@
//! The platform half of this app's logging: what
//! `client_core::log_ring` needs that only Android can supply, which is
//! `android_logger` as the logger to forward to and nothing else.
//!
//! Everything general -- the ring, its bounds, the `log::Log` backend --
//! is in `client-core`, shared with the desktop app (AGENTS.md's sharing
//! rule).
//!
//! **Why an app carries its own log at all**: Iris tests these builds on a
//! GrapheneOS phone with no `adb`, and Android forbids one app reading
//! another's `logcat`. Nothing outside this process can recover what it
//! wrote, so the process keeps a copy -- and hands it to Dev Updater on
//! the same phone through `devlog`'s `ContentProvider`. See
//! `docs/DECISIONS.md`, 2026-09-07.
use client_core::log_ring::{self, LogRing};
/// Installs the ring in front of `android_logger`, so `logcat` still sees
/// exactly what it saw before and the ring sees it too.
///
/// Called once, from `JNI_OnLoad`. A second call is refused by `log`
/// itself; the message says which caller, since two initialisation paths
/// is a programmer error rather than something to recover from.
pub fn install(max_level: log::LevelFilter) {
let inner = android_logger::AndroidLogger::new(
android_logger::Config::default()
.with_max_level(max_level)
.with_tag("iris-android-app"),
);
if log_ring::install_process_logger(
Box::new(inner),
max_level,
iris::diagnostics::trace_enabled,
)
.is_err()
{
// Not a panic: a logger already installed means logging works,
// just without the ring, and taking the app down over a
// diagnostic would be worse than the diagnostic being missing.
// The line goes through whatever logger did win.
log::warn!("iris app log: a logger was already installed, so there is no ring");
}
install_panic_hook();
}
/// The process's ring -- what `Copy report` appends, what the diagnostics
/// pane counts, and what `devlog`'s provider hands to Dev Updater.
pub fn ring() -> &'static LogRing {
log_ring::process_ring()
}
/// Only the bench build has a diagnostics pane to put this in; the
/// transcript build's screen is the app's own and has no room for a
/// readout. Gated rather than left dead so the build stays warning-clean.
#[cfg(feature = "bench")]
/// Two lines for the diagnostics pane: how much of this app's log is held,
/// and where it can be read from.
///
/// The second names the provider's authority rather than saying "logging
/// is on", so a screenshot of this pane is enough to tell whether the
/// contract is live and which package's log it is -- the bench build and
/// the ordinary one have different ones.
pub fn diagnostics_line() -> String {
let where_to_read = match crate::devlog::authority() {
Some(authority) => format!("devlog provider: content://{authority}"),
// Not "off": Android creates a provider lazily, so this is what
// "nobody has asked for it yet" looks like, and it is a different
// thing from a build that does not have one.
None => "devlog provider: declared, not created yet".to_string(),
};
format!("{}\n{where_to_read}", ring().summary())
}
/// Where the panic hook leaves its report, under the app's private
/// directory. Read back and dropped by [`set_crash_dir`] on the next
/// start.
const CRASH_FILE: &str = "last-panic.txt";
/// How many of the dying run's own log lines the panic hook saves with
/// the panic, and [`set_crash_dir`] replays.
///
/// The panic's message and location say *what* broke; these say what the
/// app was doing on the way there, which is the half that is otherwise
/// unrecoverable -- the ring is memory only, so an abort takes every line
/// before the panic with it. Bounded rather than the whole ring because
/// this is written by a hook on a process that is about to die, and
/// because the replay pushes each line into the new run's ring, where an
/// unbounded paste would evict the run that is actually being watched.
const CRASH_CONTEXT_LINES: usize = 80;
/// The target the replayed context lines carry, so a reader can tell a
/// line from the run that died from one this run wrote. They keep their
/// original timestamp and level inside the text, which is why the level
/// they are re-pushed at is not meaningful and the target has to be.
const PREVIOUS_RUN_TARGET: &str = "previous_run";
static CRASH_PATH: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
/// Installs a `log`-level panic hook, so a panic's message and location
/// reach the ring and `logcat` rather than only the tombstone.
///
/// **Why this is needed at all**: these builds are `panic = "abort"`
/// (`Cargo.toml`), and the default hook writes to `stderr` plus
/// `android_set_abort_message` -- the crash report. Iris runs these on a
/// phone with no `adb`, so the crash report is exactly the surface she
/// cannot read, and an `assert!` that fired said nothing anywhere she
/// could see it. Routing it through `log::error!` puts it in front of
/// `android_logger` *and* in the ring `devlog`'s provider hands to Dev
/// Updater.
///
/// The ring is memory only, so after an abort the process that holds it
/// is gone -- hence the file half. [`set_crash_dir`] replays it.
fn install_panic_hook() {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let where_at = match info.location() {
Some(at) => format!("{}:{}:{}", at.file(), at.line(), at.column()),
None => "an unknown location".to_string(),
};
// `info`'s own `Display` repeats the location and a newline;
// the payload alone keeps this to the one line the ring wants.
let message = info.payload_as_str().unwrap_or("Box<dyn Any>");
let line = format!("iris panic at {where_at}: {message}");
log::error!("{line}");
if let Some(path) = CRASH_PATH.get() {
// The panic line first, then what the app was doing before
// it: one file, split again on that first newline by
// `set_crash_dir`.
let context = ring()
.try_tail_text(CRASH_CONTEXT_LINES)
// Said rather than left empty, so "the ring was locked as
// we died" cannot be read as "nothing had been logged".
.unwrap_or_else(|| {
"(the log ring was locked as this run died; no context)".to_string()
});
// Best effort by design: a panic is already the failure, and
// failing to record it must not become a second one.
let _ = std::fs::write(path, format!("{line}\n{context}"));
}
previous(info);
}));
}
/// Tells the panic hook where to leave its report, and replays the report
/// a previous run left there into the ring before deleting it.
///
/// Called from **both** `MainActivity.nativeSetFilesDir` and
/// `DevLogProvider.nativeReady` -- whichever of the two runs first in
/// this process, since after a crash Dev Updater's query starts the
/// process for the provider alone and no activity ever runs. Safe to call
/// twice: the file is gone after the first, so the second finds nothing
/// and says nothing. The panic itself is replayed at `error` level and
/// says it is from the previous run, so a crash loop shows the reason it
/// is looping in the Runtime tab of the run that is still up.
pub fn set_crash_dir(dir: &std::path::Path) {
let path = dir.join(CRASH_FILE);
if let Ok(previous) = std::fs::read_to_string(&path) {
// Delete before replaying rather than after: a replay that itself
// panicked would otherwise leave the file to be replayed again on
// every start, and a crash loop nothing can get out of is worse
// than one report lost.
let _ = std::fs::remove_file(&path);
replay_crash(&previous);
}
let _ = CRASH_PATH.set(path);
}
/// Puts a previous run's report back in the ring: its context lines in
/// the order they happened, then the panic itself.
///
/// Chronological, so the Runtime tab reads as one story -- the lines that
/// led to the crash, then the crash, then this run. The context goes in
/// through `LogRing::push` rather than through `log::info!` so it is not
/// stamped with this run's clock: each line already carries the time and
/// level it was written at, and [`PREVIOUS_RUN_TARGET`] is what says
/// whose run it was.
fn replay_crash(report: &str) {
let (panic_line, context) = report.split_once('\n').unwrap_or((report, ""));
for line in context.lines().filter(|line| !line.is_empty()) {
ring().push(log::Level::Info, PREVIOUS_RUN_TARGET, line.to_string());
}
log::error!(
"iris app log: the previous run died -- {}",
panic_line.trim()
);
}
+228 -46
View File
@@ -42,7 +42,7 @@ const STREAM_SECONDS: u64 = 20;
/// swipe with these any more.
const LEGACY_CYCLES: usize = 6;
/// Fling phase (v2): a real fling through `List::fling`, not a tween --
/// Fling phase (v2): a real fling through `Scroll::fling`, not a tween --
/// Iris's ask was that it "travel way faster" than the v1 swipe, and a
/// tween can never exceed the distance/time it is given while a real
/// fling decays from an initial velocity the way a finger flick does.
@@ -72,13 +72,15 @@ const TYPE_CHAR_MS: u64 = 50;
const KEYBOARD_CYCLES: usize = 5;
const KEYBOARD_WAIT_MS: u64 = 1_000;
/// One animation step's target cadence -- close enough to 60Hz that a
/// fling/scroll is many small moves rather than one jump, so frames are
/// actually rendered along the way, and close enough that a `ctx.update`
/// closure's effect (only applied once the next frame callback drains the
/// task channel -- `IrisViewPeer::drain_tasks`) is visible again quickly
/// when a later step in the same phase needs to read state back.
const ANIM_STEP_MS: u64 = 16;
/// How often this file *asks a question of* the running app -- polls for
/// a `ctx.update` closure's answer, or for a fling to have settled.
///
/// It is not an animation cadence and nothing on screen moves at this
/// rate: the frame loop advances animations once per frame at the
/// display's own refresh (`UiData::tick_animations`). It used to be both,
/// and that is the defect Iris reported on 2026-09-08 -- see
/// `wait_for_fling_settle`.
const POLL_MS: u64 = 16;
/// How much of the screen a *filled* benchmark report may take before it
/// scrolls instead of growing -- roughly a third of a phone screen, the
@@ -259,7 +261,7 @@ impl AndroidAppState for BenchClient {
let font = rsc.ui.text.font_diagnostics();
log::info!(
"iris fonts: {} families found, default={:?} mono={:?}, resolved regular={:?} \
bold={:?} italic={:?} mono={:?}",
bold={:?} italic={:?} mono={:?}, icons={:?}",
font.families_found,
font.default_family,
font.default_mono_family,
@@ -267,6 +269,7 @@ impl AndroidAppState for BenchClient {
font.bold_resolved,
font.italic_resolved,
font.mono_resolved,
font.icon_family,
);
let mut client = Self {
@@ -415,6 +418,29 @@ const KEYBOARD_DIAGNOSTICS_DELAY_MS: u64 = 500;
type Rsc = AndroidRsc<BenchClient>;
/// What a report says about the `iris::input`/`iris::frame` trace, from
/// the flag read at the start of what is being reported and again at the
/// end.
///
/// Three answers rather than two. Those lines are default-off and the
/// switch that turns them on is on screen while a benchmark runs, so
/// "somebody moved it half way through" is a state that actually happens
/// -- and reported as either "on" or "off" it is a confident sentence
/// about a log that only covers part of the run. The "on" wording also
/// says what it costs, because a traced run fills the ring in seconds and
/// a reader looking at a log with nothing else in it should know why.
fn trace_line(at_start: bool, at_end: bool) -> String {
match (at_start, at_end) {
(true, true) => "input/frame trace: on (iris::input and iris::frame lines are in \
the app log, and a traced run fills the ring in seconds)"
.to_string(),
(false, false) => "input/frame trace: off".to_string(),
_ => "input/frame trace: switched during this run, so those lines cover only part \
of it"
.to_string(),
}
}
/// The header row's own backdrop -- see `bench_controls`'s doc comment on
/// why it needs one at all. A dark neutral rather than pure black
/// (`android::render::CLEAR_COLOR`) so the row reads as a distinct panel
@@ -442,6 +468,25 @@ const HEADER_SURFACE: UiColor = UiColor::new(28, 28, 34, 255);
/// pixels) to `dp(...)` (IRIS_TODO.md's density-independent length unit),
/// so the row's reserved height in the outer `Span::DOWN`
/// (`AndroidAppState::new`) matches what is actually painted.
/// The size every label in the header row is drawn at.
///
/// One constant for all four rather than a number per button, because the
/// whole row has to be sized together. Adding the trace switch made four
/// controls too wide for one row at the size three had used (18), and an
/// earlier pass shrank this constant to 13 to make them fit -- exactly
/// what UI_RULES forbids ("never shrink text to make it fit": a label a
/// different size from its neighbours elsewhere in the app for a reason
/// the reader cannot see). The fix is [`bench_controls`]'s two rows
/// instead, which leaves room to put this back. Whoever adds a fifth
/// control reconsiders the row split, not this number.
const HEADER_TEXT: f32 = 18.0;
/// The height of one row of header controls, in dp. `bench_controls` now
/// stacks two of these, so this is the one number to change if a control's
/// own padding ever changes instead of `dp(56)` and `dp(112)` needing to
/// be kept in sync by hand.
const HEADER_ROW_HEIGHT_DP: f32 = 56.0;
fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
let run_rect = rect(Color::rgb(40, 70, 40))
.on(
@@ -453,7 +498,9 @@ fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
.label("Run benchmark");
let run = (
run_rect,
wtext("Run benchmark").size(18).text_align(Align::CENTER),
wtext("Run benchmark")
.size(HEADER_TEXT)
.text_align(Align::CENTER),
)
.stack()
.pad(dp(8))
@@ -462,14 +509,16 @@ fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
let copy_rect = rect(Color::rgb(50, 50, 60))
.on(
CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| {
ctx.state.copy_report();
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
ctx.state.copy_report(rsc);
},
)
.label("Copy report");
let copy = (
copy_rect,
wtext("Copy report").size(18).text_align(Align::CENTER),
wtext("Copy report")
.size(HEADER_TEXT)
.text_align(Align::CENTER),
)
.stack()
.pad(dp(8))
@@ -485,17 +534,61 @@ fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
.label("Diagnostics");
let diagnostics = (
diag_rect,
wtext("Diagnostics").size(18).text_align(Align::CENTER),
wtext("Diagnostics")
.size(HEADER_TEXT)
.text_align(Align::CENTER),
)
.stack()
.pad(dp(8))
.add(rsc);
let buttons = (run, copy, diagnostics).span(Dir::RIGHT).add(rsc);
// A switch rather than a button, so its own appearance says which
// state it is in: the two `iris::input`/`iris::frame` targets are
// default-off (`iris::diagnostics`'s module doc) because a 120Hz
// session fills the 2000-line ring in seconds, so "is it on right
// now" is the question somebody has while looking at a log that is
// either full of trace or has none.
//
// The visible text carries the state and the accessibility label does
// not, deliberately: the label is also what `run-bench.sh` taps by
// name, and a control that renames itself when pressed is one no
// script can find twice.
let tracing = iris::diagnostics::trace_enabled();
let trace_rect = rect(if tracing {
Color::rgb(90, 70, 30)
} else {
Color::rgb(50, 50, 60)
})
.on(
CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
ctx.state.toggle_trace(rsc);
},
)
.label("Trace input and frames");
let trace = (
trace_rect,
wtext(if tracing { "Trace on" } else { "Trace off" })
.size(HEADER_TEXT)
.text_align(Align::CENTER),
)
.stack()
.pad(dp(8))
.add(rsc);
// Two rows rather than one: four controls at the restored `HEADER_TEXT`
// no longer fit a 1080px-wide row (that was the shrink this replaces --
// see the constant's own doc). Grouped by what they act on: the first
// row starts a benchmark and copies its result; the second is the
// diagnostics pane and the switch that decides what it will contain
// next time.
let row1 = (run, copy).span(Dir::RIGHT).add(rsc);
let row2 = (diagnostics, trace).span(Dir::RIGHT).add(rsc);
let buttons = (row1, row2).span(Dir::DOWN).add(rsc);
(rect(HEADER_SURFACE), buttons)
.stack()
.height(dp(56))
.height(dp(2.0 * HEADER_ROW_HEIGHT_DP))
.pad(Padding::top(top_pad))
.add_strong(rsc)
.any()
@@ -526,6 +619,25 @@ impl BenchClient {
self.last_report = Some(report);
}
/// Turns the `iris::input`/`iris::frame` trace on or off, redraws the
/// switch that says so, and shows the pane that now reports it.
///
/// Showing the pane is the point rather than a convenience: this is a
/// control whose whole effect is on what a *later* report says, so
/// putting the state on screen at the moment of the press is the only
/// thing that distinguishes it from a button that did nothing.
fn toggle_trace(&mut self, rsc: &mut Rsc) {
let on = !iris::diagnostics::trace_enabled();
iris::diagnostics::set_trace(on);
log::info!(
"iris diagnostics: input/frame trace {}",
if on { "on" } else { "off" }
);
let controls = bench_controls(rsc, self.last_top_pad);
(self.top_bar)(rsc).set(controls);
self.show_diagnostics(rsc);
}
/// The diagnostics report as text, with no side effect on what is on
/// screen -- shared by the `Diagnostics` button (which shows it) and
/// the keyboard-open capture (which only logs it), so the two can
@@ -544,7 +656,20 @@ impl BenchClient {
// logcat on her phone, and "the keyboard does not push the
// composer up" cannot be told from "the listener never fired"
// without it (`AndroidUiState::insets_report`).
format!("{renderer}\n{}", self.android_state().insets_report())
format!(
"{renderer}\n{}\n{}\n{}\n{}",
trace_line(
iris::diagnostics::trace_enabled(),
iris::diagnostics::trace_enabled()
),
self.android_state().insets_report(),
// Which server this build talks to, and what to do when the
// answer is "none" -- the bench itself opens a checked-in
// fixture and needs no server, so this pane is the only place
// an enrolment can be seen to have taken.
crate::enrollment::status_line(),
crate::app_log::diagnostics_line()
)
}
/// The keyboard's own diagnostics capture -- see `on_insets_changed`'s
@@ -565,16 +690,40 @@ impl BenchClient {
log::info!("iris keyboard diagnostics:\n{report}");
}
fn copy_report(&mut self) {
let Some(report) = &self.last_report else {
log::info!("iris bench report: nothing to copy -- run the benchmark first");
return;
};
/// Always copies something, and never depends on `Diagnostics` or
/// `Run benchmark` having been pressed first (docs/IRIS_TODO.md,
/// 2026-09-07 night: "the copy report button seemed impossible to hit
/// until I hit the diagnostics one" -- it was silently declining
/// instead of reporting where it had failed, the UI_RULES failure "a
/// failure is reported where it happened"). With no benchmark run yet,
/// it copies the diagnostics pane's own text instead, with a first
/// line saying so -- `diagnostics_text` needs no prior button press
/// either, so this is never actually empty-handed.
fn copy_report(&mut self, rsc: &mut Rsc) {
let Some(platform) = &self.platform else {
log::info!("iris bench report: no platform handle, can't reach the clipboard");
return;
};
if platform.copy_to_clipboard("iris bench report", report) {
let report = match self.last_report.clone() {
Some(report) => report,
None => format!(
"no benchmark has run yet -- these are the diagnostics instead:\n\n{}",
self.diagnostics_text(rsc)
),
};
// The ring's tail goes on the clipboard, not the full ring, and
// not into the on-screen pane either: the full ring can be over a
// thousand lines with tracing on, and pasting that into a phone's
// message box was Iris's own "causes a lot of lag" report. The
// full ring is still reachable through Dev Updater's Runtime tab
// (`devlog`'s provider reads the same ring) -- this only bounds
// what gets inlined here.
let report = format!(
"{report}\n\n=== app log ({}) ===\n{}",
crate::app_log::ring().summary(),
crate::app_log::ring().tail_text(client_core::log_ring::COPY_REPORT_TAIL_LINES)
);
if platform.copy_to_clipboard("iris bench report", &report) {
log::info!("iris bench report: copied to clipboard");
} else {
log::info!("iris bench report: clipboard copy failed");
@@ -603,6 +752,11 @@ impl BenchClient {
.and_then(|p| p.refresh_rate_hz())
.unwrap_or(60.0);
let cpu_start = process_cpu_ms();
// Read at the start as well as the end, because the switch is on
// screen while a run is going: a report that only asked afterwards
// would say "on" about a run whose first half has no trace in it
// -- the inferred answer presented as the measured one.
let trace_at_start = iris::diagnostics::trace_enabled();
let run_started_at = Instant::now();
rsc.spawn_task(async move |mut ctx| {
@@ -700,9 +854,11 @@ impl BenchClient {
" type: {} characters inserted then deleted, one per {TYPE_CHAR_MS}ms",
TYPE_TEXT.chars().count()
);
let traced = trace_line(trace_at_start, iris::diagnostics::trace_enabled());
let report = format!(
"iris bench report\n{per_phase}{frames_block}\n\nbench:\n{fling_line}\n\
{scroll_line}\n{type_line}\n{keyboard}\n{cpu_line}\n{rss_line}\n{battery}"
"iris bench report\n{traced}\n{per_phase}{frames_block}\n\nbench:\n\
{fling_line}\n{scroll_line}\n{type_line}\n{keyboard}\n{cpu_line}\n\
{rss_line}\n{battery}"
);
log::info!("iris bench report: {report}");
state.report_display.edit(rsc).set(&report);
@@ -727,7 +883,7 @@ impl BenchClient {
/// drained everything queued before this call existed. Cost a real hang
/// in this file's first version of the fling phase: every loop iteration
/// after the first sat forever with nothing scheduled to drain it.
/// Polls rather than assuming one `ANIM_STEP_MS` sleep is enough, since a
/// Polls rather than assuming one `POLL_MS` sleep is enough, since a
/// slow device's frame callback can lag further than that.
async fn read_from_state<T, F>(
ctx: &mut iris::task::TaskCtx<Rsc>,
@@ -747,19 +903,20 @@ where
if let Ok(value) = rx.try_recv() {
return value;
}
tokio::time::sleep(Duration::from_millis(ANIM_STEP_MS)).await;
tokio::time::sleep(Duration::from_millis(POLL_MS)).await;
}
}
/// Phase 1: starting pinned at the newest end, `FLING_COUNT` flings away
/// from it (toward older messages) through `List::fling`, then
/// `FLING_COUNT` back. Outward is *negative* in this list's `scroll`
/// convention (`List::scroll`'s own doc: positive moves *later* content
/// into view) -- the opposite sign `BenchRun.kt`'s `runFlingPhase` uses,
/// since `TranscriptList`'s `LazyColumn` and this list define "positive"
/// the other way around; the two apps' *travel* is still directly
/// comparable because both report it as a row index + pixel offset, not a
/// signed distance.
/// from it (toward older messages) through `Scroll::fling`, then
/// `FLING_COUNT` back. Outward is *positive* in `Scroll::scroll`'s
/// convention, which is the finger's: a finger dragged down the screen
/// brings earlier content into view. It was negative here until
/// 2026-09-08, when the transcript's scroll position moved out of the
/// `LazySpan` -- whose anchor offset ran the other way -- and into the
/// `Scroll` around it. The two apps' *travel* is directly comparable
/// whichever way the signs run, because both report it as a row index plus
/// a pixel offset rather than a signed distance.
async fn run_fling_phase(
ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn RequestRedraw>,
@@ -775,13 +932,14 @@ async fn run_fling_phase(
redraw.request_redraw();
// Lets the next frame's `repair_anchor` resolve `jump_to_end`'s
// `anchor = None` into a real slot before `start` is read.
tokio::time::sleep(Duration::from_millis(ANIM_STEP_MS * 2)).await;
tokio::time::sleep(Duration::from_millis(POLL_MS * 2)).await;
let start = read_anchor_position(ctx, redraw).await;
for _ in 0..FLING_COUNT {
ctx.update(|state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
(screen.list)(rsc).fling(-FLING_VELOCITY_PX_S);
(screen.scroll)(rsc).fling(FLING_VELOCITY_PX_S);
animate_scroll(screen.scroll, rsc);
}
});
redraw.request_redraw();
@@ -793,7 +951,8 @@ async fn run_fling_phase(
for _ in 0..FLING_COUNT {
ctx.update(|state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
(screen.list)(rsc).fling(FLING_VELOCITY_PX_S);
(screen.scroll)(rsc).fling(-FLING_VELOCITY_PX_S);
animate_scroll(screen.scroll, rsc);
}
});
redraw.request_redraw();
@@ -802,7 +961,10 @@ async fn run_fling_phase(
}
let end = read_anchor_position(ctx, redraw).await;
format!("start={start} outward={outward} end={end}")
// Says how the fling was advanced, because that is what changed on
// 2026-09-08 and a report from before then is not comparable: the
// phase used to tick the fling itself at ~60Hz.
format!("start={start} outward={outward} end={end} ticked=frame-loop")
}
async fn read_anchor_position(
@@ -816,11 +978,31 @@ async fn read_anchor_position(
.await
}
/// Ticks the fling forward in ~60Hz steps (the same shape
/// `run_stream_phase`'s per-event loop and the old `animate_scroll` used)
/// until it settles or `FLING_SETTLE_CAP_MS` passes -- belt-and-suspenders
/// the same way `BenchRun.kt`'s own `waitForSettle` is, since a fling's
/// own spline-decided `duration()` already caps how long it can run.
/// Register the scroll area with the frame loop, exactly as a finger's own
/// release does (`transcript_ui::Selection::drag`'s `Released` arm) --
/// `Scroll::fling` sets a velocity and drives nothing by itself.
fn animate_scroll(scroll: iris::prelude::WeakWidget<iris::prelude::Scroll>, rsc: &mut Rsc) {
let id = scroll.id();
rsc.ui_mut().animate(id);
}
/// Waits for the fling started above to settle, or for
/// `FLING_SETTLE_CAP_MS` -- belt-and-suspenders the same way
/// `BenchRun.kt`'s own `waitForSettle` is, since a fling's own
/// spline-decided `duration()` already caps how long it can run.
///
/// **It observes; it does not drive.** Until 2026-09-08 this loop called
/// `Scroll::tick` itself every `POLL_MS`, which advanced the
/// fling in 16ms steps -- so on Iris's 120Hz phone every second frame
/// redrew the list at a position it had already drawn, and the benchmark
/// looked distinctly less smooth than the same list under her finger.
/// That is what she reported that day, and it was the rig rather than the
/// renderer: a real fling is ticked once per frame by
/// `UiData::tick_animations`, from the frame callback. So the bench now
/// starts the fling the way a gesture does (`fling` + `UiData::animate`)
/// and polls `is_scrolling` to know when it is over, which makes the
/// phase measure the same path a finger takes. The poll interval is only
/// how often the *question* is asked and has no bearing on the animation.
async fn wait_for_fling_settle(
ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn RequestRedraw>,
@@ -829,14 +1011,14 @@ async fn wait_for_fling_settle(
let started = Instant::now();
while started.elapsed() < cap {
let still_scrolling = read_from_state(ctx, redraw, |state, rsc| match &state.screen {
Some(screen) => (screen.list)(rsc).tick_fling(Instant::now()),
Some(screen) => (screen.scroll)(rsc).is_scrolling(),
None => false,
})
.await;
if !still_scrolling {
return;
}
tokio::time::sleep(Duration::from_millis(ANIM_STEP_MS)).await;
tokio::time::sleep(Duration::from_millis(POLL_MS)).await;
}
}
+211
View File
@@ -0,0 +1,211 @@
//! The JNI half of `DevLogProvider`: reading this process's own log ring
//! for a `ContentProvider` that Dev Updater queries.
//!
//! **Why**: Iris runs these builds on a phone with no `adb`, and Android
//! forbids one app reading another's `logcat`, so nothing outside this
//! process can recover what it wrote. The app already keeps a bounded copy
//! (`client_core::log_ring`); this is how the copy leaves the process. Dev
//! Updater is on the same phone, so handing it over needs no tunnel, no
//! token and no second enrolment -- and it is Dev Updater's own contract
//! rather than something invented here, so any app it delivers can do the
//! same (its `README.md`, "An app's own log").
//!
//! **Everything general stays in `client-core`** (AGENTS.md's sharing
//! rule). What is here is only what Android forces: the JNI boundary and
//! the Java class on the other side of it.
//!
//! Both entry points answer a **flat `String[]`** rather than a row of
//! typed columns. That is the whole of the JNI, and it is one array type
//! instead of three interleaved ones for a payload the provider is about
//! to hand back over binder as a `MatrixCursor` anyway; `DevLogProvider`
//! parses the two numeric fields. Kept flat rather than nested for the
//! same reason -- an array of arrays is four more JNI calls per line.
use android_view::jni::JNIEnv;
use android_view::jni::objects::{JClass, JObject, JString};
use android_view::jni::sys::{jlong, jobjectArray};
use std::sync::OnceLock;
/// How many `String`s each log line occupies in the flat answer:
/// `seq`, `t_ms`, `level`, `target`, `message`, in that order. The Java
/// side has the same constant, and the two are the one place the shape is
/// written down on each side.
///
/// Gated with its one reader: the tabs demo links no `client-core` and so
/// has no ring to lay out, and an ungated constant is a warning in that
/// build (`iris-android-app` without `transcript-screen`).
#[cfg(feature = "transcript-screen")]
const FIELDS_PER_LINE: usize = 5;
/// The authority the provider registered itself under, once it has been
/// created. `None` until then, which is a state worth being able to say:
/// a provider Android never instantiated and one that is answering look
/// the same from inside this process otherwise.
static AUTHORITY: OnceLock<String> = OnceLock::new();
/// Where this app's log can be read from, for the diagnostics pane.
///
/// The provider's own answer rather than one composed from the package
/// name here: what makes the line worth showing is that it names an
/// authority somebody can actually query, and only the provider knows it
/// registered.
#[cfg(feature = "bench")]
pub fn authority() -> Option<&'static str> {
AUTHORITY.get().map(String::as_str)
}
/// `DevLogProvider.nativeReady` -- the provider announcing the authority
/// it registered under and the app's private directory, from its own
/// `onCreate`.
///
/// The directory is taken here as well as in
/// `MainActivity.nativeSetFilesDir` because **the provider is often the
/// only thing running**: once the app has died, Dev Updater's query
/// starts the process for the provider alone, so no activity ever runs
/// and the panic hook's file would never be replayed into the ring. That
/// is precisely the run whose log is being asked for. Whichever of the
/// two arrives first does the replay; `set_crash_dir` deletes the file,
/// so the second finds nothing and says nothing.
///
/// # Safety
/// Called by the JVM with the arguments its `native` declaration names.
#[unsafe(no_mangle)]
pub extern "system" fn Java_dev_iris_android_demo_DevLogProvider_nativeReady(
mut env: JNIEnv,
_class: JClass,
authority: JString,
files_dir: JString,
) {
// Before the authority line, so the previous run's death is above the
// line announcing this one rather than buried under it.
#[cfg(feature = "transcript-screen")]
if let Some(dir) = string_arg(&mut env, &files_dir) {
crate::app_log::set_crash_dir(std::path::Path::new(&dir));
}
#[cfg(not(feature = "transcript-screen"))]
let _ = &files_dir;
let Some(authority) = string_arg(&mut env, &authority) else {
return;
};
log::info!("iris devlog: serving this app's log at content://{authority}");
let _ = AUTHORITY.set(authority);
}
/// One `String` argument, or `None` for a null or unreadable one.
fn string_arg(env: &mut JNIEnv, value: &JString) -> Option<String> {
if value.is_null() {
return None;
}
env.get_string(value).ok().map(Into::into)
}
/// `DevLogProvider.nativeStatus` -- `held`, `dropped`, `newest_seq`, as
/// three strings.
///
/// `newest_seq` is `-1` for a ring nothing has been written to, which is
/// what tells a reader holding a cursor that this process **restarted**:
/// the ring is in memory, so a new process starts again at zero and a
/// stale cursor would otherwise skip everything silently.
///
/// Exported by name rather than registered, matching this crate's other
/// activity-side natives: the mangled name is the whole of what a class
/// this app owns needs.
///
/// # Safety
/// Called by the JVM with the arguments its `native` declaration names.
#[unsafe(no_mangle)]
pub extern "system" fn Java_dev_iris_android_demo_DevLogProvider_nativeStatus(
mut env: JNIEnv,
_class: JClass,
) -> jobjectArray {
string_array(&mut env, &status_fields())
}
/// `DevLogProvider.nativeLinesSince` -- every held line with a sequence at
/// or after `since`, oldest first, [`FIELDS_PER_LINE`] strings each.
///
/// Inclusive of `since` because [`client_core::log_ring::LogRing::since`]
/// is, and one definition of the cursor is what keeps the app's own
/// uploaded report and this provider describing the same lines.
///
/// # Safety
/// Called by the JVM with the arguments its `native` declaration names.
#[unsafe(no_mangle)]
pub extern "system" fn Java_dev_iris_android_demo_DevLogProvider_nativeLinesSince(
mut env: JNIEnv,
_class: JClass,
since: jlong,
) -> jobjectArray {
// A negative cursor is a caller asking for everything, not an error to
// take the app down over: the provider is a diagnostic.
string_array(&mut env, &line_fields(since.max(0) as u64))
}
/// The three status numbers, as the provider's row.
#[cfg(feature = "transcript-screen")]
fn status_fields() -> Vec<String> {
let ring = client_core::log_ring::process_ring();
vec![
ring.len().to_string(),
ring.dropped().to_string(),
ring.newest_seq().map_or(-1, |seq| seq as i64).to_string(),
]
}
/// The tabs demo links no `client-core` and keeps no ring, so it holds
/// nothing and has never dropped anything -- which is the truth, not a
/// stand-in. The natives are still exported there, because a `native`
/// method Java declares and the library does not is an
/// `UnsatisfiedLinkError` the moment the class loads.
#[cfg(not(feature = "transcript-screen"))]
fn status_fields() -> Vec<String> {
vec!["0".to_string(), "0".to_string(), "-1".to_string()]
}
#[cfg(feature = "transcript-screen")]
fn line_fields(since: u64) -> Vec<String> {
let (lines, _next) = client_core::log_ring::process_ring().since(since);
let mut fields = Vec::with_capacity(lines.len() * FIELDS_PER_LINE);
for line in lines {
fields.push(line.seq.to_string());
fields.push(line.at_ms.to_string());
fields.push(line.level.to_string());
fields.push(line.target);
fields.push(line.message);
}
fields
}
#[cfg(not(feature = "transcript-screen"))]
fn line_fields(_since: u64) -> Vec<String> {
Vec::new()
}
/// A Java `String[]` of those, or a null array if the JVM refused one.
///
/// Null rather than a panic across the JNI boundary: `DevLogProvider`
/// reads it as "the provider could not answer" and returns no cursor,
/// which Dev Updater already draws as a distinct state. Taking the app
/// down to report that its diagnostic is unavailable would be worse than
/// the diagnostic being unavailable.
fn string_array(env: &mut JNIEnv, fields: &[String]) -> jobjectArray {
let null = std::ptr::null_mut();
let Ok(class) = env.find_class("java/lang/String") else {
return null;
};
let Ok(array) = env.new_object_array(fields.len() as i32, class, JObject::null()) else {
return null;
};
for (index, field) in fields.iter().enumerate() {
let Ok(value) = env.new_string(field) else {
return null;
};
if env
.set_object_array_element(&array, index as i32, value)
.is_err()
{
return null;
}
}
array.into_raw()
}
+133
View File
@@ -0,0 +1,133 @@
//! Which `ai-server` this app talks to, and how it was told.
//!
//! The parsing, the file and its owner-only mode are
//! `client_core::config` (`EnrolledServer`/`EnrollmentStore`), shared with
//! the desktop app. What is genuinely this platform's, and all that is
//! here, is the intent plumbing: Android hands an `aiapp://enroll?...`
//! link to `MainActivity`, which passes it and the app's private files
//! directory across JNI (see `lib.rs`'s two exported functions).
//!
//! **Why the app is told at runtime rather than at build time.** The APK
//! is cross-compiled in a VM and run against the server on the host, whose
//! CA and token are not this machine's -- so nothing about the destination
//! can be baked in, and no token or CA may sit in a repo or a delivered
//! artifact either way. The CA arrives with the link (`ca` parameter,
//! `wg_app_link::enroll::ca_param`), which is what makes an APK built
//! anywhere able to pin the server it is pointed at.
//!
//! The files directory is process-wide state, which this project otherwise
//! avoids: it arrives from the activity, and `AndroidAppState::new` -- the
//! first thing that wants the enrollment -- has no parameter it could come
//! in through. Same shape, and the same reason, as
//! `client_core::log_ring`'s process ring.
#[cfg(not(feature = "bench"))]
use client_core::api::UreqTransport;
use client_core::config::{EnrolledServer, EnrollmentStore};
use std::path::PathBuf;
use std::sync::OnceLock;
/// `Context.getFilesDir()`, handed over by `MainActivity` before it builds
/// the view. Set once per process; a second call with a different path is
/// a programmer error rather than something to recover from, and a second
/// call with the same one is what a re-created activity does.
static FILES_DIR: OnceLock<PathBuf> = OnceLock::new();
pub fn set_files_dir(dir: PathBuf) {
if let Err(existing) = FILES_DIR.set(dir.clone()) {
assert_eq!(
existing, dir,
"the app's files directory was set twice with different paths"
);
}
}
/// `None` before `MainActivity` has handed the directory over -- which is
/// **not** the same as "not enrolled", and is why [`status`] has a state
/// for it (UI_RULES: design the unknown state first).
fn store() -> Option<EnrollmentStore> {
FILES_DIR.get().map(EnrollmentStore::new)
}
/// What this app has been told, or why it has not been.
pub enum Status {
Enrolled(EnrolledServer),
/// Nothing has been enrolled yet: the ordinary first-run state.
NotEnrolled,
/// The question could not be answered -- the activity never handed a
/// files directory over, or the file is there and unreadable. Kept
/// apart from `NotEnrolled` because the two want different actions
/// from whoever is looking.
Unknown(String),
}
pub fn status() -> Status {
let Some(store) = store() else {
return Status::Unknown("the activity never handed over a files directory".to_string());
};
match store.load() {
Ok(Some(server)) => Status::Enrolled(server),
Ok(None) => Status::NotEnrolled,
Err(error) => Status::Unknown(error.to_string()),
}
}
/// One line for the diagnostics pane. The three states read differently on
/// purpose: "not enrolled" says what to do about it, and "couldn't tell"
/// must not be mistaken for it.
///
/// Only the bench build has a pane to put this in -- same gate, and the
/// same reason, as `app_log::diagnostics_line`. The transcript build says
/// the same things where they matter to it, in the message
/// [`transport`]'s error becomes on screen.
#[cfg(feature = "bench")]
pub fn status_line() -> String {
match status() {
Status::Enrolled(server) => format!("enrolled: {}:{}", server.host, server.port),
Status::NotEnrolled => "not enrolled -- open the enrol link from Dev Updater".to_string(),
Status::Unknown(why) => format!("enrolment unreadable: {why}"),
}
}
/// Parses an `aiapp://enroll?...` link and saves it, replacing whatever
/// was enrolled before -- opening a link is how somebody says "this server
/// now", including after the old one's token was rotated.
///
/// The returned `Err` is the message for a person: this is called from a
/// tap on a link, and a link that did nothing with nothing said is the
/// failure the UI rules are most insistent about.
pub fn apply_link(uri: &str) -> Result<EnrolledServer, String> {
let server = EnrolledServer::parse_link(uri)?;
let store = store().ok_or("the app has no files directory to save an enrollment in")?;
store
.save(&server)
.map_err(|error| format!("couldn't save the enrollment: {error}"))?;
Ok(server)
}
/// A transport for the enrolled server, pinning the CA the link carried.
///
/// Gated to the same builds as `transcript_client`, its only caller: the
/// bench build opens a checked-in fixture and reaches no server, so
/// compiling this into it would be a warning about dead code that is
/// dead on purpose.
///
/// Every failure here is a sentence a screen can show, because there is
/// nowhere else for it to go: this app has no `logcat` on the phone it is
/// built for.
#[cfg(not(feature = "bench"))]
pub fn transport() -> Result<UreqTransport, String> {
let server = match status() {
Status::Enrolled(server) => server,
Status::NotEnrolled => {
return Err("Not enrolled yet -- open the enrol link from Dev Updater.".to_string());
}
Status::Unknown(why) => return Err(format!("Couldn't read the enrollment: {why}")),
};
let ca_pem = server.ca_pem.as_ref().ok_or(
"The enrollment link carried no CA, so there is nothing to pin. \
Enrol again with a link minted by this server.",
)?;
UreqTransport::new(server.base_url(), &server.token, ca_pem.as_bytes())
.map_err(|error| error.message)
}
+103
View File
@@ -40,6 +40,7 @@ use android_view::{
Context, View,
jni::{
JNIEnv, JavaVM,
objects::{JClass, JString},
sys::{JNI_VERSION_1_6, JavaVM as RawJavaVM, jint, jlong},
},
register_view_class,
@@ -51,10 +52,25 @@ use iris::prelude::*;
use log::LevelFilter;
use std::ffi::c_void;
/// The app's own log ring and its upload -- only where `client-core` is
/// linked, which is every build that has a server to send to. The plain
/// tabs demo keeps `android_logger` alone, as it always had.
#[cfg(feature = "transcript-screen")]
mod app_log;
#[cfg(feature = "bench")]
mod bench_client;
#[cfg(feature = "bench")]
mod bench_jni;
/// This app's log ring, handed to Dev Updater on the phone through a
/// `ContentProvider`. Declared in every build for the reason the module
/// gives: the Java class is in the manifest either way, and a `native`
/// method the library does not export fails the class load.
mod devlog;
/// Which server this app talks to, told to it at runtime by an
/// `aiapp://enroll` link. Only where `client-core` is linked -- the plain
/// tabs demo makes no network call and has nothing to enrol against.
#[cfg(feature = "transcript-screen")]
mod enrollment;
#[cfg(all(feature = "transcript-screen", not(feature = "bench")))]
mod transcript_client;
@@ -119,6 +135,13 @@ extern "system" fn new_view_peer<'local>(
/// mirrors android-view's own demo, which carries the same comment.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn JNI_OnLoad(vm: *mut RawJavaVM, _: *mut c_void) -> jint {
// The ring in front of `android_logger` where there is one (see
// `app_log`), and `android_logger` alone otherwise. Both install the
// same tag and level, so `logcat` cannot tell the two builds apart --
// the ring only adds a second reader.
#[cfg(feature = "transcript-screen")]
app_log::install(LevelFilter::Debug);
#[cfg(not(feature = "transcript-screen"))]
android_logger::init_once(
android_logger::Config::default()
.with_max_level(LevelFilter::Debug)
@@ -130,3 +153,83 @@ pub unsafe extern "system" fn JNI_OnLoad(vm: *mut RawJavaVM, _: *mut c_void) ->
iris::android::register_native_methods(&mut env, VIEW_CLASS);
JNI_VERSION_1_6
}
/// `MainActivity.nativeSetFilesDir` -- the app's private directory, handed
/// over before the view exists because that is where the enrollment is
/// read from and written to (`enrollment`'s module doc).
///
/// Exported by name rather than registered through `RegisterNatives`: the
/// view's methods are registered because `android-view` owns that class
/// and hands out one function pointer, whereas these two are this app's
/// own activity and the mangled name is the whole of what is needed.
///
/// Declared in every build, including the tabs demo that has no
/// `client-core` to store anything -- a `native` method Java declares and
/// the library does not export is an `UnsatisfiedLinkError` when the class
/// loads, which would take down a build that merely shares the activity.
///
/// # Safety
/// Called by the JVM with the arguments its `native` declaration names.
#[unsafe(no_mangle)]
pub extern "system" fn Java_dev_iris_android_demo_MainActivity_nativeSetFilesDir(
mut env: JNIEnv,
_class: JClass,
dir: JString,
) {
let Some(dir) = jstring(&mut env, dir) else {
return;
};
#[cfg(feature = "transcript-screen")]
{
app_log::set_crash_dir(std::path::Path::new(&dir));
enrollment::set_files_dir(std::path::PathBuf::from(&dir));
}
log::debug!("iris app: files directory is {dir}");
}
/// `MainActivity.nativeEnroll` -- one `aiapp://enroll?...` link, from the
/// VIEW intent that started or resumed the activity.
///
/// Logged either way rather than answered: the activity has nothing to do
/// with the result, and where the enrollment shows up is the diagnostics
/// pane (`enrollment::status_line`), which reads the stored answer rather
/// than being told it.
///
/// # Safety
/// Called by the JVM with the arguments its `native` declaration names.
#[unsafe(no_mangle)]
pub extern "system" fn Java_dev_iris_android_demo_MainActivity_nativeEnroll(
mut env: JNIEnv,
_class: JClass,
uri: JString,
) {
let Some(uri) = jstring(&mut env, uri) else {
return;
};
#[cfg(feature = "transcript-screen")]
match enrollment::apply_link(&uri) {
// Never the token: `wg-app-link`'s enroll module forbids logging
// it, and this line would otherwise be the one place it leaked.
Ok(server) => log::info!("iris app: enrolled with {}:{}", server.host, server.port),
Err(error) => log::warn!("iris app: that enrolment link was refused -- {error}"),
}
#[cfg(not(feature = "transcript-screen"))]
log::warn!("iris app: {uri} arrived, but this build has no server to enrol with");
}
/// A `JString` as a Rust `String`, or `None` for a null or non-UTF-8 one --
/// neither is worth taking the app down for, and both are logged where
/// they happen.
fn jstring(env: &mut JNIEnv, value: JString) -> Option<String> {
if value.is_null() {
log::warn!("iris app: the activity passed a null string across JNI");
return None;
}
match env.get_string(&value) {
Ok(value) => Some(value.into()),
Err(error) => {
log::warn!("iris app: couldn't read a string from the activity -- {error}");
None
}
}
}
+18 -24
View File
@@ -7,15 +7,15 @@
//!
//! **Deliberate simplification, recorded rather than left to be
//! rediscovered (RUST.md's I5 box has the full account)**: there is no
//! session list and no enrollment UI here. The server, port, token and
//! pinned CA are baked in at build time (`build.rs`'s
//! `AI_APP_TRANSCRIPT_HOST`/`_PORT`/`_TOKEN`/`AI_APP_CA`), and the first
//! session `ApiClient::fetch_sessions` returns is opened automatically --
//! there is nothing to tap to get there, which is what `transcript-bench.sh`
//! and `ui-trace` need to land straight on the screen under test. A real
//! app needs `desktop-app`'s `EnrolledServer`/QR-link flow or E3's
//! Keystore-sealed `ServerConfig.kt`; building a second one of those was
//! not this pass's job.
//! session list here -- the first session `ApiClient::fetch_sessions`
//! returns is opened automatically, since there is nothing to tap to get
//! there, which is what `transcript-bench.sh` and `ui-trace` need to land
//! straight on the screen under test.
//!
//! Which server it opens it against is no longer baked in: it is the
//! enrollment an `aiapp://enroll` link left behind (`crate::enrollment`,
//! and `desktop-app`'s identical `--link`), because an APK
//! cross-compiled here cannot pin the CA of a server on the host.
//!
//! **Reuses `iris/desktop-app`'s `app.rs` shape almost exactly** --
//! `fold_event`/`group_tool_runs`/`fold_page`/`raw_seq` from
@@ -46,10 +46,6 @@ use iris::prelude::*;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
mod pinned {
include!(concat!(env!("OUT_DIR"), "/pinned_config.rs"));
}
pub struct TranscriptClient {
ui_state: AndroidUiState,
/// The screen's own content -- everything under the fixed
@@ -85,18 +81,16 @@ impl HasAndroidUiState for TranscriptClient {
}
}
/// Builds one `UreqTransport` from the config `build.rs` baked in. Called
/// twice per session load, same as `desktop-app`'s `build_transport`
/// closure -- `ApiClient` and the live-stream follow each need their own,
/// since `UreqTransport` holds its own `ureq::Agent`.
/// Builds one `UreqTransport` from the stored enrollment. Called twice per
/// session load, same as `desktop-app`'s `build_transport` closure --
/// `ApiClient` and the live-stream follow each need their own, since
/// `UreqTransport` holds its own `ureq::Agent`.
///
/// Read afresh each time rather than held: opening a new enrolment link
/// while the app is running is how somebody points it at another server,
/// and a cached transport would keep talking to the old one.
fn build_transport() -> Result<UreqTransport, String> {
let base_url = format!("https://{}:{}", pinned::HOST, pinned::PORT);
UreqTransport::new(
base_url,
pinned::TOKEN.to_string(),
pinned::CA_PEM.as_bytes(),
)
.map_err(|e| e.to_string())
crate::enrollment::transport()
}
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
+5 -1
View File
@@ -130,7 +130,11 @@ if __name__ == "__main__":
# 2.55 is Iris's Pixel 9 Pro XL (docs/bench/iris-phone-v2-2026-09-06.md);
# 2.75 is this checkout's emulator.
for density in (2.55, 2.75):
for velocity in (5000.0, 11064.0):
# 15250 is `transcript-fixture/touch/flick-120hz.touch`'s own
# release velocity (velocity_reference.py), so `phone_screen.rs`
# can bound the fling it produces from *here* rather than from the
# `FlingCalculator` under test (docs/REVIEW-2026-09-07.md's T1).
for velocity in (5000.0, 11064.0, 15250.0):
dur = fling_duration_s(velocity, density)
print(
f"density={density} v={velocity}: "
+32 -24
View File
@@ -14,11 +14,11 @@
//! -- and it avoids a new dependency this crate does not otherwise need.
//! Per the code rules, the plain option is also the one shorter to explain.
//!
//! **The list under test is `iris::widget::List` (RUST.md's I3), not a
//! **The list under test is `iris::widget::LazySpan` (RUST.md's I3), not a
//! `Scroll` over a `Span` of pre-built rows.** Earlier versions of this
//! file built their own giant `Span` and wrapped it in `Scroll`, which
//! meant (a)/(b)/(c) below were measuring "move one big child," never the
//! virtualised widget the app's transcript screen actually needs. `List`
//! virtualised widget the app's transcript screen actually needs. `LazySpan`
//! still needs every row's *widget* built up front by the caller (its
//! module doc explains why: it only ever sees `&dyn Widget` through
//! `Painter`, so it cannot construct a row lazily on its own) -- what
@@ -26,7 +26,7 @@
//! *drawn*, which is what the draw/rewrite/move counters below are
//! measuring, not construction time.
//!
//! Scenarios (LAYOUT.md's O(1) move chain, list.rs's module doc, and
//! Scenarios (LAYOUT.md's O(1) move chain, lazy_span.rs's module doc, and
//! IRIS_TODO.md's "Benchmarks" wording):
//!
//! - (a) first-frame cost of a message list of N wrapped-text rows, some
@@ -41,11 +41,11 @@
//! Reports frame time *and* the draw/rewrite/move counters LAYOUT.md
//! section 8 defines.
//! - (d) insert-above-anchor: paging older history onto the front of an
//! already-scrolled list. `List::push_front` is an O(1) index update
//! (list.rs's module doc); this measures that none of the rows already
//! already-scrolled list. `LazySpan::push_front` is an O(1) index update
//! (lazy_span.rs's module doc); this measures that none of the rows already
//! on screen are touched by it.
//! - (e) expand-a-row-holding-its-edge: growing one row's height with a
//! tap recorded near one of its edges (list.rs's `note_tap`) must move
//! tap recorded near one of its edges (lazy_span.rs's `note_tap`) must move
//! only the rows on the far side of it, never redraw the ones already
//! correctly placed.
//!
@@ -106,21 +106,29 @@ fn build_row(rsc: &mut BenchRsc, i: usize, image_every: usize) -> StrongWidget {
}
}
/// A virtualised `List` of `n` message rows, one in `image_every` of them
/// A virtualised `LazySpan` of `n` message rows, one in `image_every` of them
/// carrying an image (0 disables images entirely). Returns the list widget
/// (weak, so the caller can drive it) and the erased root to render.
fn build_message_list(
rsc: &mut BenchRsc,
n: usize,
image_every: usize,
) -> (WeakWidget<List>, StrongWidget) {
let mut list = List::new(Axis::Y);
) -> (WeakWidget<LazySpan>, WeakWidget<Scroll>, StrongWidget) {
let mut list = LazySpan::new(Dir::DOWN, true);
for i in 0..n {
let row = build_row(rsc, i, image_every);
list.push_back(ListRow::new(i as u64, row));
list.push_back(LazyItem::new(i as u64, row));
}
let list = rsc.ui.widgets.add_strong(list);
(list.weak(), list.any())
let list_weak = list.weak();
// Scrolled through a `Scroll`, like every other scroll area in iris
// since the position moved out of the list: what this measures has to
// be the path the app actually takes.
let scroll = rsc
.ui
.widgets
.add_strong(Scroll::new(list.any(), Axis::Y, true));
(list_weak, scroll.weak(), scroll.any())
}
fn report(label: &str, elapsed: std::time::Duration, draws: u64, rewrites: u64, moves: u64) {
@@ -135,7 +143,7 @@ fn bench_first_frame(n: usize) {
let mut rsc = BenchRsc {
ui: UiData::default(),
};
let (_list, root) = build_message_list(&mut rsc, n, 20);
let (_list, _scroll, root) = build_message_list(&mut rsc, n, 20);
let mut render = UiRenderState::new();
render.resize((1080.0, 2000.0));
@@ -160,11 +168,11 @@ fn bench_scroll(n: usize, ticks: usize) {
let mut rsc = BenchRsc {
ui: UiData::default(),
};
let (list, root) = build_message_list(&mut rsc, n, 20);
let (_list, scroll, root) = build_message_list(&mut rsc, n, 20);
let mut render = UiRenderState::new();
render.resize((1080.0, 2000.0));
render.update(&root, &mut rsc);
rsc.ui.widgets.get_mut(&list).unwrap().scroll(0.0);
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
render.update(&root, &mut rsc);
render.take_counters();
@@ -173,7 +181,7 @@ fn bench_scroll(n: usize, ticks: usize) {
let mut total_rewrites = 0u64;
let mut total_moves = 0u64;
for _ in 0..ticks {
rsc.ui.widgets.get_mut(&list).unwrap().scroll(-8.0);
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-8.0);
let start = Instant::now();
render.update(&root, &mut rsc);
total += start.elapsed();
@@ -207,7 +215,7 @@ fn bench_input_grows(n: usize, lines: usize) {
let mut rsc = BenchRsc {
ui: UiData::default(),
};
let (list, list_root) = build_message_list(&mut rsc, n, 20);
let (_list, scroll, list_root) = build_message_list(&mut rsc, n, 20);
let list_area = rsc.ui.widgets.add_strong(Sized {
inner: list_root,
x: None,
@@ -231,7 +239,7 @@ fn bench_input_grows(n: usize, lines: usize) {
let mut render = UiRenderState::new();
render.resize((1080.0, 2000.0));
render.update(&root, &mut rsc);
rsc.ui.widgets.get_mut(&list).unwrap().scroll(0.0);
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
render.update(&root, &mut rsc);
render.take_counters();
@@ -270,14 +278,14 @@ fn bench_input_grows(n: usize, lines: usize) {
/// row (`jump_to_start`, an O(1) re-anchor) rather than left at the default
/// bottom, so a row prepended above it is genuinely "inserted above the
/// anchor" rather than merely far off-screen at the far end. Each
/// `push_front` is O(1) (list.rs's module doc: the anchor's slot is an
/// `push_front` is O(1) (lazy_span.rs's module doc: the anchor's slot is an
/// index, bumped by one) and, since the prepended rows never enter the
/// viewport, none of them should cost a draw either.
fn bench_insert_above_anchor(n: usize, inserts: usize) {
let mut rsc = BenchRsc {
ui: UiData::default(),
};
let (list, root) = build_message_list(&mut rsc, n, 20);
let (list, _scroll, root) = build_message_list(&mut rsc, n, 20);
let mut render = UiRenderState::new();
render.resize((1080.0, 2000.0));
render.update(&root, &mut rsc);
@@ -298,7 +306,7 @@ fn bench_insert_above_anchor(n: usize, inserts: usize) {
.widgets
.get_mut(&list)
.unwrap()
.push_front(ListRow::new(i as u64, row));
.push_front(LazyItem::new(i as u64, row));
let start = Instant::now();
render.update(&root, &mut rsc);
total += start.elapsed();
@@ -325,7 +333,7 @@ fn bench_insert_above_anchor(n: usize, inserts: usize) {
/// (e) Expand-a-row-holding-its-edge: one row (fixed-height, so its size is
/// directly controllable) is grown a little at a time, each time preceded
/// by `note_tap` aimed at its own top edge -- the exact mechanism list.rs's
/// by `note_tap` aimed at its own top edge -- the exact mechanism lazy_span.rs's
/// module doc describes and its unit tests check for correctness. This
/// measures its *cost*: only the rows on the far side of the grown one
/// (below it, since the top edge is held) should ever move, and nothing
@@ -334,7 +342,7 @@ fn bench_expand_holds_edge(n: usize, growths: usize) {
let mut rsc = BenchRsc {
ui: UiData::default(),
};
let mut list = List::new(Axis::Y);
let mut list = LazySpan::new(Dir::DOWN, true);
// Near the end (not the very last row) so it is already on screen
// under the list's default bottom-anchored placement, for every N --
// no scrolling needed to bring it into view before measuring.
@@ -349,10 +357,10 @@ fn bench_expand_holds_edge(n: usize, growths: usize) {
y: Some(abs(40.0)),
});
growable = Some(sized.weak());
list.push_back(ListRow::new(i as u64, sized.any()));
list.push_back(LazyItem::new(i as u64, sized.any()));
} else {
let row = build_row(&mut rsc, i, 20);
list.push_back(ListRow::new(i as u64, row));
list.push_back(LazyItem::new(i as u64, row));
}
}
let list = rsc.ui.widgets.add_strong(list);
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Turns `iris::input` debug lines -- from a phone's diagnostics report, or
from a report the layer-1 harness produced with tracing on
(`iris::diagnostics::set_trace(true)`) -- back into a `TouchScript` file
`iris::harness::Harness::replay` can play back at layer 1.
Why this exists: `docs/RUST.md`'s "Three test layers" box says the cheapest
layer that can answer a question wins, and a gesture that misbehaves on
Iris's phone is otherwise only describable in words. `iris::sense::
log_input_event`'s one line per platform event (Android's on_touch_event
once per `MotionEvent`, with historical samples inline; winit's once per
pointer `WindowEvent`; the harness's `touch`, once per script line) already
carries everything a `.touch` file's `t_ms action x y` needs -- this just
reads it back out and reconstructs the samples in order, expanding each
event's inline historical samples into their own `move` lines first (they
are always intermediate positions of a move, and Android documents them as
oldest first, which is also the order they appear in the line).
Usage:
report_to_touch.py < report.txt > replay.touch
report_to_touch.py report.txt > replay.touch
Only lines containing "iris input: action=..." are read; everything else in
the report (insets, frame timings, drag-release summaries) is ignored, so
this can be pointed at Copy report's whole clipboard text directly.
"""
import re
import sys
# The message half of `sense::log_input_event`'s format string, prefix-
# agnostic: a real report line also carries the ring's own
# `HH:MM:SS.mmm LEVEL target:` header (`LogLine::format`) or, forwarded
# through `ai_server::client_log`, a `[<source> <clock> #<seq>]` tag ahead
# of that -- neither of which this needs to understand, since `search`
# (not `match`) finds the marker wherever it starts.
LINE_RE = re.compile(
r"iris input: action=(?P<action>\w+) x=(?P<x>-?[0-9.]+) y=(?P<y>-?[0-9.]+) "
r"t=(?P<t>[0-9]+)ms history=(?P<hist>[0-9]+)(?P<rest>.*)$"
)
# One historical sample inside `rest`: `t:x,y`, space-separated, oldest first
# -- see `log_input_event`'s own doc for why order matters.
HIST_RE = re.compile(r"(?P<t>[0-9]+):(?P<x>-?[0-9.]+),(?P<y>-?[0-9.]+)")
def _fmt(value: float) -> str:
"""The number as `TouchScript::parse`'s own `f32::parse` would round-trip
it -- an integer without a trailing `.0` where the source was one
(every coordinate here is a physical pixel), `{:g}` otherwise so a
fractional value from a real device is not silently truncated."""
if value == int(value):
return str(int(value))
return f"{value:g}"
def convert(lines):
"""Every `iris::input` line, oldest first, expanded to one `(t_ms,
action, x, y)` tuple per touch sample -- a historical sample is always
an intermediate `move`, and the event's own sample keeps its real
action (`down`/`move`/`up`/`cancel`)."""
rows = []
for line in lines:
m = LINE_RE.search(line)
if not m:
continue
hist_count = int(m.group("hist"))
hist_matches = list(HIST_RE.finditer(m.group("rest")))
if len(hist_matches) != hist_count:
print(
f"report_to_touch: {line.strip()!r} says history={hist_count} but "
f"holds {len(hist_matches)} samples -- skipped",
file=sys.stderr,
)
continue
for hm in hist_matches:
rows.append(
(int(hm.group("t")), "move", float(hm.group("x")), float(hm.group("y")))
)
rows.append(
(int(m.group("t")), m.group("action"), float(m.group("x")), float(m.group("y")))
)
return rows
def main():
if len(sys.argv) > 2:
print("usage: report_to_touch.py [report.txt] < report.txt", file=sys.stderr)
return 2
text = open(sys.argv[1]) if len(sys.argv) == 2 else sys.stdin
for t_ms, action, x, y in convert(text):
print(f"{t_ms} {action} {_fmt(x)} {_fmt(y)}")
return 0
if __name__ == "__main__":
sys.exit(main())
+298
View File
@@ -0,0 +1,298 @@
#!/usr/bin/env python3
"""Compose's touch velocity tracker, transcribed independently of the Rust port.
Same reason `fling_spline_reference.py` exists: the numbers checked into
`sense.rs`'s velocity tests must not be numbers the Rust produced. The old
estimator -- total motion over the sample span, an average -- passed every test
it had, because every one of those tests asserted the average's own definition
back at it. An average cannot tell an accelerating flick from a steady drag, and
that is exactly what Iris reported from the phone on 2026-09-07: "flinging now
actually works but is slower than Compose's immediately after releasing the
flick".
Transcribed by hand from, and only from, the `-sources.jar` of
**androidx.compose.ui:ui-android:1.12.0** and
**androidx.compose.foundation:foundation-android:1.12.0**
(dl.google.com/dl/android/maven2), read 2026-09-07:
* `androidx/compose/ui/input/pointer/util/VelocityTracker.kt` --
`VelocityTracker1D.calculateVelocity`, `polyFitLeastSquares`,
`calculateImpulseVelocity`, `kineticEnergyToVelocity`, and the constants
`HistorySize = 20`, `HorizonMilliseconds = 100`,
`AssumePointerMoveStoppedMilliseconds = 40`.
* `androidx/compose/ui/input/pointer/util/PlatformVelocityTracker.kt` --
`Lsq2VelocityTracker`, which is what the 2D `VelocityTracker` delegates to.
* `androidx/compose/ui/input/pointer/util/PlatformVelocityTracker.android.kt`
-- the `AndroidComposeUiFlags.isFrameworkVelocityTrackerEnabled` fork.
* `androidx/compose/ui/AndroidComposeUiFlags.android.kt` -- that flag's
default, which is `false`.
* `androidx/compose/foundation/gestures/Draggable.kt` -- `sendDragStart` /
`sendDragEvent` / `sendDragStopped`, i.e. *which* samples a touch drag
feeds the tracker and where the maximum-velocity clamp is applied.
* `androidx/compose/foundation/gestures/DifferentialVelocityTracker.kt` and
`NonTouchScrollingLogic.kt` -- the Impulse strategy's only caller.
* `androidx/compose/foundation/gestures/Scrollable.kt` --
`DefaultFlingBehavior.performFling`, for the minimum-velocity question.
**Which strategy a touch fling actually uses, since this was the surprise.**
`Strategy.Impulse` is *not* it. `scrollable`/`draggable` release through
`DragGestureNode.sendDragStopped`, which calls the 2D `VelocityTracker`; on
Android that is `Lsq2VelocityTracker` (the framework-tracker flag defaults to
false), which is two `VelocityTracker1D(strategy = Lsq2)` -- a degree-2
least-squares fit over **absolute positions**, whose velocity is the fitted
polynomial's derivative at the newest sample. Impulse is reached only through
`DifferentialVelocityTracker`, whose sole caller is `NonTouchScrollingLogic`:
mouse wheel and trackpad, never a finger. So this script transcribes Lsq2 and
iris ports Lsq2. `calculate_impulse_velocity` is here anyway, unused by the
printed points, because ruling it out by reading is cheaper than ruling it out
again next time somebody remembers "Compose uses impulse".
**Which samples a touch drag feeds it.** `sendDragStart` adds the DOWN change;
every subsequent MOVE, historical samples included, is added by `sendDragEvent`.
The **UP position is never added**: `Lsq2VelocityTracker.addPointerInputChange`
wraps its two `addPosition` calls in `if (!event.changedToUpIgnoreConsumed())`,
and all the UP branch does is reset the tracker when more than 40ms have passed
since the last MOVE (b/238654963). So a finger that stops before lifting reads
as a stop, not as a decelerating tail. Positions are the raw event positions,
so the touch slop is inside the motion the tracker sees even though the list
never scrolled by it.
Two of Compose's samples iris does *not* reproduce, both noted rather than
copied: pre-slop MOVEs (iris's `DragArbiter` is `Undecided` then too, so it
feeds none either -- these agree), and the single MOVE that *crosses* the slop,
which Compose drops because `sendDragStart` adds only the DOWN. iris feeds that
one, since it is a real measured position and dropping it would be copying a
quirk of where Compose happens to split its state machine.
**The clamps.** Maximum: `sendDragStopped` passes
`LocalViewConfiguration.maximumFlingVelocity`, which on Android is
`ViewConfiguration.getScaledMaximumFlingVelocity()` -- 8000 dp/s. Minimum:
there is **none** on this path. `ViewConfiguration.minimumFlingVelocity`
exists in Compose's `ViewConfiguration` interface but its only use in either
artifact is `NestedScrollInteropConnection`, for View interop.
`DefaultFlingBehavior.performFling` guards with `abs(initialVelocity) > 1f`
and says why in its own comment: "we need it since spline curve gives us
NaNs". 1 px/s, not 50 dp/s.
Run it with no arguments; it prints the sample sets and the velocities the
Rust tests assert on.
"""
import math
HISTORY_SIZE = 20
HORIZON_MILLISECONDS = 100.0
ASSUME_POINTER_MOVE_STOPPED_MILLISECONDS = 40.0
MIN_SAMPLE_SIZE_LSQ2 = 3
# ViewConfiguration.getScaledMaximumFlingVelocity(), in dp/s.
MAXIMUM_FLING_VELOCITY_DP_S = 8000.0
# DefaultFlingBehavior.performFling's own threshold, in the units of the
# positions fed to the tracker -- pixels per second here.
FLING_MINIMUM_PX_S = 1.0
def poly_fit_least_squares(x, y, sample_count, degree):
"""`polyFitLeastSquares`: Gram-Schmidt QR, coefficients low order first."""
if degree < 1:
raise ValueError("The degree must be at positive integer")
if sample_count == 0:
raise ValueError("At least one point must be provided")
truncated_degree = sample_count - 1 if degree >= sample_count else degree
m = sample_count
n = truncated_degree + 1
# a[i][h] = x[h]**i, pre-multiplied by the (always 1.0) weight.
a = [[0.0] * m for _ in range(n)]
for h in range(m):
a[0][h] = 1.0
for i in range(1, n):
a[i][h] = a[i - 1][h] * x[h]
q = [[0.0] * m for _ in range(n)]
r = [[0.0] * n for _ in range(n)]
for j in range(n):
w = q[j]
w[:] = a[j][:m]
for i in range(j):
z = q[i]
dot = sum(w[h] * z[h] for h in range(m))
for h in range(m):
w[h] -= dot * z[h]
norm = math.sqrt(sum(v * v for v in w))
inverse_norm = 1.0 / max(norm, 1e-6)
for h in range(m):
w[h] *= inverse_norm
for i in range(n):
r[j][i] = 0.0 if i < j else sum(w[h] * a[i][h] for h in range(m))
coefficients = [0.0] * n
for i in range(n - 1, -1, -1):
c = sum(q[i][h] * y[h] for h in range(m))
for j in range(n - 1, i, -1):
c -= r[i][j] * coefficients[j]
coefficients[i] = c / r[i][i]
return coefficients
def kinetic_energy_to_velocity(kinetic_energy):
sign = 0.0 if kinetic_energy == 0.0 else math.copysign(1.0, kinetic_energy)
return sign * math.sqrt(2 * abs(kinetic_energy))
def calculate_impulse_velocity(data_points, time, sample_count, is_data_differential):
"""`calculateImpulseVelocity` -- not on the touch path; see the module doc."""
work = 0.0
start = sample_count - 1
next_time = time[start]
for i in range(start, 0, -1):
current_time = next_time
next_time = time[i - 1]
if current_time == next_time:
continue
if is_data_differential:
delta = -data_points[i - 1]
else:
delta = data_points[i] - data_points[i - 1]
v_curr = delta / (current_time - next_time)
v_prev = kinetic_energy_to_velocity(work)
work += (v_curr - v_prev) * abs(v_curr)
if i == start:
work = work * 0.5
return kinetic_energy_to_velocity(work)
def calculate_velocity(samples):
"""`VelocityTracker1D.calculateVelocity` with `Strategy.Lsq2`.
`samples` is `(time_millis, position)` oldest first, at most the last
`HISTORY_SIZE` of which the circular buffer would still be holding.
Returns units per second.
"""
held = samples[-HISTORY_SIZE:]
if not held:
return 0.0
data_points = []
time = []
newest_time, _ = held[-1]
previous_time = newest_time
for sample_time, sample_position in reversed(held):
age = float(newest_time - sample_time)
delta = abs(float(sample_time - previous_time))
# Lsq2 walks back sample to sample; only the non-differential
# Impulse branch compares every sample against the newest one.
previous_time = sample_time
if age > HORIZON_MILLISECONDS or delta > ASSUME_POINTER_MOVE_STOPPED_MILLISECONDS:
break
data_points.append(sample_position)
time.append(-age)
if len(data_points) == HISTORY_SIZE:
break
if len(data_points) < MIN_SAMPLE_SIZE_LSQ2:
return 0.0
try:
coefficients = poly_fit_least_squares(time, data_points, len(data_points), 2)
except ValueError:
return 0.0
# The 2nd coefficient is the fitted polynomial's derivative at x = 0,
# which is the newest sample's timestamp. units/ms -> units/s.
return coefficients[1] * 1000.0
def clamped(velocity, maximum):
"""`VelocityTracker1D.calculateVelocity(maximumVelocity)`."""
if velocity == 0.0 or math.isnan(velocity):
return 0.0
return min(velocity, maximum) if velocity > 0 else max(velocity, -maximum)
def average(samples):
"""The estimator being replaced: total motion over the span."""
if len(samples) < 2:
return 0.0
span = (samples[-1][0] - samples[0][0]) / 1000.0
if span <= 0.0:
return 0.0
return (samples[-1][1] - samples[0][1]) / span
# --- The three recorded sample sets the Rust tests assert on. ----------------
# 1. `transcript-fixture/touch/flick-120hz.touch`, as `DragGesture` feeds it:
# the DOWN position, then one position per MOVE. The UP at t=20 adds no
# sample (see the module doc), which is why the finger sitting still for its
# last 4ms does not drag the estimate down. y only; the flick is vertical.
FLICK_120HZ = [(0, 1000.0), (4, 1040.0), (8, 1086.0), (12, 1138.0), (16, 1196.0)]
# 2. A steady drag: 5px every 10ms for 100ms. A constant-velocity fit and an
# average must agree here -- this is the case that cannot tell the two
# estimators apart, which is why it is not the only one.
STEADY_DRAG = [(i * 10, float(i * 5)) for i in range(11)]
# 3. A flick that accelerates into the release: 10ms apart, deltas doubling.
# This is the case the average gets wrong, and the negative control for
# the port -- reverting to the average must fail this test and only this
# kind of test.
ACCELERATING_FLICK = [(0, 0.0), (10, 2.0), (20, 6.0), (30, 14.0), (40, 30.0), (50, 54.0)]
# 4. The two edges of the sample walk, checked here so the Rust asserts
# Compose's answer rather than iris's own reading of the rule.
# (a) An old, fast burst outside the 100ms horizon, then a slow steady
# drag: the burst must not leak into the estimate.
OLD_BURST_THEN_STEADY = [(0, 0.0)] + [(10 + i * 10, 1000.0 + i) for i in range(11)]
# (b) The finger stops for 48ms and then lifts. The gap exceeds
# AssumePointerMoveStopped, so the walk breaks after one sample and
# there is no fling -- what stops a "park it and let go" from
# flinging at whatever speed the finger arrived with.
STOPPED_BEFORE_RELEASE = [(0, 0.0), (4, 40.0), (8, 90.0), (12, 150.0), (60, 152.0)]
# 5. `sense.rs`'s own `drag_gesture_tests`: what `DragGesture` feeds for a
# press and two move frames, which is the fewest a fit can use.
TWO_MOVE_FRAMES = [(0, 0.0), (8, 100.0), (16, 220.0)]
# ... and one move frame, which Compose cannot fit either.
ONE_MOVE_FRAME = [(0, 0.0), (8, 100.0)]
# The phone: 1080x2424 at content_scale 2.55.
PHONE_DENSITY = 2.55
def report(name, samples):
v = calculate_velocity(samples)
print(f"{name}:")
print(f" samples (t_ms, position): {samples}")
print(f" Lsq2 (Compose's touch path): {v:.4f} px/s")
print(f" average (the old estimator): {average(samples):.4f} px/s")
print(f" impulse (non-touch, for ref): ", end="")
held = list(reversed(samples[-HISTORY_SIZE:]))
newest = held[0][0]
print(
f"{calculate_impulse_velocity([p for _, p in held], [-(newest - t) for t, _ in held], len(held), False) * 1000.0:.4f} px/s"
)
print()
if __name__ == "__main__":
print("Compose 1.12.0 touch velocity: VelocityTracker1D, Strategy.Lsq2,")
print("non-differential (positions), HistorySize=20, Horizon=100ms,")
print("AssumePointerMoveStopped=40ms, minSampleSize=3.\n")
report("flick-120hz.touch", FLICK_120HZ)
report("steady drag (5px/10ms)", STEADY_DRAG)
report("accelerating flick (deltas 2,4,8,16,24 per 10ms)", ACCELERATING_FLICK)
report("old burst then steady 1px/10ms", OLD_BURST_THEN_STEADY)
report("stopped 48ms before release", STOPPED_BEFORE_RELEASE)
report("press and two move frames", TWO_MOVE_FRAMES)
report("press and one move frame", ONE_MOVE_FRAME)
print("Clamps:")
print(f" maximum: {MAXIMUM_FLING_VELOCITY_DP_S} dp/s")
print(
f" = {MAXIMUM_FLING_VELOCITY_DP_S * PHONE_DENSITY:.1f} px/s at the phone's density {PHONE_DENSITY}"
)
print(f" minimum: none on the fling path; DefaultFlingBehavior skips |v| <= {FLING_MINIMUM_PX_S} px/s")
print()
print("Two samples only (a press and one move, the phone's 120Hz worst case):")
print(f" Lsq2 needs 3 and answers {calculate_velocity(FLICK_120HZ[:2]):.4f} px/s")
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014 Ryan L McIntyre
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-201
View File
@@ -1,201 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Binary file not shown.
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# Rebuilds iris/core/assets/fonts/nerd_icons.ttf.
#
# iris draws its icons as glyphs in a Nerd Fonts subset it ships, rather
# than as ordinary Unicode out of whatever the platform resolved. Unicode's
# own geometric shapes are what this replaced: `tool.rs` set its disclosure
# mark with U+25B8/25BE/25B4, and once iris stopped bundling fonts
# (2026-09-07) Iris's phone drew an empty box for them and this VM drew a
# dot. UI_RULES: "don't rely on characters the platform might not have --
# ship the glyph or the asset rather than hoping."
#
# The whole symbols font is 3 MB for the handful below, so what is
# committed is a subset. Add a codepoint to GLYPHS *and* to
# `iris/core/src/icon.rs` (the two lists have to agree -- a codepoint in
# the Rust that this script did not subset is a glyph that silently isn't
# there), then run this and commit the result.
#
# Needs python3 and network access; fontTools is fetched into a temporary
# venv, so nothing has to be installed on the machine.
#
# The same arrangement as the Compose app's `app/build-icon-font.sh`, which
# this is copied from -- including the Mono face and the Material Design
# family, so an icon means the same thing in both apps. Copied rather than
# shared because most of it is the GLYPHS list, which has to differ: the
# point of subsetting is to ship only the codepoints one app draws.
set -euo pipefail
# Codepoint, then the Nerd Fonts glyph name it came from. Material Design
# Icons, as in the Compose app.
GLYPHS=(
U+F035D # md-menu_down -- a card that is open
U+F035F # md-menu_right -- a card that opens
U+F0360 # md-menu_up -- collapse this group again
)
url=https://github.com/ryanoasis/nerd-fonts/releases/latest/download/NerdFontsSymbolsOnly.zip
here="$(cd "$(dirname "$0")" && pwd)"
out="$here/assets/fonts/nerd_icons.ttf"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
echo "Fetching $url"
curl -fsSL -o "$work/nf.zip" "$url"
python3 -c 'import sys,zipfile; zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])' "$work/nf.zip" "$work"
python3 -m venv "$work/venv"
"$work/venv/bin/pip" -q install fonttools
unicodes="$(IFS=,; echo "${GLYPHS[*]}")"
mkdir -p "$(dirname "$out")"
# The Mono face, where every glyph is one em wide and one em tall, so two
# icons at the same font size are the same size without either being given
# one -- the same reason the Compose app's script takes it. It is also what
# makes an icon's box predictable beside a line of text.
"$work/venv/bin/pyftsubset" "$work/SymbolsNerdFontMono-Regular.ttf" \
--unicodes="$unicodes" \
--layout-features= \
--drop-tables+=DSIG \
--output-file="$out"
cp "$work/LICENSE" "$here/assets/fonts/NERD_FONTS_LICENSE.txt"
echo "Wrote $out ($(stat -c %s "$out") bytes) with ${#GLYPHS[@]} glyphs"
+3
View File
@@ -79,6 +79,8 @@ type EventData<Rsc, E> = (E, Rc<dyn for<'a> EventFn<Rsc, <E as Event>::Data<'a>>
pub struct TypeEventManager<Rsc: HasEvents, E: Event> {
// TODO: reduce visiblity!!
pub active: HashMap<LayerId, HashMap<WidgetId, E::State>>,
/// This event's own input-wide state -- see [`Event::Global`].
pub global: E::Global,
map: HashMap<WidgetId, Vec<EventData<Rsc, E>>>,
}
@@ -107,6 +109,7 @@ impl<Rsc: HasEvents, E: Event> Default for TypeEventManager<Rsc, E> {
fn default() -> Self {
Self {
active: Default::default(),
global: Default::default(),
map: Default::default(),
}
}
+14
View File
@@ -9,6 +9,20 @@ pub use rsc::*;
pub trait Event: Sized + 'static + Clone {
type Data<'a>: Clone = ();
type State: Default = ();
/// State this event owns that belongs to no single widget -- what the
/// thing dispatching the event knows about the *input*, rather than
/// about a listener. `()` for almost every event; the cursor's is
/// `iris::sense::PointerInput` (which widget holds pointer capture,
/// and who is tracking the press in flight).
///
/// It lives here so that such state has one owner, reached by `&mut`
/// through the event manager, instead of being parked on whatever
/// structure a handler happens to be able to reach and guarded with a
/// lock. Iris asked for that on 2026-09-08, of the pointer capture
/// that used to sit in a `Mutex` on `UiRenderState`: "everything
/// global should be stored in the general input handler, not in
/// specific senses with locking stuff."
type Global: Default = ();
#[allow(unused_variables)]
fn should_run<'a>(&self, data: &Self::Data<'a>) -> Option<Self::Data<'a>> {
Some(data.clone())
+39
View File
@@ -0,0 +1,39 @@
//! The icons iris draws, as codepoints in the Nerd Fonts subset it ships.
//!
//! **Why a bundled font rather than ordinary Unicode**: the disclosure
//! mark used to be U+25B8/25BE/25B4 out of whatever face the platform
//! resolved, and once iris stopped bundling fonts (DECISIONS.md,
//! 2026-09-07) Iris's phone drew an empty box for them and this VM drew a
//! dot. UI_RULES' answer is not to avoid glyphs but to ship them, which is
//! also what the Compose app has always done for its icons
//! (`app/build-icon-font.sh`, `NerdIcons.kt`) -- the same Material Design
//! family, so an icon means the same thing in both apps.
//!
//! **Why not vector assets or drawn shapes**: an icon beside a line of
//! text wants that line's size, colour and baseline, and text gets all
//! three for free. This replaced `iris::widget::mark`, which drew the
//! triangle into a texture: correct, but one shape, and every further icon
//! would have been another bespoke rasteriser.
//!
//! Each constant here has to have a matching codepoint in
//! `iris/core/build-icon-font.sh`'s `GLYPHS`; a codepoint here that the
//! script did not subset is a glyph that silently isn't there. The subset
//! is the font's **Mono** face, where every glyph is one em wide and one
//! em tall, so two icons at one font size are one size without either
//! being given one -- and why an icon looks smaller than text at the same
//! size, since the glyph is drawn inside that em rather than filling it.
//!
//! Draw one with [`crate::Family::Icons`]:
//!
//! ```ignore
//! text(icon::OPEN, 12.0, MUTED).family(Family::Icons)
//! ```
/// `md-menu_down` -- a filled triangle pointing down: this card is open.
pub const OPEN: &str = "\u{F035D}";
/// `md-menu_right` -- pointing right: this card opens.
pub const CLOSED: &str = "\u{F035F}";
/// `md-menu_up` -- pointing up: fold this group of cards away again.
pub const COLLAPSE: &str = "\u{F0360}";
+1
View File
@@ -19,6 +19,7 @@ mod render;
mod ui;
mod widget;
pub mod icon;
pub mod util;
pub use attr::*;
+1 -1
View File
@@ -1,6 +1,6 @@
use super::*;
#[derive(Copy, Clone, Eq, PartialEq)]
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
pub enum Axis {
X,
Y,
+5 -32
View File
@@ -1,10 +1,6 @@
use std::ops::{Index, IndexMut};
use crate::{
UiRegion, WidgetId,
render::{MaskIdx, MoveIdx, Primitive, PrimitiveHandle, PrimitiveInst, Primitives},
util::to_mut,
};
use crate::{render::LayerOrder, util::to_mut};
pub type LayerId = usize;
@@ -40,7 +36,10 @@ struct Child {
tail: usize,
}
pub type PrimitiveLayers = Layers<Primitives>;
/// The draw order of every layer. The primitives themselves live in one
/// arena beside this (`UiRenderState::primitives`); a layer names the
/// slots it draws, which is what its vertex buffer is.
pub type PrimitiveLayers = Layers<LayerOrder>;
impl<T: Default> Layers<T> {
pub fn new() -> Layers<T> {
@@ -120,32 +119,6 @@ impl<T: Default> Layers<T> {
}
}
impl PrimitiveLayers {
pub fn write<P: Primitive>(
&mut self,
layer: LayerId,
info: PrimitiveInst<P>,
) -> PrimitiveHandle {
self[layer].write(layer, info)
}
pub fn free(&mut self, h: &PrimitiveHandle) -> MaskIdx {
self[h.layer].free(h)
}
pub fn write_image(
&mut self,
layer: LayerId,
id: WidgetId,
texture_idx: u32,
region: UiRegion,
mask_idx: MaskIdx,
move_idx: MoveIdx,
) -> PrimitiveHandle {
self[layer].write_image(layer, id, texture_idx, region, mask_idx, move_idx)
}
}
impl<T: Default> Default for Layers<T> {
fn default() -> Self {
Self::new()
+233 -86
View File
@@ -2,7 +2,7 @@ use crate::{Align, GlyphAtlas, GlyphKey, PlacedGlyph, RegionAlign, Textures, UiC
use parley::{
Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, FontStyle, FontWeight,
GenericFamily, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty,
fontique::{Blob, FamilyId},
fontique::Blob,
};
use std::ops::Range;
use std::sync::Arc;
@@ -12,23 +12,16 @@ use swash::{
zeno::{Format, Vector},
};
/// Bundled fonts, registered over the system collection rather than relied
/// on alone -- see `TextData::register_bundled_fonts`'s doc comment for
/// why. Static weight/style cuts, not a variable font: parley/fontique
/// resolve a variable font's weight axis by picking normalized coordinates
/// on whatever single face registers for the family, and a phone whose
/// system "Roboto" is actually the variable "Roboto Flex" is exactly the
/// device class this sidesteps, rather than depends on working correctly.
/// Noto Sans, OFL-licensed (`assets/fonts/OFL.txt`), chosen for coverage
/// breadth (a transcript's content is not known in advance) over a
/// smaller-footprint alternative -- see the doc comment for the size this
/// added.
const NOTO_SANS_REGULAR: &[u8] = include_bytes!("../../assets/fonts/NotoSans-Regular.ttf");
const NOTO_SANS_BOLD: &[u8] = include_bytes!("../../assets/fonts/NotoSans-Bold.ttf");
const NOTO_SANS_ITALIC: &[u8] = include_bytes!("../../assets/fonts/NotoSans-Italic.ttf");
const NOTO_SANS_BOLD_ITALIC: &[u8] = include_bytes!("../../assets/fonts/NotoSans-BoldItalic.ttf");
const NOTO_SANS_MONO_REGULAR: &[u8] = include_bytes!("../../assets/fonts/NotoSansMono-Regular.ttf");
const NOTO_SANS_MONO_BOLD: &[u8] = include_bytes!("../../assets/fonts/NotoSansMono-Bold.ttf");
/// The icon font iris ships: the Nerd Fonts Symbols **Mono** subset built
/// by `iris/core/build-icon-font.sh`, holding only the codepoints
/// `crate::icon` names (992 bytes for three glyphs today).
///
/// This is the one font bundled here, and it is not a text font: body and
/// monospace text still come from the platform's own collection
/// (DECISIONS.md, 2026-09-07). An icon is the opposite case -- a small,
/// closed set of codepoints no system font is guaranteed to have -- which
/// is the same division the Compose app makes.
const NERD_ICONS: &[u8] = include_bytes!("../../assets/fonts/nerd_icons.ttf");
/// What starting up found about text rendering, for the on-screen
/// Diagnostics page and the one startup log line (RUST.md's P0 box, "log
@@ -57,6 +50,11 @@ pub struct FontDiagnostics {
pub bold_resolved: Option<String>,
pub italic_resolved: Option<String>,
pub mono_resolved: Option<String>,
/// The family the bundled icon font registered under, or `None` if
/// registering it failed. Reported rather than assumed: it is the one
/// font iris ships, so `None` is a broken build and must not look
/// like a device that happens to lack a face.
pub icon_family: Option<String>,
}
/// Everything text needs that outlives one string: the font collection, the
@@ -77,91 +75,175 @@ pub struct TextData {
/// truth would mean carrying a `Painter` (or output size) into every
/// input handler for the sake of one field.
pub density: f32,
/// The family name [`NERD_ICONS`] registered under, which is what
/// [`Family::Icons`] resolves to. `None` only if registering the
/// bundled font failed, which is a broken build rather than a
/// platform difference -- said in the startup diagnostics rather than
/// silently drawn as tofu.
pub icon_family: Option<String>,
}
impl Default for TextData {
/// Text comes entirely from the platform's own font collection --
/// `FontContext::new()` builds a `fontique::Collection` with
/// `CollectionOptions::system_fonts` on by default, which is real
/// discovery on both targets this crate ships on: Android's backend
/// parses `/system/fonts` and `/system/etc/fonts.xml` and maps
/// `SansSerif`/`SystemUi` to `["Roboto Flex", "Roboto", "Noto Sans"]`
/// and `Monospace` to the platform's `"monospace"` alias; the desktop
/// build's backend is fontconfig. No font is bundled or registered
/// here -- see DECISIONS.md's 2026-09-07 entry for why (matching what
/// the Compose app does: it takes body/monospace text from
/// `FontFamily.Default`/`FontFamily.Monospace`, i.e. Android's Roboto
/// and its platform monospace face, and ships no text font of its own,
/// only its committed Nerd Fonts icon subset for fixed glyphs).
fn default() -> Self {
let mut data = Self {
font_cx: FontContext::new(),
let mut font_cx = FontContext::new();
patch_android_monospace(&mut font_cx);
let icon_family = register_icon_font(&mut font_cx);
Self {
font_cx,
layout_cx: LayoutContext::new(),
scale_cx: ScaleContext::new(),
atlas: GlyphAtlas::default(),
density: 1.0,
};
data.register_bundled_fonts();
data
icon_family,
}
}
}
impl TextData {
/// Registers Noto Sans (regular/bold/italic/bold-italic) and Noto Sans
/// Mono (regular/bold) as static faces, and puts them **first** in the
/// `SansSerif`/`Monospace` generic-family fallback lists -- ahead of,
/// not instead of, whatever the platform already found, so a script
/// Noto Sans lacks (CJK, emoji, ...) still falls through to the system
/// font the same as before this existed.
/// Registers the bundled icon font as an ordinary named family and
/// answers the name it registered under -- read back from the collection
/// rather than written down here, so the name cannot drift from the file
/// (`build-icon-font.sh` takes whatever face the Nerd Fonts release
/// ships).
///
/// Exists because text rendering must not depend on the platform's own
/// font enumeration succeeding or resolving weight/style the way this
/// crate assumes: RUST.md's P0 box found bold spans on a real phone
/// rendering as blank gaps of the correct advance width (the glyph
/// simply wasn't rasterised -- `TextData::place`'s `None` arm), while
/// the emulator's system fonts happened to resolve every style. A
/// bundled, static-per-style family removes fontique's Android font
/// scan (`fontique::backend::android::SystemFonts::new`, which parses
/// `/system/fonts` and `/system/etc/fonts.xml`) from the path a glyph
/// has to survive to reach the screen at all.
///
/// Cost: six static `.ttf`s, ~3.6 MB uncompressed
/// (`iris/core/assets/fonts/`), landing in the APK compressed --
/// `build-apk.sh`'s own output is what says the delivered number, not
/// this comment.
fn register_bundled_fonts(&mut self) {
fn register(cx: &mut FontContext, bytes: &'static [u8]) -> Option<FamilyId> {
let blob = Blob::new(Arc::new(bytes));
cx.collection
/// A *named* family rather than a generic one: nothing should fall back
/// to it for ordinary text, and nothing should fall back out of it for an
/// icon -- a system face that happens to have one of these codepoints
/// would draw somebody else's picture.
fn register_icon_font(font_cx: &mut FontContext) -> Option<String> {
let blob = Blob::new(Arc::new(NERD_ICONS));
let id = font_cx
.collection
.register_fonts(blob, None)
.into_iter()
.map(|(id, _)| id)
.next()
.next()?;
font_cx.collection.family_name(id).map(str::to_string)
}
let sans_id = register(&mut self.font_cx, NOTO_SANS_REGULAR);
register(&mut self.font_cx, NOTO_SANS_BOLD);
register(&mut self.font_cx, NOTO_SANS_ITALIC);
register(&mut self.font_cx, NOTO_SANS_BOLD_ITALIC);
let mono_id = register(&mut self.font_cx, NOTO_SANS_MONO_REGULAR);
register(&mut self.font_cx, NOTO_SANS_MONO_BOLD);
if let Some(sans_id) = sans_id {
let existing: Vec<_> = self
.font_cx
.collection
.generic_families(GenericFamily::SansSerif)
.collect();
self.font_cx.collection.set_generic_families(
GenericFamily::SansSerif,
std::iter::once(sans_id).chain(existing),
);
let existing: Vec<_> = self
.font_cx
.collection
.generic_families(GenericFamily::SystemUi)
.collect();
self.font_cx.collection.set_generic_families(
GenericFamily::SystemUi,
std::iter::once(sans_id).chain(existing),
);
}
if let Some(mono_id) = mono_id {
let existing: Vec<_> = self
.font_cx
/// Works around `fontique` 0.11.1's Android backend never resolving
/// `GenericFamily::Monospace` (confirmed against
/// `fontique-0.11.1/src/backend/android.rs`'s `SystemFonts::new`, and still
/// present on `linebender/parley`'s `main` as of 2026-09-07, so there is no
/// released fix to bump to yet -- see DECISIONS.md's 2026-09-07 entry,
/// "Platform fonts," for the full account). Two bugs stack, not one:
/// `DEFAULT_GENERIC_FAMILIES` looks up the name `"monospace"` *before*
/// `fonts.xml` is parsed into that same name map, and even after parsing,
/// AOSP's `fonts.xml` names it with a `<family name="monospace">` element
/// (not an `<alias>`) whose `<font>` children the backend's own parser
/// does not read (a `TODO` in that match arm) -- so the name gets a
/// `FamilyId` with no font data behind it, and `family_by_name("monospace")`
/// comes back empty too. Confirmed on this checkout's emulator: `adb pull
/// /system/etc/fonts.xml` shows
/// `<family name="monospace"><font weight="400"
/// style="normal">DroidSansMono.ttf</font></family>` with no matching
/// alias.
///
/// So this reads `fonts.xml` itself (already on-device, already the
/// authority Compose's own `Typeface.MONOSPACE` resolves through) for the
/// filename that declaration names, then finds which of fontique's
/// *actually* scanned families (from `/system/fonts`, which do carry real
/// font data, just under whatever name the font's own metadata gives it --
/// "Droid Sans Mono" here, but that name is never hardcoded) owns a font
/// file with that name, and registers that family as the `Monospace`
/// generic the way the backend itself would have if its parser had reified
/// the declaration. A no-op if the family is somehow already resolved
/// (future fontique) or nothing matches (no `fonts.xml`, e.g. a headless
/// test, or a device that names it some other way).
#[cfg(target_os = "android")]
fn patch_android_monospace(font_cx: &mut FontContext) {
use parley::fontique::SourceKind;
let already_resolved = font_cx
.collection
.generic_families(GenericFamily::Monospace)
.next()
.is_some();
if already_resolved {
return;
}
let Some(target_file) = android_monospace_font_filename() else {
return;
};
let names: Vec<String> = font_cx
.collection
.family_names()
.map(str::to_string)
.collect();
self.font_cx.collection.set_generic_families(
GenericFamily::Monospace,
std::iter::once(mono_id).chain(existing),
);
for name in names {
let Some(id) = font_cx.collection.family_id(&name) else {
continue;
};
let Some(info) = font_cx.collection.family(id) else {
continue;
};
let Some(font) = info.default_font() else {
continue;
};
let SourceKind::Path(path) = font.source().kind() else {
continue;
};
if path.file_name().and_then(|f| f.to_str()) == Some(target_file.as_str()) {
font_cx
.collection
.append_generic_families(GenericFamily::Monospace, std::iter::once(id));
return;
}
}
}
/// Reads the font filename `fonts.xml` names for its `"monospace"` family
/// (e.g. `"DroidSansMono.ttf"`), by plain substring search rather than a
/// real XML parser -- a new dependency for one well-known, stable AOSP file
/// whose structure fontique itself already parses with a full parser one
/// module over. Not a general XML reader; assumes the file has exactly one
/// `<family name="monospace">` element with at least one `<font>` child,
/// which is the format on every AOSP `fonts.xml` this was checked against.
#[cfg(target_os = "android")]
fn android_monospace_font_filename() -> Option<String> {
let android_root = std::env::var("ANDROID_ROOT").unwrap_or_else(|_| "/system".to_string());
let xml =
std::fs::read_to_string(std::path::Path::new(&android_root).join("etc/fonts.xml")).ok()?;
let family_start = xml.find("<family name=\"monospace\">")?;
let block = &xml[family_start..];
let block = &block[..block.find("</family>")?];
let font_tag = block.find("<font")?;
let after_tag = &block[font_tag..];
let content_start = after_tag.find('>')? + 1;
let content = &after_tag[content_start..];
let filename = content[..content.find('<')?].trim();
(!filename.is_empty()).then(|| filename.to_string())
}
#[cfg(not(target_os = "android"))]
fn patch_android_monospace(_font_cx: &mut FontContext) {}
impl TextData {
/// [`Family::Icons`] as the name the bundled font actually registered
/// under; everything else unchanged.
///
/// Cloned rather than borrowed because the caller needs it while the
/// layout builder holds `&mut self` -- a `String` per shaped icon run,
/// paid only when the layout is rebuilt.
pub fn resolve_family(&self, family: &Family) -> Family {
match family {
Family::Icons => self
.icon_family
.clone()
.map_or(Family::Icons, Family::Named),
other => other.clone(),
}
}
@@ -242,6 +324,7 @@ impl TextData {
bold_resolved,
italic_resolved,
mono_resolved,
icon_family: self.icon_family.clone(),
}
}
}
@@ -253,6 +336,11 @@ pub enum Family {
SansSerif,
Serif,
Monospace,
/// The bundled icon font -- see [`crate::icon`] for what is in it.
/// Named as an intention rather than as a font name because only
/// [`TextData`] knows what the file registered as; it resolves this
/// during shaping ([`TextData::resolve_family`]).
Icons,
Named(String),
}
@@ -262,6 +350,11 @@ impl Family {
Self::SansSerif => FontFamilyName::Generic(GenericFamily::SansSerif),
Self::Serif => FontFamilyName::Generic(GenericFamily::Serif),
Self::Monospace => FontFamilyName::Generic(GenericFamily::Monospace),
// Only reachable if `resolve_family` did not run, which no
// shaping path allows -- and sans-serif is the honest answer
// for a build whose icon font failed to register: the reader
// gets the platform's own tofu rather than a wrong picture.
Self::Icons => FontFamilyName::Generic(GenericFamily::SansSerif),
Self::Named(name) => FontFamilyName::Named(name.as_str().into()),
};
FontFamily::Single(name)
@@ -460,21 +553,29 @@ impl TextBuffer {
if self.shaped.as_ref() == Some(&(attrs.clone(), width, density)) {
return;
}
// Resolved before the builder borrows `data`: `Family::Icons`
// names an intention, and the name behind it lives on `TextData`.
let base_family = data.resolve_family(&attrs.family);
let span_families: Vec<Option<Family>> = self
.spans
.iter()
.map(|span| span.family.as_ref().map(|f| data.resolve_family(f)))
.collect();
let mut builder = data
.layout_cx
.ranged_builder(&mut data.font_cx, &self.text, 1.0, true);
builder.push_default(StyleProperty::FontFamily(attrs.family.family()));
builder.push_default(StyleProperty::FontFamily(base_family.family()));
builder.push_default(StyleProperty::FontSize(attrs.font_size * density));
builder.push_default(StyleProperty::LineHeight(LineHeight::Absolute(
attrs.line_height * density,
)));
builder.push_default(StyleProperty::Brush(attrs.color));
for span in &self.spans {
for (span, family) in self.spans.iter().zip(&span_families) {
let range = span.range.clone();
if let Some(color) = span.color {
builder.push(StyleProperty::Brush(color), range.clone());
}
if let Some(family) = &span.family {
if let Some(family) = family {
builder.push(StyleProperty::FontFamily(family.family()), range.clone());
}
if let Some(size) = span.font_size {
@@ -628,3 +729,49 @@ impl TextData {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::icon;
/// Every codepoint `icon` names is actually in the subset the script
/// built. This is the failure `build-icon-font.sh`'s own comment warns
/// about -- a constant added on one side and not the other is a glyph
/// that silently isn't there -- and it is invisible at runtime,
/// because a missing glyph draws as nothing rather than as an error.
#[test]
fn every_icon_is_in_the_bundled_font() {
let font = FontRef::from_index(NERD_ICONS, 0).expect("the bundled icon font parses");
let charmap = font.charmap();
for (name, glyph) in [
("OPEN", icon::OPEN),
("CLOSED", icon::CLOSED),
("COLLAPSE", icon::COLLAPSE),
] {
let mut chars = glyph.chars();
let ch = chars.next().expect("an icon is one character");
assert!(chars.next().is_none(), "{name} is more than one character");
assert_ne!(
charmap.map(ch),
0,
"{name} (U+{:04X}) is not in nerd_icons.ttf -- add it to \
build-icon-font.sh's GLYPHS and rerun the script",
ch as u32
);
}
}
/// The font registers, so `Family::Icons` resolves to a real family
/// rather than falling through to sans-serif and drawing tofu.
#[test]
fn the_icon_family_registers_and_resolves() {
let data = TextData::default();
let family = data.resolve_family(&Family::Icons);
assert!(
matches!(family, Family::Named(_)),
"the bundled icon font did not register: {:?}",
data.icon_family
);
}
}
+167 -19
View File
@@ -1,6 +1,7 @@
use crate::util::{RefCounter, Vec2};
use image::{DynamicImage, GenericImageView};
use std::{
collections::HashMap,
ops::Index,
sync::mpsc::{Receiver, Sender, channel},
};
@@ -21,6 +22,16 @@ pub enum TextureKind {
},
}
/// What a [`Textures::shared`] texture is a picture of -- exactly, not by
/// hash: `owner` names the widget kind whose description it is, and `id`
/// packs that description's own fields, so two owners cannot collide and
/// a debugger shows which picture a slot holds.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SharedTextureKey {
pub owner: &'static str,
pub id: u64,
}
#[derive(Debug, Clone)]
pub struct TextureHandle {
slot: u32,
@@ -35,6 +46,14 @@ pub struct TextureHandle {
pub struct Textures {
free: Vec<u32>,
images: Vec<Option<DynamicImage>>,
/// What each slot is, kept beside the image so a slot can be pushed
/// again without the handle that knows -- see [`Textures::reupload`].
kinds: Vec<TextureKind>,
/// Textures built from a description rather than from a file, one per
/// distinct description: see [`Textures::shared`]. The map holds a
/// reference of its own, so a shared texture outlives every widget
/// drawing it and its slot is never recycled underneath one.
shared: HashMap<SharedTextureKey, TextureHandle>,
/// Next layer to hand out to an atlas page. Pages are never freed (no
/// atlas eviction), so this only grows and `free` never holds one.
next_page_layer: u32,
@@ -77,6 +96,8 @@ impl Textures {
Self {
free: Vec::new(),
images: Vec::new(),
kinds: Vec::new(),
shared: HashMap::new(),
next_page_layer: 0,
updates: Vec::new(),
send,
@@ -119,16 +140,46 @@ impl Textures {
fn push(&mut self, kind: TextureKind, image: DynamicImage) -> u32 {
if let Some(i) = self.free.pop() {
self.images[i as usize] = Some(image);
self.kinds[i as usize] = kind;
self.updates.push(Update::Set(kind, i));
i
} else {
let i = self.images.len() as u32;
self.images.push(Some(image));
self.kinds.push(kind);
self.updates.push(Update::Push(kind, i));
i
}
}
/// The one texture for `key`, building it on the first ask and handing
/// out a further reference to it every time after.
///
/// **Why this exists**: a texture rasterised from a *description* --
/// `widget::mark`'s triangle, from a direction and a colour -- has as
/// many copies as there are widgets asking for it, and each copy is
/// its own GPU texture, its own bind group and its own draw call. A
/// transcript screen with a folded card per tool call built one per
/// card: hundreds of 48x48 textures of three distinct pictures,
/// created and freed again as rows recycled. `make` is not called when
/// the key is already known, so the rasterising is paid once too.
///
/// The map keeps its own reference for the life of the `Textures`, so
/// a shared slot is never freed and never reused for something else --
/// which is what makes a handle held by a long-lived widget safe.
pub fn shared(
&mut self,
key: SharedTextureKey,
make: impl FnOnce() -> DynamicImage,
) -> TextureHandle {
if let Some(handle) = self.shared.get(&key) {
return handle.clone();
}
let handle = self.add(make());
self.shared.insert(key, handle.clone());
handle
}
/// The stored image for a handle, to be written into before `patch`.
pub fn image_mut(&mut self, handle: &TextureHandle) -> &mut DynamicImage {
self.images[handle.slot as usize]
@@ -141,25 +192,35 @@ impl Textures {
self.updates.push(Update::Patch(handle.slot, rect));
}
/// Forget every image, page and pending update -- what a genuinely new
/// GPU device needs alongside [`crate::render::atlas::GlyphAtlas::
/// clear`], which this module's own doc references: every slot number
/// and every queued [`Update`] here describes the *old* device's
/// textures (an `Update::Push`/`Update::Patch` already drained into a
/// renderer that no longer exists is gone for good, and a fresh
/// `UiRenderNode`'s own texture manager starts with none of them
/// applied), so nothing is lost by starting this bookkeeping over too.
/// Any `TextureHandle` a caller still holds across the reset (none in
/// the transcript screen this reset is wired up for today -- confirmed
/// by grep, the only standalone (non-atlas) image anywhere in this
/// workspace is `iris/widget/image.rs`'s `Image`, used by the separate
/// `tabs-ui` example) is left pointing at a slot this instance no
/// longer recognises and needs reinserting via `add`/`add_page` again
/// -- the same pre-existing gap a renderer restart already left for
/// such a handle before this method existed, just named rather than
/// silent now.
pub fn reset(&mut self) {
*self = Self::new();
/// Queue every live slot for upload again, in slot order -- what a
/// genuinely new GPU device needs, in place of forgetting everything.
///
/// A new device starts with no textures, and the renderer-side mirror
/// of these slots (`render::texture::GpuTextures`) starts empty with
/// it. What it must not do is start empty while the handles widgets
/// are still holding name slots by *index*: `Textures::reset` used to
/// throw this bookkeeping away, which left every live `TextureHandle`
/// -- one per `widget::mark`, hundreds on a transcript screen --
/// pointing at a slot nothing recognised, and the first frame after an
/// Android surface rebuild panicked in `image_bind_group` ("texture
/// slot 89 is not a live standalone image: None"). Re-uploading
/// instead keeps every index meaning what it meant, because this side
/// still holds the images: the slot list is rebuilt identically,
/// including the empty slots, which go across as `PushFree` so the
/// ones after them still land where they were.
///
/// The glyph atlas comes back with it and is deliberately *not*
/// cleared any more: its pages are slots here, this side holds their
/// pixels, and re-uploading them restores exactly the atlas that was
/// there -- so an app switch no longer costs a re-rasterisation of
/// every glyph on screen either.
///
/// Pending updates are dropped rather than kept: each is either a push
/// or a patch of a slot this replays in full.
pub fn reupload(&mut self) {
self.updates.clear();
self.updates
.extend((0..self.images.len() as u32).map(|i| Update::Push(self.kinds[i as usize], i)));
}
pub fn free(&mut self) {
@@ -245,3 +306,90 @@ impl Default for Textures {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use image::RgbaImage;
fn image(n: u32) -> DynamicImage {
RgbaImage::new(n, n).into()
}
fn key(id: u64) -> SharedTextureKey {
SharedTextureKey { owner: "test", id }
}
/// What `widget::mark` needs: one texture per description, however
/// many widgets ask for it, and a different description is a
/// different texture.
#[test]
fn a_shared_texture_is_built_once_and_handed_out_again() {
let mut textures = Textures::new();
let built = std::cell::Cell::new(0);
let make = |textures: &mut Textures, id: u64| {
textures.shared(key(id), || {
built.set(built.get() + 1);
image(4)
})
};
let first = make(&mut textures, 1);
let again = make(&mut textures, 1);
let other = make(&mut textures, 2);
assert_eq!(built.get(), 2, "the second ask for key 1 rasterised again");
assert_eq!(first.image_index(), again.image_index());
assert_ne!(first.image_index(), other.image_index());
}
/// The map's own reference is what keeps a shared slot alive: every
/// widget holding one can go away and the slot must not be recycled,
/// because the next widget to ask gets that same index back.
#[test]
fn a_shared_slot_is_not_freed_when_the_last_widget_drops_it() {
let mut textures = Textures::new();
let slot = textures.shared(key(1), || image(4)).image_index();
textures.free();
let plain = textures.add(image(4));
assert_ne!(
plain.image_index(),
slot,
"an ordinary texture was handed the shared mark's slot"
);
}
/// A new GPU device gets the same slot numbering back, so a handle a
/// widget has been holding all along still names its own texture --
/// the crash `reupload` replaced `reset` to fix.
#[test]
fn reupload_replays_every_slot_in_order_including_the_empty_ones() {
let mut textures = Textures::new();
let keep_a = textures.add(image(4));
let dropped = textures.add(image(4));
let keep_b = textures.add(image(4));
let (a, gone, b) = (
keep_a.image_index(),
dropped.image_index(),
keep_b.image_index(),
);
drop(dropped);
textures.free();
// Drain the updates so far, the way a frame does.
assert!(textures.updates().count() > 0);
textures.reupload();
let kinds: Vec<String> = textures
.updates()
.map(|u| match u {
TextureUpdate::Push(..) => "push".to_string(),
TextureUpdate::PushFree(..) => "push-free".to_string(),
_ => "other".to_string(),
})
.collect();
assert_eq!(
kinds,
["push", "push-free", "push"],
"slots {a}, {gone} (freed) and {b} must replay in order, so the \
indices after a hole still land where they were"
);
}
}
+62 -22
View File
@@ -8,6 +8,15 @@ pub struct WindowUniform {
pub height: f32,
}
/// One primitive's placement and what to draw there, in the one arena
/// every layer shares (`Primitives`). Read from a storage buffer by
/// **both** shader stages: the vertex stage for the corners of the
/// primitive it is drawing, the fragment stage for the corners of a
/// *mask's* primitive, which is generally a different one and often in
/// another layer. A layer's vertex buffer carries only the slot
/// ([`instance_slot_layout`]), so there is exactly one copy of a
/// placement and a mask cannot disagree with what was drawn. See
/// LAYOUT.md's "Masks with a shape".
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct PrimitiveInstance {
@@ -18,24 +27,17 @@ pub struct PrimitiveInstance {
pub move_idx: MoveIdx,
}
impl PrimitiveInstance {
const ATTRIBS: [VertexAttribute; 8] = vertex_attr_array![
0 => Float32x2,
1 => Float32x2,
2 => Float32x2,
3 => Float32x2,
4 => Uint32,
5 => Uint32,
6 => Uint32,
7 => Uint32,
];
pub fn desc() -> VertexBufferLayout<'static> {
/// The vertex layout of a layer's draw order: one `u32` slot into the
/// global instance arena per instance, stepped per instance. Everything a
/// primitive is made of used to be here as eight vertex attributes; it
/// moved into the storage buffer above so the fragment stage can read it
/// too.
pub fn instance_slot_layout() -> VertexBufferLayout<'static> {
const ATTRIBS: [VertexAttribute; 1] = vertex_attr_array![0 => Uint32];
VertexBufferLayout {
array_stride: std::mem::size_of::<Self>() as BufferAddress,
array_stride: std::mem::size_of::<u32>() as BufferAddress,
step_mode: VertexStepMode::Instance,
attributes: &Self::ATTRIBS,
}
attributes: &ATTRIBS,
}
}
@@ -47,15 +49,53 @@ impl MaskIdx {
pub type MoveIdx = Id<u32>;
/// A clip, as a reference to a primitive already written plus the mask it
/// nests inside. The fragment stage evaluates that primitive's coverage
/// *at the masked pixel* -- for a rect, the same `rounded_rect_coverage`
/// from the same SDF the rect itself is drawn with -- and multiplies it
/// into the pixel's alpha, so a rounded container's corner and its
/// children's clipped corner are the same arithmetic and cannot disagree.
/// See LAYOUT.md's "Masks with a shape".
///
/// **No `kind` and no `flags`**, which the design sketched: the referenced
/// instance already carries its own `binding`, and a copy of it here is a
/// second thing to keep in step; alpha-only is the only mode there is, so
/// there is nothing to select. Both are a field away if a second mode
/// appears.
#[repr(C)]
#[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct Mask {
pub region: UiRegion,
/// The mask-owning widget's own move slot -- resolved in the fragment
/// shader against the same chain the vertex shader walks for a
/// primitive's own corners, so a mask and the content clipped by it
/// can move independently. See LAYOUT.md section 2b.
pub move_idx: MoveIdx,
/// The slot in `UiRenderState::primitives` of the primitive whose
/// coverage this mask is. Today always a `RectPrimitive`: a glyph or
/// a standalone image would need, respectively, a CPU-side alpha
/// plane for the hit test to agree with the shader, and a bind-group
/// switch the fragment stage cannot make -- `Painter::set_mask`
/// rejects both by name rather than leaving the shader to read a rect
/// that is not there.
///
/// Who owns it depends on which way the mask was set. A plain
/// `.masked()` writes its own undrawn rect, so the primitive is in
/// the masking widget's `ActiveData::primitives` and lives exactly as
/// long as the mask. `.masked_by(shape)` points at a *child's*
/// primitive, which that child can free on any redraw of its own --
/// so `UiRenderState::remask_shape_users` marks the mask's owner for
/// redraw whenever a referenced slot is freed, since that widget's
/// own `set_mask` is the only thing that resolves the slot again.
pub primitive: u32,
/// The mask this one was set *inside* (`MaskIdx::NONE` at the top), so
/// clipping nests: the fragment stage walks the chain and multiplies
/// every coverage on it, which is what makes a pixel inside two
/// feathered corners dimmed by both. Chained rather than intersected
/// on the CPU because each mask moves with its own widget -- a code
/// fence inside a transcript row carries the row's scroll, the list's
/// own box does not, and one region resolved when the fence was last
/// drawn gets the second of those wrong as soon as the row moves.
///
/// A child holds one ref on its parent's slot (`Painter::set_mask`),
/// released when the child's own slot goes
/// (`UiRenderState::remove`), so the chain cannot outlive what it
/// points at.
pub parent: MaskIdx,
}
/// One widget's cumulative on-screen translation, and the slot of the
+105 -75
View File
@@ -1,6 +1,10 @@
use crate::{
UiData, UiRenderState,
render::{data::PrimitiveInstance, texture::GpuTextures, util::ArrBuf},
render::{
data::{PrimitiveInstance, instance_slot_layout},
texture::GpuTextures,
util::ArrBuf,
},
util::{HashMap, Vec2},
};
use data::WindowUniform;
@@ -14,6 +18,7 @@ mod atlas;
mod data;
mod frame_report;
mod primitive;
mod sdf;
mod texture;
mod util;
@@ -21,8 +26,14 @@ pub use atlas::*;
pub use data::{Mask, MaskIdx, MoveIdx, MoveOffset};
pub use frame_report::{FrameReport, FrameStats, JANK_THRESHOLD};
pub use primitive::*;
pub use sdf::{distance_from_rect, rounded_rect_coverage};
const SHAPE_SHADER: &str = include_str!("./shader.wgsl");
/// The one shader every primitive is drawn with. Public so a test can run
/// a function out of it against the CPU transliteration in [`sdf`] --
/// `iris/tests/mask_sdf.rs`, which LAYOUT.md's "Masks with a shape" turns
/// on: a masked corner that cannot be tapped and a masked corner that is
/// not drawn are only the same corner while the two agree.
pub const SHAPE_SHADER: &str = include_str!("./shader.wgsl");
/// The `wgpu::Limits` both platform backends (`android::render::
/// AndroidRenderer::new`, `default::render::UiRenderer::new`) ask
@@ -120,6 +131,11 @@ impl WgpuErrorLog {
pub struct UiRenderNode {
uniform_group: BindGroup,
primitive_layout: BindGroupLayout,
/// Group 1: `rects` and `glyphs`. Global and bound once per frame,
/// not per layer -- a mask referencing a rect drawn in another layer
/// has to be able to read it (see `Primitives`).
primitives: PrimitiveBuffers,
primitive_group: BindGroup,
rsc_layout: BindGroupLayout,
rsc_group: BindGroup,
@@ -129,6 +145,9 @@ pub struct UiRenderNode {
active: Vec<usize>,
window_buffer: Buffer,
textures: GpuTextures,
/// Every primitive's placement, read by the vertex stage for the
/// primitive being drawn and by the fragment stage for a mask's.
instances: ArrBuf<PrimitiveInstance>,
masks: ArrBuf<Mask>,
move_offsets: ArrBuf<MoveOffset>,
/// Group 3: the masks and move-offsets storage buffers, on their own --
@@ -146,16 +165,16 @@ pub struct UiRenderNode {
masks_group: BindGroup,
}
/// One layer's vertex buffers: the slots it draws, in order. The
/// primitives themselves are in `UiRenderNode::instances`.
struct RenderLayer {
instance: ArrBuf<PrimitiveInstance>,
primitives: PrimitiveBuffers,
primitive_group: BindGroup,
/// A standalone image's instances, kept apart from `instance` because
/// each one draws with its own bind group -- see `UiRenderNode::draw`.
image_instance: ArrBuf<PrimitiveInstance>,
/// The texture slot each entry of `image_instance` draws with, in the
/// same order, refreshed alongside it. Not stored in the vertex buffer
/// itself because it names a bind group, not shader data.
order: ArrBuf<u32>,
/// A standalone image's slots, kept apart from `order` because each
/// one draws with its own bind group -- see `UiRenderNode::draw`.
images: ArrBuf<u32>,
/// The texture slot each entry of `images` draws with, in the same
/// order, refreshed alongside it. Not in the vertex buffer itself
/// because it names a bind group, not shader data.
image_tex_indices: Vec<u32>,
}
@@ -163,6 +182,8 @@ impl UiRenderNode {
pub fn draw<'a>(&'a self, pass: &mut RenderPass<'a>) {
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &self.uniform_group, &[]);
// Group 1 is global now, so it is set here rather than per layer.
pass.set_bind_group(1, &self.primitive_group, &[]);
// Set once, not per layer or per image: masks/move_offsets are read
// by every primitive and every standalone image alike, and living
// in their own group (rather than folded into group 2 alongside the
@@ -172,14 +193,13 @@ impl UiRenderNode {
pass.set_bind_group(3, &self.masks_group, &[]);
for i in &self.active {
let layer = &self.layers[i];
if layer.instance.len() == 0 && layer.image_instance.len() == 0 {
if layer.order.len() == 0 && layer.images.len() == 0 {
continue;
}
pass.set_bind_group(1, &layer.primitive_group, &[]);
if layer.instance.len() > 0 {
if layer.order.len() > 0 {
pass.set_bind_group(2, &self.rsc_group, &[]);
pass.set_vertex_buffer(0, layer.instance.buffer.slice(..));
pass.draw(0..4, 0..layer.instance.len() as u32);
pass.set_vertex_buffer(0, layer.order.buffer.slice(..));
pass.draw(0..4, 0..layer.order.len() as u32);
}
// Images draw after this layer's rects and glyphs, one draw call
// each with its own bind group. That draws every image "on top"
@@ -188,8 +208,8 @@ impl UiRenderNode {
// draw order was already undefined before images had their own
// list -- nothing before this relied on interleaving a rect
// between two images at a particular position.
if layer.image_instance.len() > 0 {
pass.set_vertex_buffer(0, layer.image_instance.buffer.slice(..));
if layer.images.len() > 0 {
pass.set_vertex_buffer(0, layer.images.buffer.slice(..));
for (k, &tex_idx) in layer.image_tex_indices.iter().enumerate() {
pass.set_bind_group(2, self.textures.image_bind_group(tex_idx), &[]);
pass.draw(0..4, k as u32..k as u32 + 1);
@@ -206,67 +226,45 @@ impl UiRenderNode {
ui_render: &mut UiRenderState,
) -> FrameUpdateStats {
self.active.clear();
for (i, primitives) in ui_render.layers.iter_mut() {
for (i, order) in ui_render.layers.iter_mut() {
self.active.push(i);
for change in primitives.apply_free() {
if let Some(inst) = ui_render.active.get_mut(&change.id) {
for h in &mut inst.primitives {
// `is_image` disambiguates: `instances` and `images`
// are separate lists with independent indices, so
// without it a rect's renumbering could be applied to
// an image handle that happened to share the same
// (layer, inst_idx).
if h.layer == i
&& h.inst_idx == change.old
&& (h.binding == IMAGE_BINDING) == change.is_image
{
h.inst_idx = change.new;
break;
}
}
}
}
let rlayer = self.layers.entry(i).or_insert_with(|| {
let primitives = PrimitiveBuffers::new(device);
let primitive_group =
Self::primitive_group(device, &self.primitive_layout, primitives.buffers());
RenderLayer {
instance: ArrBuf::new(
let rlayer = self.layers.entry(i).or_insert_with(|| RenderLayer {
order: ArrBuf::new(
device,
BufferUsages::VERTEX | BufferUsages::COPY_DST,
"instance",
"layer order",
),
primitives,
primitive_group,
image_instance: ArrBuf::new(
images: ArrBuf::new(
device,
BufferUsages::VERTEX | BufferUsages::COPY_DST,
"image instance",
"layer image order",
),
image_tex_indices: Vec::new(),
}
});
if primitives.updated {
rlayer
.instance
.update(device, queue, primitives.instances());
rlayer.primitives.update(device, queue, primitives.data());
rlayer.primitive_group = Self::primitive_group(
device,
&self.primitive_layout,
rlayer.primitives.buffers(),
);
rlayer
.image_instance
.update(device, queue, primitives.image_instances());
rlayer.image_tex_indices = primitives
.image_instances()
if order.updated {
rlayer.order.update(device, queue, order.order());
rlayer.images.update(device, queue, order.images());
rlayer.image_tex_indices = order
.images()
.iter()
.map(|inst| inst.idx)
.map(|&slot| ui_render.primitives.instance(slot).idx)
.collect();
primitives.updated = false;
order.updated = false;
}
}
let instances_resized = if ui_render.primitives.updated {
ui_render.primitives.updated = false;
let resized = self
.instances
.update(device, queue, ui_render.primitives.instances());
self.primitives
.update(device, queue, ui_render.primitives.data());
self.primitive_group =
Self::primitive_group(device, &self.primitive_layout, self.primitives.buffers());
resized
} else {
false
};
let masks_resized = if ui.masks.changed {
ui.masks.changed = false;
self.masks.update(device, queue, &ui.masks[..])
@@ -280,9 +278,14 @@ impl UiRenderNode {
} else {
false
};
if masks_resized || moves_resized {
self.masks_group =
Self::masks_group(device, &self.masks_layout, &self.masks, &self.move_offsets);
if masks_resized || moves_resized || instances_resized {
self.masks_group = Self::masks_group(
device,
&self.masks_layout,
&self.masks,
&self.move_offsets,
&self.instances,
);
}
let rebuild_main = self.textures.update(&mut ui.textures, &self.rsc_layout);
if rebuild_main {
@@ -408,6 +411,14 @@ impl UiRenderNode {
});
let tex_manager = GpuTextures::new(device, queue);
let primitives = PrimitiveBuffers::new(device);
let primitive_group =
Self::primitive_group(device, &primitive_layout, primitives.buffers());
let instances = ArrBuf::new(
device,
BufferUsages::STORAGE | BufferUsages::COPY_DST,
"ui instances",
);
let masks = ArrBuf::new(
device,
BufferUsages::STORAGE | BufferUsages::COPY_DST,
@@ -422,15 +433,16 @@ impl UiRenderNode {
let rsc_layout = Self::rsc_layout(device);
let rsc_group = Self::rsc_group(device, &rsc_layout, &tex_manager);
let masks_layout = Self::masks_layout(device);
let masks_group = Self::masks_group(device, &masks_layout, &masks, &move_offsets);
let masks_group =
Self::masks_group(device, &masks_layout, &masks, &move_offsets, &instances);
let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
label: Some("UI Shape Pipeline Layout"),
bind_group_layouts: &[
&uniform_layout,
&primitive_layout,
&rsc_layout,
&masks_layout,
Some(&uniform_layout),
Some(&primitive_layout),
Some(&rsc_layout),
Some(&masks_layout),
],
immediate_size: 0,
});
@@ -440,7 +452,7 @@ impl UiRenderNode {
vertex: VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[PrimitiveInstance::desc()],
buffers: &[Some(instance_slot_layout())],
compilation_options: Default::default(),
},
fragment: Some(FragmentState {
@@ -486,6 +498,8 @@ impl UiRenderNode {
Ok(Self {
uniform_group,
primitive_layout,
primitives,
primitive_group,
rsc_layout,
rsc_group,
pipeline,
@@ -493,6 +507,7 @@ impl UiRenderNode {
layers: HashMap::default(),
active: Vec::new(),
textures: tex_manager,
instances,
masks,
move_offsets,
masks_layout,
@@ -627,6 +642,16 @@ impl UiRenderNode {
},
count: None,
},
BindGroupLayoutEntry {
binding: 2,
visibility: ShaderStages::VERTEX | ShaderStages::FRAGMENT,
ty: BindingType::Buffer {
ty: BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
label: Some("ui masks"),
})
@@ -637,6 +662,7 @@ impl UiRenderNode {
layout: &BindGroupLayout,
masks: &ArrBuf<Mask>,
move_offsets: &ArrBuf<MoveOffset>,
instances: &ArrBuf<PrimitiveInstance>,
) -> BindGroup {
device.create_bind_group(&BindGroupDescriptor {
layout,
@@ -649,6 +675,10 @@ impl UiRenderNode {
binding: 1,
resource: move_offsets.buffer.as_entire_binding(),
},
BindGroupEntry {
binding: 2,
resource: instances.buffer.as_entire_binding(),
},
],
label: Some("ui masks"),
})
+275 -179
View File
@@ -11,51 +11,20 @@ use crate::{
use bytemuck::Pod;
use wgpu::*;
pub struct Primitives {
instances: Vec<PrimitiveInstance>,
assoc: Vec<WidgetId>,
data: PrimitiveData,
free: Vec<usize>,
/// Standalone images, kept apart from `instances` because each one draws
/// with its own bind group rather than sharing the layer's one instanced
/// draw -- see TEXTURES.md's "Recommended shape". `idx` on each
/// `PrimitiveInstance` here is the texture's slot in `Textures`/
/// `GpuTextures`, not an index into `data`; there is no per-image entry
/// in `data` because a bind group already picks the texture; nothing
/// left to look up per-instance.
images: Vec<PrimitiveInstance>,
image_assoc: Vec<WidgetId>,
image_free: Vec<usize>,
pub updated: bool,
}
impl Default for Primitives {
fn default() -> Self {
Self {
instances: Default::default(),
assoc: Default::default(),
data: Default::default(),
free: Vec::new(),
images: Default::default(),
image_assoc: Default::default(),
image_free: Vec::new(),
updated: true,
}
}
}
/// The `binding` tag `Painter` writes on an image instance. Distinct from any
/// `Primitive::BINDING` because images have no `PrimitiveData` entry to key
/// one from -- a bind group already selects the texture -- so this only ever
/// has to match the shader's `TEXTURE` constant and flag "this instance lives
/// in `Primitives::images`, not `Primitives::instances`" to the code below.
/// has to match the shader's `TEXTURE` constant and flag "this instance is
/// drawn with its own bind group" to the code below.
pub const IMAGE_BINDING: u32 = 1;
pub trait Primitive: Pod {
const BINDING: u32;
fn vec(data: &mut PrimitiveData) -> &mut PrimitiveVec<Self>;
/// The read-only half of [`Self::vec`], for a caller that wants to
/// look one entry up rather than write one -- a mask reading the
/// radius of the rect it clips to ([`Primitives::data`]).
fn vec_ref(data: &PrimitiveData) -> &PrimitiveVec<Self>;
}
macro_rules! primitives {
@@ -121,6 +90,9 @@ macro_rules! primitives {
fn vec(data: &mut PrimitiveData) -> &mut PrimitiveVec<Self> {
&mut data.$name
}
fn vec_ref(data: &PrimitiveData) -> &PrimitiveVec<Self> {
&data.$name
}
}
)*
};
@@ -134,18 +106,61 @@ macro_rules! primitives {
(@count $t:tt) => { 1 };
}
pub struct PrimitiveInst<P> {
pub id: WidgetId,
pub primitive: P,
pub region: UiRegion,
pub mask_idx: MaskIdx,
pub move_idx: MoveIdx,
/// Every primitive instance in the tree, in one arena that all layers
/// share, plus the per-primitive data (`rects`, `glyphs`) they index.
///
/// **Why one arena rather than one per layer**, which is what this was:
/// the fragment stage evaluates a *mask's* primitive at the masked pixel
/// (LAYOUT.md's "Masks with a shape"), and the widget that owns a mask is
/// routinely in a different layer from the content it clips -- a rounded
/// container in one layer, a `Stack`'s child content in the layer below.
/// A per-layer buffer cannot answer that lookup at all: only one layer's
/// group is bound at a time, so the mask would silently read another
/// layer's rect. Both buffers are therefore global and bound once per
/// frame, and a layer keeps only its draw *order* ([`LayerOrder`]).
///
/// Slots are stable for a primitive's whole life: nothing here is
/// compacted, so a `Mask` can hold a slot across frames.
pub struct Primitives {
instances: Vec<PrimitiveInstance>,
assoc: Vec<WidgetId>,
/// Slots freed since the last [`Self::apply_free`]. Deliberately not
/// reusable yet: the layer that drew one still names it in its draw
/// order until that call compacts the order, so handing it out again
/// first would draw the new primitive twice -- once through the stale
/// order entry and once through the new one.
freed: Vec<usize>,
/// Slots [`Self::apply_free`] released, which is what [`Self::alloc`]
/// hands out.
reusable: Vec<usize>,
data: PrimitiveData,
/// Whether the instance arena or the per-primitive data changed since
/// the last upload -- one flag for both, since they are uploaded
/// together.
pub updated: bool,
}
impl Default for Primitives {
fn default() -> Self {
Self {
instances: Default::default(),
assoc: Default::default(),
freed: Vec::new(),
reusable: Vec::new(),
data: Default::default(),
updated: true,
}
}
}
impl Primitives {
pub fn write<P: Primitive>(
/// Writes a primitive into the arena and hands back its slot and its
/// entry in the per-primitive data. The caller (`UiRenderState`) puts
/// the slot into a layer's draw order -- an instance that no layer
/// names is never rasterized, which is what a mask shape drawn only to
/// be *referenced* uses.
pub fn alloc<P: Primitive>(
&mut self,
layer: usize,
PrimitiveInst {
id,
primitive,
@@ -153,154 +168,118 @@ impl Primitives {
mask_idx,
move_idx,
}: PrimitiveInst<P>,
) -> PrimitiveHandle {
self.updated = true;
let vec = P::vec(&mut self.data);
let i = vec.add(primitive);
let inst = PrimitiveInstance {
) -> (u32, usize) {
let data_idx = P::vec(&mut self.data).add(primitive);
let slot = self.push(
PrimitiveInstance {
region,
idx: i as u32,
idx: data_idx as u32,
mask_idx,
move_idx,
binding: P::BINDING,
};
let inst_i = if let Some(i) = self.free.pop() {
self.instances[i] = inst;
self.assoc[i] = id;
i
} else {
let i = self.instances.len();
self.instances.push(inst);
self.assoc.push(id);
i
};
PrimitiveHandle::new::<P>(layer, inst_i, i)
},
id,
);
(slot, data_idx)
}
/// Writes an image instance directly -- there is no `Primitive` impl for
/// it to go through `write`, since it has nowhere in `PrimitiveData` to
/// put a per-instance entry. `texture_idx` is the slot the bind group at
/// draw time is chosen from, carried in the otherwise-unused `idx` field.
pub fn write_image(
/// A standalone image, which has no `PrimitiveData` entry to allocate
/// -- its bind group already picks the texture, so `texture_idx` rides
/// in the otherwise-unused `idx` field and names the bind group the
/// draw call selects.
pub fn alloc_image(
&mut self,
layer: usize,
id: WidgetId,
texture_idx: u32,
region: UiRegion,
mask_idx: MaskIdx,
move_idx: MoveIdx,
) -> PrimitiveHandle {
self.updated = true;
let inst = PrimitiveInstance {
) -> u32 {
self.push(
PrimitiveInstance {
region,
idx: texture_idx,
mask_idx,
move_idx,
binding: IMAGE_BINDING,
};
let inst_i = if let Some(i) = self.image_free.pop() {
self.images[i] = inst;
self.image_assoc[i] = id;
},
id,
)
}
fn push(&mut self, inst: PrimitiveInstance, id: WidgetId) -> u32 {
self.updated = true;
let slot = if let Some(i) = self.reusable.pop() {
self.instances[i] = inst;
self.assoc[i] = id;
i
} else {
let i = self.images.len();
self.images.push(inst);
self.image_assoc.push(id);
i
self.instances.push(inst);
self.assoc.push(id);
self.instances.len() - 1
};
PrimitiveHandle {
layer,
inst_idx: inst_i,
data_idx: 0,
binding: IMAGE_BINDING,
}
}
pub fn image_instances(&self) -> &Vec<PrimitiveInstance> {
&self.images
}
/// returns (old index, new index) for both lists this layer keeps --
/// `PrimitiveChange::is_image` says which, since the two have separate
/// index spaces and `old`/`new` alone would collide between them.
///
/// Both lists free with `swap_remove`, so a layer's draw order was
/// already undefined before images existed: nothing here may assume one
/// primitive stays adjacent to another once anything in the layer has
/// been freed.
pub fn apply_free(&mut self) -> Vec<PrimitiveChange> {
let mut changes =
Self::apply_free_list(&mut self.free, &mut self.instances, &mut self.assoc, false);
changes.extend(Self::apply_free_list(
&mut self.image_free,
&mut self.images,
&mut self.image_assoc,
true,
));
changes
}
fn apply_free_list(
free: &mut Vec<usize>,
instances: &mut Vec<PrimitiveInstance>,
assoc: &mut Vec<WidgetId>,
is_image: bool,
) -> Vec<PrimitiveChange> {
free.sort_by(|a, b| b.cmp(a));
free.drain(..)
.filter_map(|i| {
instances.swap_remove(i);
assoc.swap_remove(i);
if i == instances.len() {
return None;
}
let id = assoc[i];
let old = instances.len();
Some(PrimitiveChange {
id,
is_image,
old,
new: i,
})
})
.collect()
slot as u32
}
/// Retires a slot, answering the mask it was drawn under so the caller
/// can drop that mask's ref. The slot itself only becomes reusable at
/// the next [`Self::apply_free`] -- see `freed`.
pub fn free(&mut self, h: &PrimitiveHandle) -> MaskIdx {
self.updated = true;
if h.binding == IMAGE_BINDING {
self.image_free.push(h.inst_idx);
self.images[h.inst_idx].mask_idx
} else {
let slot = h.slot as usize;
if h.binding != IMAGE_BINDING {
self.data.free(h.binding, h.data_idx);
self.free.push(h.inst_idx);
self.instances[h.inst_idx].mask_idx
}
self.freed.push(slot);
self.instances[slot].mask_idx
}
/// How many instances are still bound for the GPU -- the O(1) half of
/// the orphan check, so the O(primitives) walk below only runs on a
/// frame that already looks wrong. See
/// Hands this frame's freed slots back for reuse. Called once per
/// frame from `UiRenderState::update`, **after** every layer has
/// compacted its draw order, since that order is the only thing still
/// naming them.
pub fn release_freed(&mut self) {
self.reusable.append(&mut self.freed);
}
/// Which widget drew the primitive in `slot` -- how a draw-order
/// change finds the handle it has to renumber.
pub fn owner(&self, slot: u32) -> WidgetId {
self.assoc[slot as usize]
}
pub fn clear(&mut self) {
self.updated = true;
self.instances.clear();
self.assoc.clear();
self.freed.clear();
self.reusable.clear();
self.data.clear();
}
/// How many instances are still live -- the O(1) half of the orphan
/// check, so the O(primitives) walk below only runs on a frame that
/// already looks wrong. See
/// [`crate::UiRenderState::orphaned_primitives`].
pub fn live_count(&self) -> usize {
(self.instances.len() - self.free.len()) + (self.images.len() - self.image_free.len())
self.instances.len() - self.freed.len() - self.reusable.len()
}
/// Every instance that is still bound for the GPU, as `(inst_idx,
/// owner, is_image)` -- everything except the slots already handed to
/// [`Self::free`] and waiting for [`Self::apply_free`] to compact them
/// away. Only [`crate::UiRenderState::orphaned_primitives`] uses this,
/// to check that every drawn primitive still belongs to a live widget.
pub fn live_instances(&self) -> impl Iterator<Item = (usize, WidgetId, bool)> + '_ {
let free: HashSet<usize> = self.free.iter().copied().collect();
let image_free: HashSet<usize> = self.image_free.iter().copied().collect();
let rects = (0..self.instances.len())
.filter(move |i| !free.contains(i))
.map(|i| (i, self.assoc[i], false));
let images = (0..self.images.len())
.filter(move |i| !image_free.contains(i))
.map(|i| (i, self.image_assoc[i], true));
rects.chain(images)
/// Every live instance as `(slot, owner, is_image)` -- everything
/// except the freed and the reusable. Only
/// [`crate::UiRenderState::orphaned_primitives`] uses this, to check
/// that every live primitive still belongs to a live widget.
pub fn live_instances(&self) -> impl Iterator<Item = (u32, WidgetId, bool)> + '_ {
let dead: HashSet<usize> = self.freed.iter().chain(&self.reusable).copied().collect();
(0..self.instances.len())
.filter(move |i| !dead.contains(i))
.map(|i| {
(
i as u32,
self.assoc[i],
self.instances[i].binding == IMAGE_BINDING,
)
})
}
pub fn data(&self) -> &PrimitiveData {
@@ -311,44 +290,161 @@ impl Primitives {
&self.instances
}
pub fn instance(&self, slot: u32) -> &PrimitiveInstance {
&self.instances[slot as usize]
}
/// The per-primitive data behind `slot`, or `None` if that slot holds
/// a different kind of primitive -- the `binding` check is the same
/// one the shader's dispatch switch makes, and it is what stops a
/// caller reading a glyph's index into the rect table.
pub fn primitive_data<P: Primitive>(&self, slot: u32) -> Option<&P> {
let inst = self.instance(slot);
(inst.binding == P::BINDING).then(|| &P::vec_ref(&self.data)[inst.idx as usize])
}
pub fn region_mut(&mut self, h: &PrimitiveHandle) -> &mut UiRegion {
self.updated = true;
if h.binding == IMAGE_BINDING {
&mut self.images[h.inst_idx].region
&mut self.instances[h.slot as usize].region
}
}
/// One layer's draw order: the slots of the global arena it draws, in the
/// order they were written. The vertex buffer of a layer is exactly this.
///
/// Both lists free with `swap_remove`, so a layer's draw order was already
/// undefined before this split: nothing here may assume one primitive
/// stays adjacent to another once anything in the layer has been freed.
#[derive(Default)]
pub struct LayerOrder {
order: Vec<u32>,
/// Standalone images, kept apart because each draws with its own bind
/// group rather than sharing the layer's one instanced draw -- see
/// `UiRenderNode::draw`.
images: Vec<u32>,
free: Vec<usize>,
image_free: Vec<usize>,
pub updated: bool,
}
impl LayerOrder {
pub fn push(&mut self, slot: u32, is_image: bool) -> usize {
self.updated = true;
let list = if is_image {
&mut self.images
} else {
&mut self.instances[h.inst_idx].region
&mut self.order
};
list.push(slot);
list.len() - 1
}
/// Marks a position for removal. Deferred to [`Self::apply_free`] like
/// the arena's own, so that a position is only renumbered once per
/// frame however many were dropped.
pub fn free(&mut self, pos: usize, is_image: bool) {
self.updated = true;
if is_image {
self.image_free.push(pos);
} else {
self.free.push(pos);
}
}
pub struct PrimitiveChange {
pub id: WidgetId,
/// Which of `Primitives::instances`/`Primitives::images` this change
/// belongs to -- their `old`/`new` indices are independent, so a
/// consumer matching only on `(layer, inst_idx)` could apply an image's
/// renumbering to a rect's handle that happens to share the same index.
/// Compacts both lists, answering every primitive whose position
/// moved so its handle can be corrected.
pub fn apply_free(&mut self) -> Vec<OrderChange> {
let mut changes = Self::apply_free_list(&mut self.free, &mut self.order, false);
changes.extend(Self::apply_free_list(
&mut self.image_free,
&mut self.images,
true,
));
changes
}
fn apply_free_list(
free: &mut Vec<usize>,
list: &mut Vec<u32>,
is_image: bool,
) -> Vec<OrderChange> {
// Descending, so removing a contiguous tail costs no renumbering
// at all -- which is what freeing one widget's primitives is.
free.sort_by(|a, b| b.cmp(a));
free.drain(..)
.filter_map(|pos| {
list.swap_remove(pos);
if pos == list.len() {
return None;
}
Some(OrderChange {
slot: list[pos],
is_image,
pos,
})
})
.collect()
}
pub fn order(&self) -> &Vec<u32> {
&self.order
}
pub fn images(&self) -> &Vec<u32> {
&self.images
}
}
/// A primitive whose position in a layer's draw order moved when
/// something before it was freed -- `slot` names which primitive, so its
/// owner's handle can be found and pointed at `pos`.
pub struct OrderChange {
pub slot: u32,
/// Which of the layer's two lists moved: their positions are
/// independent index spaces, so a handle matching on position alone
/// could take an image's renumbering for a rect's.
pub is_image: bool,
pub old: usize,
pub new: usize,
pub pos: usize,
}
/// Whether a primitive goes into its layer's draw order. [`Drawn::No`] is
/// a primitive written only to be *referenced* -- a mask's shape
/// (LAYOUT.md's "Masks with a shape"). It is owned, moved, resized and
/// freed exactly like any other; it is simply never rasterized.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Drawn {
Yes,
No,
}
/// The `pos` of a [`Drawn::No`] primitive: it is in no layer's order, so
/// there is no position to renumber or free.
pub const NOT_DRAWN: usize = usize::MAX;
/// Where one primitive lives: its stable slot in the global arena, and
/// where in a layer's draw order it currently sits ([`NOT_DRAWN`] if it is
/// only referenced).
#[derive(Debug)]
pub struct PrimitiveHandle {
pub layer: usize,
pub inst_idx: usize,
pub pos: usize,
pub slot: u32,
pub data_idx: usize,
pub binding: u32,
}
impl PrimitiveHandle {
fn new<P: Primitive>(layer: usize, inst_idx: usize, data_idx: usize) -> Self {
Self {
layer,
inst_idx,
data_idx,
binding: P::BINDING,
pub fn is_image(&self) -> bool {
self.binding == IMAGE_BINDING
}
}
pub struct PrimitiveInst<P> {
pub id: WidgetId,
pub primitive: P,
pub region: UiRegion,
pub mask_idx: MaskIdx,
pub move_idx: MoveIdx,
}
primitives!(
+54
View File
@@ -0,0 +1,54 @@
//! The rounded-rect coverage function, on the CPU.
//!
//! `shader.wgsl`'s `distance_from_rect`/`rounded_rect_coverage` are a
//! transliteration of these two, line for line, and
//! `mask_sdf_matches_the_shader` in `iris`'s layout tests compares the two
//! at a grid of points against values the shader itself produced. They are
//! kept together here, in the crate both a renderer and a hit test can
//! reach, because LAYOUT.md's "Masks with a shape" turns on the two
//! agreeing: a masked corner that cannot be tapped and a masked corner
//! that is not drawn have to be the same corner, and they are only the
//! same corner while one function decides both.
//!
//! Window pixels throughout, matching the shader's `pos` -- not `UiRegion`
//! units, which the shader has already resolved by the time it evaluates
//! this.
use crate::util::Vec2;
/// The signed distance from `pos` to a rounded rect given by its centre,
/// its corner offset (half its size) and its corner `radius`. Negative
/// inside.
pub fn distance_from_rect(pos: Vec2, center: Vec2, corner: Vec2, radius: f32) -> f32 {
// vec from center to pixel
let p = pos - center;
// vec from inner rect corner to pixel
let q = Vec2::new(
p.x.abs() - (corner.x - radius),
p.y.abs() - (corner.y - radius),
);
let clamped = Vec2::new(q.x.max(0.0), q.y.max(0.0));
(clamped.x * clamped.x + clamped.y * clamped.y).sqrt() - radius
}
/// How much of the pixel at `pos` a rounded rect covers, anti-aliased over
/// the half-pixel either side of its edge: 1 well inside, 0 well outside.
///
/// The half-pixel feather is why a hit test asks for **more than a half**
/// rather than "any coverage at all": half is where the geometric edge is,
/// so the two answer the same question the drawn shape does.
pub fn rounded_rect_coverage(pos: Vec2, top_left: Vec2, bot_right: Vec2, radius: f32) -> f32 {
let edge: f32 = 0.5;
let corner = (bot_right - top_left) / 2.0;
let center = top_left + corner;
let dist = distance_from_rect(pos, center, corner, radius);
1.0 - smoothstep(-edge.min(radius), edge, dist)
}
/// WGSL's `smoothstep`, which Rust has no equivalent of. Undefined in WGSL
/// when `low == high`, which is why the caller above never passes a zero
/// radius into the low edge without `edge` bounding it.
fn smoothstep(low: f32, high: f32, x: f32) -> f32 {
let t = ((x - low) / (high - low)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
+110 -48
View File
@@ -30,10 +30,12 @@ struct GlyphInfo {
flags: u32,
}
/// Mirrors `Mask` in data.rs: the slot of the primitive whose coverage
/// clips this mask's subtree, and the mask it nests inside
/// (`4294967295u` at the top).
struct Mask {
x: UiSpan,
y: UiSpan,
move_idx: u32,
primitive: u32,
parent: u32,
}
/// One widget's cumulative on-screen translation and the slot of the
@@ -53,11 +55,6 @@ struct UiScalar {
abs: f32,
}
struct UiVec2 {
rel: vec2<f32>,
abs: vec2<f32>,
}
// The shared glyph atlas: every page is one layer. Growing it recreates this
// texture with headroom and copies the old layers across -- see
// GpuTextures::grow_array -- rather than the binding_array<texture_2d<f32>>
@@ -79,8 +76,15 @@ var samp: sampler;
var<storage> masks: array<Mask>;
@group(3) @binding(1)
var<storage> move_offsets: array<MoveOffset>;
// Every primitive's placement, in one arena all layers share. The vertex
// stage reads the primitive it is drawing (its slot arrives as the only
// vertex attribute); the fragment stage reads a *mask's* primitive, which
// is generally a different one in a different layer. See LAYOUT.md's
// "Masks with a shape" and `Primitives` in primitive.rs.
@group(3) @binding(2)
var<storage> instances: array<PrimitiveInstance>;
// The bound on the parent walk, kept in step with `MOVE_CHAIN_LIMIT` in
// The bound on the parent walk, kept in step with `PARENT_CHAIN_LIMIT` in
// render_state.rs, which walks the identical chain on the CPU side for
// hit-testing. Bounded so a malformed chain (a cyclic `parent`) cannot
// hang the GPU -- not a claim about how deep a real tree gets. It was 16
@@ -90,7 +94,7 @@ var<storage> move_offsets: array<MoveOffset>;
// Past the bound both walks simply stop summing, so the widget draws and
// hit-tests short by whatever the outer slots held, with nothing on
// screen to say so.
const MOVE_CHAIN_LIMIT: u32 = 64u;
const PARENT_CHAIN_LIMIT: u32 = 64u;
/// Sums the pixel delta along the parent chain starting at `idx`, shared by
/// the vertex stage (a primitive's own corners) and the fragment stage (its
@@ -98,7 +102,7 @@ const MOVE_CHAIN_LIMIT: u32 = 64u;
fn resolve_move(idx: u32) -> vec2<f32> {
var total = vec2<f32>(0.0, 0.0);
var i = idx;
for (var step = 0u; step < MOVE_CHAIN_LIMIT; step++) {
for (var step = 0u; step < PARENT_CHAIN_LIMIT; step++) {
let entry = move_offsets[i];
total += entry.delta;
if entry.parent == 4294967295u {
@@ -113,24 +117,31 @@ struct WindowUniform {
dim: vec2<f32>,
};
/// Mirrors `PrimitiveInstance` in data.rs -- the placement and what to
/// draw there. `x`/`y` are the `UiRegion`'s two spans.
struct PrimitiveInstance {
x: UiSpan,
y: UiSpan,
binding: u32,
idx: u32,
mask_idx: u32,
move_idx: u32,
}
/// A layer's draw order: one slot into `instances` per instance drawn.
struct InstanceInput {
@location(0) x_start: vec2<f32>,
@location(1) x_end: vec2<f32>,
@location(2) y_start: vec2<f32>,
@location(3) y_end: vec2<f32>,
@location(4) binding: u32,
@location(5) idx: u32,
@location(6) mask_idx: u32,
@location(7) move_idx: u32,
@location(0) slot: u32,
}
struct VertexOutput {
@location(0) top_left: vec2<f32>,
@location(1) bot_right: vec2<f32>,
@location(2) uv: vec2<f32>,
@location(3) binding: u32,
@location(4) idx: u32,
@location(5) mask_idx: u32,
// `flat` is the only interpolation an integer can have, and naga
// (wgpu 30) now requires saying so rather than inferring it.
@location(3) @interpolate(flat) binding: u32,
@location(4) @interpolate(flat) idx: u32,
@location(5) @interpolate(flat) mask_idx: u32,
@builtin(position) clip_position: vec4<f32>,
};
@@ -141,21 +152,38 @@ struct Region {
bot_right: vec2<f32>,
}
/// One primitive's on-screen corners in window pixels. Written once and
/// used by both stages: the vertex stage for the primitive it is drawing,
/// the fragment stage for a mask's -- so the shape a mask clips to and the
/// shape that was drawn cannot be computed two different ways.
struct Corners {
top_left: vec2<f32>,
bot_right: vec2<f32>,
}
fn corners_of(inst: PrimitiveInstance) -> Corners {
let top_left_rel = vec2(inst.x.start.rel, inst.y.start.rel);
let top_left_abs = vec2(inst.x.start.abs, inst.y.start.abs);
let bot_right_rel = vec2(inst.x.end.rel, inst.y.end.rel);
let bot_right_abs = vec2(inst.x.end.abs, inst.y.end.abs);
let move_delta = resolve_move(inst.move_idx);
return Corners(
floor(top_left_rel * window.dim) + floor(top_left_abs) + move_delta,
floor(bot_right_rel * window.dim) + floor(bot_right_abs) + move_delta,
);
}
@vertex
fn vs_main(
@builtin(vertex_index) vi: u32,
in: InstanceInput,
) -> VertexOutput {
var out: VertexOutput;
let inst = instances[in.slot];
let top_left_rel = vec2(in.x_start.x, in.y_start.x);
let top_left_abs = vec2(in.x_start.y, in.y_start.y);
let bot_right_rel = vec2(in.x_end.x, in.y_end.x);
let bot_right_abs = vec2(in.x_end.y, in.y_end.y);
let move_delta = resolve_move(in.move_idx);
let top_left = floor(top_left_rel * window.dim) + floor(top_left_abs) + move_delta;
let bot_right = floor(bot_right_rel * window.dim) + floor(bot_right_abs) + move_delta;
let c = corners_of(inst);
let top_left = c.top_left;
let bot_right = c.bot_right;
let size = bot_right - top_left;
let uv = vec2<f32>(
@@ -165,11 +193,11 @@ fn vs_main(
let pos = (top_left + uv * size) / window.dim * 2.0 - 1.0;
out.clip_position = vec4<f32>(pos.x, -pos.y, 0.0, 1.0);
out.uv = uv;
out.binding = in.binding;
out.idx = in.idx;
out.binding = inst.binding;
out.idx = inst.idx;
out.top_left = top_left;
out.bot_right = bot_right;
out.mask_idx = in.mask_idx;
out.mask_idx = inst.mask_idx;
return out;
}
@@ -196,21 +224,39 @@ fn fs_main(
color = vec4(1.0, 0.0, 1.0, 1.0);
}
}
if in.mask_idx != 4294967295u {
let mask = masks[in.mask_idx];
let mask_delta = resolve_move(mask.move_idx);
let tl = UiVec2(vec2(mask.x.start.rel, mask.y.start.rel), vec2(mask.x.start.abs, mask.y.start.abs));
let br = UiVec2(vec2(mask.x.end.rel, mask.y.end.rel), vec2(mask.x.end.abs, mask.y.end.abs));
let top_left = floor(tl.rel * window.dim) + floor(tl.abs) + mask_delta;
let bot_right = floor(br.rel * window.dim) + floor(br.abs) + mask_delta;
if pos.x < top_left.x || pos.x > bot_right.x || pos.y < top_left.y || pos.y > bot_right.y {
color *= 0.0;
// Every mask on the chain, not just the innermost: a widget that set
// its own mask inside another is clipped by both, and the coverages
// multiply -- so a pixel inside two feathered corners is dimmed by
// both, which is what a compositor does (`Mask::parent` in data.rs).
var mask_idx = in.mask_idx;
for (var step = 0u; step < PARENT_CHAIN_LIMIT; step++) {
if mask_idx == 4294967295u {
break;
}
let mask = masks[mask_idx];
color.a *= mask_coverage(pos, mask);
mask_idx = mask.parent;
}
return color;
}
/// How much of `pos` one mask lets through: the referenced primitive's
/// own coverage at that pixel, from the same SDF the primitive is drawn
/// with. Nothing about the shape is copied into the mask, so a rounded
/// container's corner and its children's clipped corner are the same
/// arithmetic.
fn mask_coverage(pos: vec2<f32>, mask: Mask) -> f32 {
let inst = instances[mask.primitive];
if inst.binding != RECT {
// Unreachable: `Painter::set_mask` rejects a glyph or an image
// shape by name (see `Mask::primitive`). Letting the pixel
// through rather than reading a `rects` entry that is not there.
return 1.0;
}
let c = corners_of(inst);
return rounded_rect_coverage(pos, c.top_left, c.bot_right, rects[inst.idx].radius);
}
fn draw_texture(region: Region) -> vec4<f32> {
return textureSample(image_texture, samp, region.uv);
}
@@ -226,19 +272,35 @@ fn draw_glyph(region: Region, g: GlyphInfo) -> vec4<f32> {
return color;
}
/// The anti-aliased coverage of a rounded rect at one pixel -- the one
/// function both a drawn rect and a mask go through, and the
/// transliteration of `iris_core::rounded_rect_coverage` on the CPU,
/// which the hit test uses so a corner that cannot be tapped and a corner
/// that is not drawn are the same corner.
fn rounded_rect_coverage(
pos: vec2<f32>,
top_left: vec2<f32>,
bot_right: vec2<f32>,
radius: f32,
) -> f32 {
let edge = 0.5;
let corner = (bot_right - top_left) / 2.0;
let center = top_left + corner;
let dist = distance_from_rect(pos, center, corner, radius);
return 1.0 - smoothstep(-min(edge, radius), edge, dist);
}
fn draw_rounded_rect(region: Region, rect: Rect) -> vec4<f32> {
var color = unpack4x8unorm(rect.color);
let edge = 0.5;
color.a *= rounded_rect_coverage(region.pos, region.top_left, region.bot_right, rect.radius);
if rect.thickness > 0.0 {
let size = region.bot_right - region.top_left;
let corner = size / 2.0;
let center = region.top_left + corner;
let dist = distance_from_rect(region.pos, center, corner, rect.radius);
color.a *= 1.0 - smoothstep(-min(edge, rect.radius), edge, dist);
if rect.thickness > 0.0 {
let dist2 = distance_from_rect(region.pos, center, corner - rect.thickness, rect.inner_radius);
color.a *= smoothstep(-min(edge, rect.inner_radius), edge, dist2);
}
+1 -1
View File
@@ -62,7 +62,7 @@ pub struct ActiveData {
/// the same answer rather than drifting), and both then rewrite the
/// slot from the sum -- which is what lets a parent both move a child
/// with its own layout and place it inside that moved region in one
/// frame. `List::place`'s Bottom-known branch does exactly that once a
/// frame. `LazySpan::place`'s Bottom-known branch does exactly that once a
/// row's blocks wrap. Reset to zero on a real redraw, with
/// `move_applied` and the slot itself.
pub repositioned: Vec2,
+1 -1
View File
@@ -25,7 +25,7 @@ pub struct UiData {
/// never goes stale -- see LAYOUT.md section 2.
pub move_offsets: TrackedArena<MoveOffset, u32>,
/// Every widget whose [`crate::Widget::tick`] should run before the
/// next frame -- today, a `List` coasting through a fling. Added by
/// next frame -- today, a `LazySpan` coasting through a fling. Added by
/// [`Self::animate`] when the animation starts and removed by
/// [`Self::tick_animations`] the frame its `tick` answers `false`, so
/// a stopped animation costs nothing and a dropped widget cannot be
+147 -13
View File
@@ -1,7 +1,10 @@
use crate::{
RenderedText, Size, StrongWidget, TextAttrs, TextBuffer, TextData, TextureHandle, UiRegion,
UiRenderState, UiRsc, UiScalar, UiVec2, WidgetId,
render::{GlyphPrimitive, Mask, MaskIdx, MoveIdx, Primitive, PrimitiveHandle, PrimitiveInst},
Color, RenderedText, Size, StrongWidget, TextAttrs, TextBuffer, TextData, TextureHandle,
UiRegion, UiRenderState, UiRsc, UiScalar, UiVec2, WidgetId,
render::{
Drawn, GlyphPrimitive, Mask, MaskIdx, MoveIdx, Primitive, PrimitiveHandle, PrimitiveInst,
RectPrimitive,
},
util::Vec2,
};
@@ -26,8 +29,20 @@ pub struct Painter<'a> {
impl<'a> Painter<'a> {
fn primitive_at<P: Primitive>(&mut self, primitive: P, region: UiRegion) {
let h = self.state.layers.write(
self.write_primitive(primitive, region, Drawn::Yes);
}
/// The one path every primitive this widget owns goes through --
/// drawn or, for a mask's shape, only referenced.
fn write_primitive<P: Primitive>(
&mut self,
primitive: P,
region: UiRegion,
drawn: Drawn,
) -> u32 {
let h = self.state.write_primitive(
self.layer,
drawn,
PrimitiveInst {
id: self.id,
primitive,
@@ -40,7 +55,9 @@ impl<'a> Painter<'a> {
// TODO: I have no clue if this works at all :joy:
self.rsc.ui_mut().masks.push_ref(self.mask);
}
let slot = h.slot;
self.primitives.push(h);
slot
}
/// Writes a primitive to be rendered
@@ -53,8 +70,16 @@ impl<'a> Painter<'a> {
}
/// Clip everything this widget draws, itself and its descendants, to
/// `region`. One per widget: a second call would need the two to be
/// intersected, which nothing here does.
/// `region`. One call per widget; a widget drawn inside another
/// widget's mask nests instead -- the new mask chains to the inherited
/// one (`Mask::parent`) and the fragment stage multiplies both
/// coverages, which is what lets a transcript row's code fence clip
/// to itself *and* to the list it scrolls inside.
///
/// The clip is a **primitive**, not a rectangle copied into the mask:
/// this writes an undrawn `RectPrimitive` at `region` and points the
/// mask at it, so the fragment stage evaluates the same rounded-rect
/// coverage a drawn rect gets. See LAYOUT.md's "Masks with a shape".
///
/// The slot is allocated once and **rewritten in place** on every
/// later draw rather than pushed again, because a descendant whose own
@@ -62,24 +87,131 @@ impl<'a> Painter<'a> {
/// so keeps pointing at whichever slot it was drawn under. See
/// `ActiveData::own_mask` for what pushing a fresh one cost.
pub fn set_mask(&mut self, region: UiRegion) {
assert!(self.mask == MaskIdx::NONE);
let shape = self.write_primitive(RectPrimitive::color(Color::NONE), region, Drawn::No);
self.set_mask_to(shape);
}
/// Clip everything this widget draws after this call to `shape`'s
/// own shape -- the first primitive `shape`'s subtree drew, which
/// must already have been drawn this frame
/// (`UiRenderState::first_primitive`). What `.masked_by()` uses to
/// clip a container's content to the rounded background it draws,
/// with no radius argument anywhere that could fall out of step with
/// the one being drawn.
pub fn set_mask_to_widget<W: ?Sized>(&mut self, shape: &StrongWidget<W>) {
let slot = self.state.first_primitive(shape.id()).unwrap_or_else(|| {
panic!(
"'{}' was given as a mask's shape but drew no primitive, so there is nothing to \
clip to",
self.rsc.widgets().label(shape.id()),
)
});
self.set_mask_to(slot);
}
/// Points this widget's mask at a primitive that has already been
/// written -- the shared half of [`Self::set_mask`].
fn set_mask_to(&mut self, shape: u32) {
// `assert!`, not `debug_assert!`: one comparison per widget draw,
// and the second call silently *replacing* the first is a widget
// drawn unclipped -- which reaches the screen and nothing says so.
// Every build anybody runs here is release
// (docs/REVIEW-2026-09-07.md's R1).
assert!(
self.own_mask == MaskIdx::NONE || self.mask != self.own_mask,
"set_mask called twice while drawing one widget: the second would replace the first \
rather than nest inside it",
);
// A glyph would need a CPU-side alpha plane for the hit test to
// agree with the shader, and a standalone image a bind-group
// switch the fragment stage cannot make -- see `Mask::primitive`.
// Named here rather than left to the shader, which would read a
// rect that is not there and clip to nothing.
let binding = self.state.primitives.instance(shape).binding;
assert_eq!(
binding,
RectPrimitive::BINDING,
"a mask's shape must be a rect primitive; primitive {shape} is binding {binding}",
);
let parent = self.mask;
let mask = Mask {
region,
move_idx: self.move_slot,
primitive: shape,
parent,
};
if self.own_mask == MaskIdx::NONE {
let old_parent = if self.own_mask == MaskIdx::NONE {
let slot = self.rsc.ui_mut().masks.push(mask);
// The one ref this widget holds on its own slot, so the slot
// outlives any single frame's primitives; released in
// `UiRenderState::remove`'s `undraw` branch.
self.rsc.ui_mut().masks.push_ref(slot);
self.own_mask = slot;
MaskIdx::NONE
} else {
let old = self.rsc.ui().masks[self.own_mask.idx()].parent;
*self.rsc.ui_mut().masks.get_mut(self.own_mask) = mask;
old
};
// The chain link's own ref, taken before the old one is dropped so
// that re-chaining to the same slot cannot free it in between.
// Released here when the link changes, and in
// `UiRenderState::remove` when this widget's slot goes.
if old_parent != parent {
if parent != MaskIdx::NONE {
self.rsc.ui_mut().masks.push_ref(parent);
}
if old_parent != MaskIdx::NONE {
self.rsc.ui_mut().masks.remove(old_parent);
}
}
self.mask = self.own_mask;
}
/// Ask a child whether it positions its own content
/// ([`Widget::scrolls_itself`]) -- read through `get_dyn`, which does
/// **not** mark it dirty, which is the whole reason this is a separate
/// question from [`Self::apply_scroll`] rather than something that
/// falls out of calling it. `false` for a child that has gone.
pub fn scrolls_itself<W: ?Sized>(&self, id: &StrongWidget<W>) -> bool {
self.rsc
.widgets()
.get_dyn(id.id())
.is_some_and(|w| w.scrolls_itself())
}
/// Hand a child a scroll delta to take what it can of
/// ([`Widget::apply_scroll`]), leaving the rest in `delta`.
///
/// Reaching the child mutably is what marks it for a real redraw
/// (`Widgets::get_dyn_mut`), so a caller that follows this with
/// another `widget_within` at the same region gets a genuine draw
/// rather than `draw_inner`'s unchanged-region skip -- which is
/// exactly the measure-then-place shape `Scroll` uses, and why there
/// is no "mark this for another frame" call in this type.
pub fn apply_scroll<W: ?Sized>(&mut self, id: &StrongWidget<W>, delta: &mut f32) {
if let Some(w) = self.rsc.widgets_mut().get_dyn_mut(id.id()) {
w.apply_scroll(delta);
}
}
/// Read a self-positioning child's accumulated content movement
/// ([`Widget::scroll_offset`]). Through `get_dyn`, so it does not mark
/// the child dirty. `0.0` for a child that has gone.
pub fn scroll_offset<W: ?Sized>(&self, id: &StrongWidget<W>) -> f32 {
self.rsc
.widgets()
.get_dyn(id.id())
.map_or(0.0, |w| w.scroll_offset())
}
/// Whether anything is clipping what this widget draws -- its own
/// [`Self::set_mask`], or one an ancestor set that it inherited. What
/// a widget whose contents may legitimately extend past its own box
/// (`iris::widget::LazySpan`, which draws a row straddling an edge in
/// full) asserts before relying on being cut off there.
pub fn is_masked(&self) -> bool {
self.mask != MaskIdx::NONE
}
/// Draws a widget within this widget's region, returning the size it
/// reported using.
pub fn widget<W: ?Sized>(&mut self, id: &StrongWidget<W>) -> Size {
@@ -170,7 +302,7 @@ impl<'a> Painter<'a> {
/// the layer's one instanced draw, so it goes through
/// `Primitives::write_image` instead of `primitive_at`/`Primitive::vec`.
fn write_image(&mut self, texture_idx: u32, region: UiRegion) {
let h = self.state.layers.write_image(
let h = self.state.write_image(
self.layer,
self.id,
texture_idx,
@@ -216,8 +348,10 @@ impl<'a> Painter<'a> {
// A caller re-emitting quads placed against an atlas that has since
// been cleared draws every glyph from coordinates now holding
// something else. Caught at the submission rather than on screen,
// where it reads as fragments of unrelated letters.
debug_assert_eq!(
// where it reads as fragments of unrelated letters. `assert_eq!`
// for R1's reason: two integers per laid-out string, not per
// glyph, and the failure is unreadable text on a release build.
assert_eq!(
text.generation,
self.atlas_generation(),
"glyphs placed against atlas generation {} submitted against {}: the holder did not \
+403 -82
View File
@@ -1,12 +1,37 @@
use std::sync::Mutex;
use std::time::{Duration, Instant};
use crate::{
ActiveData, IdLike, MaskIdx, MoveIdx, Painter, PixelRegion, PrimitiveLayers, RegionAlign,
StrongWidget, UiRegion, UiRsc, UiVec2, WidgetId, Widgets,
render::{IMAGE_BINDING, MoveOffset},
render::{
Drawn, MoveOffset, NOT_DRAWN, Primitive, PrimitiveHandle, PrimitiveInst, Primitives,
RectPrimitive, rounded_rect_coverage,
},
util::{HashMap, HashSet, Id, Vec2},
};
/// What [`UiRenderState::update`] did on its last call -- read back by the
/// `iris::frame` diagnostic (`iris::diagnostics::log_frame` in the `iris`
/// crate) so a report can tell a full relayout from a frame that only
/// redrew a handful of dirty widgets from one that drew nothing at all.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RedrawKind {
/// Neither the root nor any widget changed -- `update` did nothing.
None,
/// [`UiRenderState::redraw_all`]: a new root, or a resize.
All,
/// [`UiRenderState::redraw_updates`]: only the widgets `needs_redraw`
/// named.
Updates,
}
pub struct UiRenderState {
pub active: HashMap<WidgetId, ActiveData>,
/// Every primitive in the tree, in one arena -- see [`Primitives`] for
/// why it is not per layer.
pub primitives: Primitives,
/// What each layer draws, in order: slots into `primitives`.
pub layers: PrimitiveLayers,
pub(super) output_size: Vec2,
/// Physical pixels per `dp` -- see `Len::dp`'s field doc. `1.0` (an
@@ -32,21 +57,6 @@ pub struct UiRenderState {
/// ever drawn and was never emptied.
draw_started: HashSet<WidgetId>,
/// The widget currently holding exclusive pointer input, if any --
/// `iris::sense::SensorUi::run_sensors` reads and clears this every
/// call. Interior mutability (a `Mutex`, not a bare `Cell`, since a
/// `CursorData` reaching this through an async `task_on` handler needs
/// `Send`/`Sync`) because `run_sensors` takes `&self` (widgets are
/// dispatched to, not owned, at that layer) and this render state is
/// the one structure both backends (winit, android-view) already hold
/// across frames, the same way `old_root`/`resized` are -- see
/// `iris::sense`'s pointer-capture doc for why a drag needs this: once
/// a gesture has committed to panning or selecting, every later sample
/// of it must reach the same widget even if the finger has moved off
/// whatever hit region first noticed the press. Never held across an
/// await or another lock -- every access here is a single get/set.
captured: std::sync::Mutex<Option<WidgetId>>,
/// `Widget::draw` calls and `Primitives::region_mut` rewrites since the
/// last `take_counters`. LAYOUT.md section 8's pass conditions are
/// stated in terms of these two: an unchanged frame must cost 0 of
@@ -58,6 +68,30 @@ pub struct UiRenderState {
/// Text layouts actually computed -- bumped by `Painter::render_text`,
/// which `TextView::render` only reaches on a cache miss.
pub(super) shape_count: u64,
/// `Instant::now()` at construction -- the zero every `iris::frame` line
/// dates itself from, so a report's `now=` is comparable to a harness's
/// own `t_ms` (`Harness::new` builds its `base` the same way, in the
/// same constructor call) without either side needing the wall clock.
epoch: Instant,
/// How many times [`Self::update`] has run -- the `iris::frame` line's
/// frame number. Counts every call, including one that found nothing to
/// redraw, so a gap in the sequence in a report is a frame this state
/// was never asked to run at all (a stalled event loop), not one that
/// ran and did nothing.
frame_no: u64,
/// How long the redraw phase of the last [`Self::update`] took --
/// [`Self::redraw_all`] or [`Self::redraw_updates`], whichever ran, or
/// zero if neither did. Read back by `iris::diagnostics::log_frame`.
last_layout: Duration,
last_redraw_kind: RedrawKind,
/// When the sensor dispatch (`SensorUi::run_sensors`, in the `iris`
/// crate) last saw an input sample, dated by the sample's own clock
/// (`CursorState::time`) rather than when the dispatch ran -- same
/// reasoning as that field's own doc. A `Mutex` because `run_sensors`
/// takes `&self` and this is the one render state both backends
/// already share across frames.
last_input_at: Mutex<Option<Instant>>,
}
/// The bound on the parent walk -- see `resolve_move` in shader.wgsl,
@@ -70,23 +104,33 @@ pub struct UiRenderState {
/// prints the chain). A chain past the bound is not reported anywhere at
/// run time; both walks just stop summing, so the widget is drawn and hit
/// tested short by whatever the outer slots held.
pub const MOVE_CHAIN_LIMIT: usize = 64;
///
/// Named for the walk rather than for one of its two subjects: it bounds
/// the move-offset chain *and* the mask chain (`Mask::parent`, walked in
/// the fragment stage), and `MOVE_CHAIN_LIMIT` said only the first
/// (docs/REVIEW-2026-09-07.md).
pub const PARENT_CHAIN_LIMIT: usize = 64;
impl UiRenderState {
pub fn new() -> Self {
Self {
active: Default::default(),
primitives: Default::default(),
layers: Default::default(),
output_size: Vec2::ZERO,
density: 1.0,
old_root: None,
resized: false,
draw_started: Default::default(),
captured: Default::default(),
draw_count: 0,
region_mut_count: 0,
mov_count: 0,
shape_count: 0,
epoch: Instant::now(),
frame_no: 0,
last_layout: Duration::ZERO,
last_redraw_kind: RedrawKind::None,
last_input_at: Mutex::new(None),
}
}
@@ -108,6 +152,75 @@ impl UiRenderState {
)
}
/// Writes a primitive into the arena and, unless it is
/// [`Drawn::No`], into `layer`'s draw order.
pub(super) fn write_primitive<P: Primitive>(
&mut self,
layer: usize,
drawn: Drawn,
inst: PrimitiveInst<P>,
) -> PrimitiveHandle {
let (slot, data_idx) = self.primitives.alloc(inst);
let pos = match drawn {
Drawn::Yes => self.layers[layer].push(slot, false),
Drawn::No => NOT_DRAWN,
};
PrimitiveHandle {
layer,
pos,
slot,
data_idx,
binding: P::BINDING,
}
}
/// A standalone image, which draws with its own bind group rather
/// than sharing the layer's one instanced draw.
pub(super) fn write_image(
&mut self,
layer: usize,
id: WidgetId,
texture_idx: u32,
region: UiRegion,
mask_idx: MaskIdx,
move_idx: MoveIdx,
) -> PrimitiveHandle {
let slot = self
.primitives
.alloc_image(id, texture_idx, region, mask_idx, move_idx);
let pos = self.layers[layer].push(slot, true);
PrimitiveHandle {
layer,
pos,
slot,
data_idx: 0,
binding: crate::render::IMAGE_BINDING,
}
}
/// Compacts every layer's draw order around the primitives freed
/// this frame, corrects the handles that moved, and only then hands
/// the arena slots back for reuse -- that order is the whole reason
/// `Primitives::freed` exists. Once per frame, at the end of
/// [`Self::update`], so the harness (which has no renderer) applies
/// it exactly as a real backend does.
fn apply_free(&mut self) {
for (layer, order) in self.layers.iter_mut() {
for change in order.apply_free() {
let owner = self.primitives.owner(change.slot);
if let Some(active) = self.active.get_mut(&owner) {
for h in &mut active.primitives {
if h.layer == layer && h.slot == change.slot {
h.pos = change.pos;
break;
}
}
}
}
}
self.primitives.release_freed();
}
pub fn resize(&mut self, size: impl Into<Vec2>) {
self.output_size = size.into();
self.resized = true;
@@ -119,7 +232,16 @@ impl UiRenderState {
/// different triggers (a surface resize on every rotation or keyboard
/// open; a density change only if the app follows the display to a
/// different screen, which Android surfaces separately).
///
/// Marks the tree for a full redraw when the value actually changes:
/// every `Len::dp` already resolved and every glyph already shaped
/// (`Text::shape` keys its cache on `(attrs, width, density)`) belongs
/// to the old one, and nothing else would ask for them again
/// (docs/REVIEW-2026-09-07.md's R5).
pub fn set_density(&mut self, density: f32) {
if density != self.density {
self.resized = true;
}
self.density = density;
}
@@ -150,17 +272,94 @@ impl UiRenderState {
"a previous frame left {} widget(s) marked as mid-draw",
self.draw_started.len(),
);
if self.needs_redraw_all(root) {
// Timed unconditionally -- an `Instant::now()` pair is cheap enough
// not to move the `--phone` bench's frame time (checked when this
// was added), and gating it behind the trace toggle would leave
// `iris::frame` with nothing to report the one frame somebody just
// turned tracing on to look at.
let layout_start = Instant::now();
let kind = if self.needs_redraw_all(root) {
self.redraw_all(root, rsc);
self.old_root = root.map(|r| r.id());
self.resized = false;
RedrawKind::All
} else if rsc.widgets().has_updates() {
self.redraw_updates(rsc);
}
RedrawKind::Updates
} else {
RedrawKind::None
};
self.last_layout = layout_start.elapsed();
self.last_redraw_kind = kind;
self.frame_no += 1;
// After the redraw and before anything reads the frame: every
// slot freed above is still named by its layer's draw order until
// this runs.
self.apply_free();
#[cfg(debug_assertions)]
debug_assert!(self.primitive_counts_agree(), "{}", self.orphan_report(rsc),);
}
/// `Instant::now()` at construction -- see the field's own doc.
pub fn epoch(&self) -> Instant {
self.epoch
}
/// How many times [`Self::update`] has run, counting from 1.
pub fn frame_number(&self) -> u64 {
self.frame_no
}
/// How long the last [`Self::update`]'s redraw phase took.
pub fn last_layout_duration(&self) -> Duration {
self.last_layout
}
/// What the last [`Self::update`] did -- see [`RedrawKind`].
pub fn last_redraw_kind(&self) -> RedrawKind {
self.last_redraw_kind
}
/// Records that a real input sample was just dispatched, dated by the
/// sample's own clock -- called once per sensor pass, so `iris::frame`'s
/// `since_input` can answer "how stale was the input
/// this frame drew" instead of a caller guessing from the frame
/// interval. `&self` because `run_sensors` only ever has that -- see
/// `last_input_at`'s field doc.
pub fn note_input(&self, at: Instant) {
if let Ok(mut guard) = self.last_input_at.lock() {
*guard = Some(at);
}
}
/// `now - ` the last input sample's own timestamp, or `None` if no
/// input has ever reached this render state (a cold start, or a screen
/// that only ever animates on its own). Saturates to zero rather than
/// panicking if `now` is earlier than the input sample somehow was --
/// a diagnostic reading wrong is not worth a crash over.
pub fn time_since_input(&self, now: Instant) -> Option<Duration> {
let at = *self.last_input_at.lock().ok()?;
at.map(|at| now.saturating_duration_since(at))
}
/// Primitive instances every currently-active widget owns, summed --
/// what `iris::frame`'s `primitives=` reports. Not a per-frame delta:
/// `redraw_updates` only rewrites what changed, so this is "how much is
/// on screen", which is what a report reads as "did this frame have
/// more to draw than the last one", not "how much work did this frame
/// do" (`take_counters` answers that).
///
/// A mask's shape does not count: it is a [`Drawn::No`] primitive
/// that is never rasterized, so including it would put one extra on
/// the line for every masked widget and make a number Iris reads off
/// a phone report disagree with what is drawn.
pub fn active_primitive_count(&self) -> usize {
self.active
.values()
.map(|a| a.primitives.iter().filter(|h| h.pos != NOT_DRAWN).count())
.sum()
}
fn redraw_all(&mut self, root: Option<&StrongWidget>, rsc: &mut dyn UiRsc) {
self.clear(rsc);
// free all resources & cache
@@ -218,13 +417,13 @@ impl UiRenderState {
// Consumed here, not merely read: this call *is* the redraw the mark
// asked for, and leaving the mark set is what stranded a widget's
// primitives. `Painter::draw_twice` calls this twice for the same id
// in one frame (`List::place`'s measurement pass), and on the second
// in one frame (`LazySpan::place`'s measurement pass), and on the second
// call the still-set mark took the whole `if let` below -- including
// the `remove` that frees the first draw's primitives -- out of play,
// so `active.insert` at the end overwrote the only handles that could
// ever have freed them. The result is a full second copy of the row,
// drawn every frame from then on at the oversized measurement region
// and, with `List` setting no mask, outside the list's own bounds:
// and, with `LazySpan` setting no mask, outside the list's own bounds:
// the doubled `Compacted:` row in docs/bench/iris-phone-v2-2026-09-06.md.
// The same shape reaches any dirty widget an ancestor redraws first.
let dirty = rsc.widgets_mut().needs_redraw.remove(&id);
@@ -252,7 +451,7 @@ impl UiRenderState {
// instead of redrawing. See LAYOUT.md section 3.
let from = active.region;
for h in &active.primitives {
let r = self.layers[h.layer].region_mut(h);
let r = self.primitives.region_mut(h);
*r = r.outside(&from).within(&region);
self.region_mut_count += 1;
}
@@ -324,10 +523,9 @@ impl UiRenderState {
// own new one -- and `ActiveData::mask`'s only consumer is
// `redraw`, which feeds it back in as the *inherited* mask. Storing
// the set one instead handed a `Masked` its own mask on every
// targeted redraw, tripping `set_mask`'s nested-mask assert:
// `assertion failed: self.mask == MaskIdx::NONE`, an abort the
// first time the composer's scroll area was redrawn on the
// emulator.
// targeted redraw -- an abort the first time the composer's scroll
// area was redrawn on the emulator, and now (masks nest) a mask
// whose parent is itself, which `set_mask`'s own assert names.
let inherited_mask = mask;
let mut painter = Painter {
state: self,
@@ -490,21 +688,26 @@ impl UiRenderState {
let mut active = self.active.remove(&id);
if let Some(active) = &mut active {
for h in &active.primitives {
let mask = self.layers.free(h);
let mask = self.primitives.free(h);
if h.pos != NOT_DRAWN {
self.layers[h.layer].free(h.pos, h.is_image());
}
if mask != MaskIdx::NONE {
rsc.ui_mut().masks.remove(mask);
}
}
Self::remask_shape_users(&self.active, id, active.own_mask, &active.primitives, rsc);
active.textures.clear();
rsc.ui_mut().textures.free();
if undraw {
// A captured widget that goes away mid-gesture (List's
// A captured widget that goes away mid-gesture (LazySpan's
// virtualisation retiring a row, a rebuild) must not leave
// the pointer permanently captured by an id nothing will
// ever draw again -- `captured`'s own path out.
if *self.captured.lock().unwrap() == Some(id) {
*self.captured.lock().unwrap() = None;
}
// the pointer captured by an id nothing will ever draw
// again. That path out is the sensor pass's, not this
// one's: `iris::sense::SensorUi::run_sensors` releases a
// capture whose widget no longer resolves to a region,
// which covers this case and every other way an id can
// stop being drawn.
// Permanent removal: retire this widget's own move slot
// (the self-ownership ref taken when it was allocated) and
// the up-link ref it held on its parent's slot -- read from
@@ -514,8 +717,15 @@ impl UiRenderState {
// section 2's lifecycle note).
if active.own_mask != MaskIdx::NONE {
// The self-ownership ref `Painter::set_mask` took when
// it allocated this widget's own mask slot.
// it allocated this widget's own mask slot, and the
// chain link's ref on the mask this one nests inside
// -- read from the arena entry, for the same reason
// the move slot's parent is.
let outer = rsc.ui().masks[active.own_mask.idx()].parent;
rsc.ui_mut().masks.remove(active.own_mask);
if outer != MaskIdx::NONE {
rsc.ui_mut().masks.remove(outer);
}
}
let parent_slot = rsc.ui_mut().move_offsets[active.move_slot.idx()].parent;
rsc.ui_mut().move_offsets.remove(active.move_slot);
@@ -528,6 +738,54 @@ impl UiRenderState {
active
}
/// A mask whose shape primitive was just freed clips to a slot that
/// now holds something else, so the widget that owns it is marked for
/// redraw -- its own `set_mask` is the only thing that resolves the
/// slot, and it is the same mechanism a dirty widget already goes
/// through.
///
/// `own` is the mask belonging to the widget being removed and is
/// skipped: this runs in the middle of that widget's own redraw,
/// which sets its mask again on the way out, and a mark left on
/// itself would redraw it every frame from then on. Skipping it is
/// also what keeps the O(active) scan off the ordinary path -- a
/// plain `.masked()` frees exactly its own shape, so `stale` is empty
/// and this returns before touching `active`.
///
/// Both `Vec`s start empty and stay unallocated in that case, and
/// membership is a linear scan of two lists that are a handful long
/// (a widget's own primitives, and the live masks): this runs once
/// per widget removed, which is once per dirty widget per frame, and
/// a set built there would be an allocation on the phone's frame
/// path in exchange for nothing at these sizes.
fn remask_shape_users(
active: &HashMap<WidgetId, ActiveData>,
id: WidgetId,
own: MaskIdx,
freed: &[PrimitiveHandle],
rsc: &mut dyn UiRsc,
) {
let mut stale: Vec<MaskIdx> = Vec::new();
for (i, mask) in rsc.ui().masks.iter().enumerate() {
let idx = Id::preset(i as u32);
if idx != own && freed.iter().any(|h| h.slot == mask.primitive) {
stale.push(idx);
}
}
if stale.is_empty() {
return;
}
let mut owners: Vec<WidgetId> = Vec::new();
for (widget, data) in active {
if *widget != id && stale.contains(&data.own_mask) {
owners.push(*widget);
}
}
for owner in owners {
rsc.widgets_mut().needs_redraw.insert(owner);
}
}
fn remove_rec(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) -> Option<ActiveData> {
let inst = self.remove(id, true, rsc);
if let Some(inst) = &inst {
@@ -543,6 +801,7 @@ impl UiRenderState {
rsc.on_undraw(&active);
}
self.layers.clear();
self.primitives.clear();
rsc.widgets_mut().needs_redraw.clear();
rsc.free();
}
@@ -585,8 +844,9 @@ impl UiRenderState {
/// Primitive instances still bound for the GPU whose owner is no
/// longer in `active`, or whose owner's `ActiveData` no longer names
/// them: a copy nothing can move, clip, resize or free, redrawn every
/// frame at whatever position it last had. `(layer, inst_idx, owner)`
/// each.
/// frame at whatever position it last had. `(slot, owner)` each --
/// the arena knows which primitive, not which layer's draw order still
/// names it.
///
/// Asserted empty at the end of every [`Self::update`], because this
/// is exactly the shape of the duplicated transcript row on Iris's
@@ -595,20 +855,15 @@ impl UiRenderState {
/// much alive -- it is the *earlier* set of primitives that got
/// stranded when the widget was drawn a second time without the first
/// draw being freed. O(primitives), debug builds only.
pub fn orphaned_primitives(&self) -> Vec<(usize, usize, WidgetId)> {
pub fn orphaned_primitives(&self) -> Vec<(u32, WidgetId)> {
let mut orphans = Vec::new();
for (layer, primitives) in self.layers.iter() {
for (inst_idx, owner, is_image) in primitives.live_instances() {
let owned = self.active.get(&owner).is_some_and(|a| {
a.primitives.iter().any(|h| {
h.layer == layer
&& h.inst_idx == inst_idx
&& (h.binding == IMAGE_BINDING) == is_image
})
});
for (slot, owner, _) in self.primitives.live_instances() {
let owned = self
.active
.get(&owner)
.is_some_and(|a| a.primitives.iter().any(|h| h.slot == slot));
if !owned {
orphans.push((layer, inst_idx, owner));
}
orphans.push((slot, owner));
}
}
orphans
@@ -622,7 +877,7 @@ impl UiRenderState {
/// transcript is tens of thousands and made a debug build on a phone
/// too slow to finish a benchmark run.
fn primitive_counts_agree(&self) -> bool {
let live: usize = self.layers.iter().map(|(_, p)| p.live_count()).sum();
let live: usize = self.primitives.live_count();
let owned: usize = self.active.values().map(|a| a.primitives.len()).sum();
live == owned
}
@@ -636,10 +891,10 @@ impl UiRenderState {
let mut lines: Vec<String> = orphans
.iter()
.take(8)
.map(|(layer, idx, owner)| {
.map(|(slot, owner)| {
let alive = self.active.contains_key(owner);
format!(
" layer {layer} instance {idx}: owner '{}' ({owner:?}), owner still active: {alive}",
" instance {slot}: owner '{}' ({owner:?}), owner still active: {alive}",
rsc.widgets().label(*owner),
)
})
@@ -655,27 +910,6 @@ impl UiRenderState {
)
}
/// Give `id` exclusive pointer input from the next `run_sensors` call
/// on -- see `captured`'s field doc. Overwrites any previous capture
/// (a gesture that starts a new one has already decided the old one
/// is over).
pub fn capture_pointer(&self, id: WidgetId) {
*self.captured.lock().unwrap() = Some(id);
}
/// Release exclusive pointer input, if any is held -- called once
/// `run_sensors` has delivered the terminal `Drop` to the capturing
/// widget, or by that widget itself if it decides the gesture is over
/// some other way.
pub fn release_pointer(&self) {
*self.captured.lock().unwrap() = None;
}
/// The widget currently holding exclusive pointer input, if any.
pub fn captured_pointer(&self) -> Option<WidgetId> {
*self.captured.lock().unwrap()
}
pub fn debug(&self, widgets: &Widgets, label: &str) -> impl Iterator<Item = &ActiveData> {
self.active.iter().filter_map(move |(&id, inst)| {
let l = widgets.label(id);
@@ -684,12 +918,12 @@ impl UiRenderState {
}
pub fn debug_layers(&self) {
for ((idx, depth), primitives) in self.layers.iter_depth() {
for ((idx, depth), order) in self.layers.iter_depth() {
let indent = " ".repeat(depth * 2);
let len = primitives.instances().len();
let len = order.order().len();
print!("{indent}{idx}: {len} primitives");
if len >= 1 {
print!(" ({})", primitives.instances()[0].binding);
print!(" ({})", self.primitives.instance(order.order()[0]).binding);
}
println!();
}
@@ -713,13 +947,13 @@ impl UiRenderState {
/// The plain-Rust twin of `resolve_move` in shader.wgsl: sums the
/// pixel delta along the parent chain starting at `slot`. Both walks
/// share `MOVE_CHAIN_LIMIT` as their bound so the two cannot disagree
/// share `PARENT_CHAIN_LIMIT` as their bound so the two cannot disagree
/// about where the chain ends.
fn resolve_move_chain(&self, slot: MoveIdx, rsc: &dyn UiRsc) -> Vec2 {
let offsets = &rsc.ui().move_offsets;
let mut delta = Vec2::ZERO;
let mut at = slot;
for i in 0..MOVE_CHAIN_LIMIT {
for i in 0..PARENT_CHAIN_LIMIT {
let entry = &offsets[at.idx()];
delta.x += entry.delta[0];
delta.y += entry.delta[1];
@@ -732,8 +966,9 @@ impl UiRenderState {
// follow are different faults with different fixes, and the
// slot numbers are the only thing that tells them apart.
debug_assert!(
i + 1 < MOVE_CHAIN_LIMIT,
"move offset chain exceeded MOVE_CHAIN_LIMIT ({MOVE_CHAIN_LIMIT}): {chain} -- a \
i + 1 < PARENT_CHAIN_LIMIT,
"move offset chain exceeded PARENT_CHAIN_LIMIT ({PARENT_CHAIN_LIMIT}): {chain} \
-- a \
repeated slot means a `parent` link is cyclic, all-distinct slots mean the tree \
nests deeper than shader.wgsl's own walk of the same bound",
chain = Self::move_chain_debug(slot, offsets)
@@ -743,13 +978,13 @@ impl UiRenderState {
}
/// The parent chain from `slot`, as `slot(dx, dy) -> ...`, walked twice
/// `MOVE_CHAIN_LIMIT` so a cycle shows up as a repeated slot number
/// `PARENT_CHAIN_LIMIT` so a cycle shows up as a repeated slot number
/// rather than as a chain that merely stops. Only ever called from the
/// failed assertion above.
fn move_chain_debug(slot: MoveIdx, offsets: &[MoveOffset]) -> String {
let mut parts = Vec::new();
let mut at = slot;
for _ in 0..MOVE_CHAIN_LIMIT * 2 {
for _ in 0..PARENT_CHAIN_LIMIT * 2 {
let entry = &offsets[at.idx()];
parts.push(format!(
"{}({}, {})",
@@ -765,6 +1000,92 @@ impl UiRenderState {
parts.join(" -> ")
}
/// One primitive's corners in window pixels -- the transliteration of
/// `shader.wgsl`'s `corners_of`, `floor` for `floor`. The rounding is
/// the whole reason this is not `region.to_px()`: the shader floors
/// each half separately before adding the move delta, and a hit test
/// that skipped it would disagree with the pixels by up to one along
/// each edge -- invisible in every test written against a whole-pixel
/// layout and wrong on the phone, whose 2.55 density makes nothing
/// land on a whole pixel.
pub fn primitive_corners(&self, slot: u32, rsc: &dyn UiRsc) -> PixelRegion {
let inst = self.primitives.instance(slot);
let delta = self.resolve_move_chain(inst.move_idx, rsc);
let size = self.output_size;
let corner = |c: UiVec2| (c.get_rel() * size).floor() + c.get_abs().floor() + delta;
PixelRegion {
top_left: corner(inst.region.top_left()),
bot_right: corner(inst.region.bot_right()),
}
}
/// Where a mask's clip actually is on screen: the box of the
/// primitive it references. Its *shape* within that box is
/// [`Self::mask_coverage`]'s -- this is the bounding box, which is
/// what a test asking "is the clip over the right part of the screen"
/// wants and all a square-cornered mask has ever had.
pub fn mask_region(&self, mask: MaskIdx, rsc: &dyn UiRsc) -> PixelRegion {
self.primitive_corners(rsc.ui().masks[mask.idx()].primitive, rsc)
}
/// How much of the pixel at `pos` (window pixels) survives `mask` and
/// every mask it nests inside: the referenced primitives' own
/// coverage, multiplied along the chain. The CPU half of
/// `shader.wgsl`'s `fs_main` mask loop -- same order, same bound, same
/// `rounded_rect_coverage` -- so a corner that cannot be tapped and a
/// corner that is not drawn are the same corner (LAYOUT.md's "Masks
/// with a shape", point 4).
///
/// A mask whose shape is not a rect covers everything, exactly as the
/// shader's own `mask_coverage` does: `Painter::set_mask_to` rejects
/// those by name, so this is the unreachable half of the same
/// agreement rather than a second policy.
pub fn mask_coverage(&self, mask: MaskIdx, pos: Vec2, rsc: &dyn UiRsc) -> f32 {
let mut coverage = 1.0;
let mut at = mask;
for i in 0..PARENT_CHAIN_LIMIT {
if at == MaskIdx::NONE {
return coverage;
}
let m = rsc.ui().masks[at.idx()];
if let Some(rect) = self.primitives.primitive_data::<RectPrimitive>(m.primitive) {
let c = self.primitive_corners(m.primitive, rsc);
coverage *= rounded_rect_coverage(pos, c.top_left, c.bot_right, rect.radius);
}
at = m.parent;
debug_assert!(
i + 1 < PARENT_CHAIN_LIMIT || at == MaskIdx::NONE,
"mask chain exceeded PARENT_CHAIN_LIMIT ({PARENT_CHAIN_LIMIT}) from {mask:?} -- a \
repeated slot means a `parent` link is cyclic, all-distinct slots mean the tree \
nests deeper than shader.wgsl's own walk of the same bound",
);
}
coverage
}
/// Whether `pos` is inside `mask` at all -- more than half covered,
/// which is where the drawn edge is (`rounded_rect_coverage`'s doc).
/// What a hit test asks.
pub fn mask_admits(&self, mask: MaskIdx, pos: Vec2, rsc: &dyn UiRsc) -> bool {
self.mask_coverage(mask, pos, rsc) > 0.5
}
/// The first primitive `id`'s subtree wrote this frame, depth first
/// in draw order -- what a mask pointed at a widget clips to
/// (`Painter::set_mask_to_widget`). A widget that draws more than one
/// (a bordered rect is one primitive; a card with a stripe is two)
/// gives its first; a widget that wants another names it.
pub fn first_primitive(&self, id: WidgetId) -> Option<u32> {
let active = self.active.get(&id)?;
if let Some(h) = active.primitives.first() {
return Some(h.slot);
}
active
.children
.iter()
.find_map(|child| self.first_primitive(*child))
}
pub fn window_region(&self, id: &impl IdLike, rsc: &dyn UiRsc) -> Option<PixelRegion> {
let region = self.resolved_region(id, rsc)?;
Some(region.to_px(self.output_size))
+66
View File
@@ -60,6 +60,72 @@ pub trait Widget: Any {
fn tick(&mut self, now: std::time::Instant) -> bool {
false
}
/// Whether this widget positions its own content and should be handed
/// scroll deltas ([`Self::apply_scroll`]) instead of being moved by
/// the `Scroll` around it. Default `false`: an ordinary child is a
/// fixed lump its parent slides about, which is what makes a scroll
/// tick an O(1) move of one subtree rather than a redraw.
///
/// A lazy layout has to answer `true`, because the two halves of a
/// scroll are not separable for it: which children exist at all is a
/// function of where it is scrolled to, so it cannot be a lump, and it
/// cannot report a content length for the parent to clamp against
/// either -- it has never measured the rows it has not drawn.
///
/// **`&self` on purpose.** Reaching a widget through
/// `Widgets::get_dyn_mut` marks it dirty, so asking the question
/// through [`Self::apply_scroll`] would dirty every ordinary child on
/// every scroll tick and cost exactly the redraw the move path exists
/// to avoid. This is read through `get_dyn`, which does not mark.
fn scrolls_itself(&self) -> bool {
false
}
/// Take as much of `delta` as this widget can actually move, and leave
/// the rest in it. Only called on a widget whose
/// [`Self::scrolls_itself`] is `true`.
///
/// The sign is `Scroll::scroll`'s, which is the finger's: a positive
/// delta moves the content in the positive direction of the axis, and
/// so brings *earlier* content into view.
///
/// What is left behind is how the caller learns it reached a wall --
/// asked for 300, got 50 back means the content ran out 250 short --
/// which is all a fling needs to know to stop, and all a pin needs to
/// know to re-pin. There is deliberately nothing here reporting an
/// absolute position: a lazy layout's origin moves when content is
/// loaded above it, so any such number would be a fiction.
///
/// Called between the two draws of `Scroll::draw`, so the walls this
/// answers against were measured by the first of them.
#[allow(unused_variables)]
fn apply_scroll(&mut self, delta: &mut f32) {}
/// How far this widget has moved its own content in total, in
/// [`Self::apply_scroll`]'s direction convention -- for a parent
/// keeping an account of where a self-positioning child has got to.
///
/// **Why this exists and `apply_scroll`'s remainder is not enough.**
/// A lazy layout usually cannot say where its content ends until it
/// has walked there, so `apply_scroll` takes a delta in full whenever
/// the wall is not already in view, and the wall is found by the walk
/// that follows -- which gives some of it back. The remainder is
/// therefore right only when the wall was already visible, and a
/// parent adding remainders up would over-count by every overshoot
/// and never correct. Read after the child has been placed, this is
/// what actually happened.
///
/// `&self`, so asking does not mark the child dirty
/// ([`Self::scrolls_itself`] has the reasoning).
///
/// Counts scrolling only: a jump straight to an item is not travel
/// across the content and does not appear here, because for a layout
/// whose origin moves as content is paged in there is no distance
/// between the two positions to report.
fn scroll_offset(&self) -> f32 {
0.0
}
}
impl Widget for () {
+10 -113
View File
@@ -1,19 +1,13 @@
//! Where the desktop app keeps the enrollment it should not have to be
//! told about a second time: `client_core::config::EnrolledServer`,
//! persisted at `$XDG_CONFIG_HOME/ai-app-desktop/enrollment.json`,
//! owner-only (0600) -- MACHINE.md's rule for anything holding a bearer
//! token, and the reason `client_core::config`'s own doc comment leaves
//! persistence and file mode to the caller.
//! Where the desktop app keeps its enrollment: `client_core::config`'s
//! [`EnrollmentStore`] pointed at `$XDG_CONFIG_HOME/ai-app-desktop`.
//!
//! JSON rather than the project's usual RON: `wg-app-link`'s RON house
//! rules (`format`) are for configs a person hand-edits, and this file
//! never is one -- only this program ever writes or reads it, and
//! `serde_json` is already in the dependency graph through `client-core`,
//! so nothing new is added to reach for it.
//! Only the directory is this app's -- the file's name, its JSON, and its
//! owner-only mode (MACHINE.md's rule for anything holding a bearer token)
//! are the store's, shared with the Android client so the two cannot come
//! to disagree about them.
use client_core::config::EnrolledServer;
use std::io;
use std::path::{Path, PathBuf};
use client_core::config::EnrollmentStore;
use std::path::PathBuf;
/// `$XDG_CONFIG_HOME/ai-app-desktop`, falling back to `~/.config` the way
/// the XDG basedir spec says to when the variable is unset -- the same
@@ -32,103 +26,6 @@ pub fn config_dir() -> PathBuf {
base.join("ai-app-desktop")
}
fn enrollment_file(dir: &Path) -> PathBuf {
dir.join("enrollment.json")
}
/// Persists `server` under `dir` (`config_dir()` for real use; a tempdir in
/// the tests below), creating it if needed, and sets the file owner-only --
/// it carries a bearer token, the same reason `server/`'s own token store
/// is 0600.
pub fn save_enrollment_in(dir: &Path, server: &EnrolledServer) -> io::Result<()> {
std::fs::create_dir_all(dir)?;
let path = enrollment_file(dir);
let json = serde_json::to_vec_pretty(server)
.expect("EnrolledServer holds nothing that fails to serialise");
std::fs::write(&path, json)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
/// `Ok(None)` when nothing has been enrolled yet, rather than an error --
/// "not enrolled" is an ordinary first-run state, not a failure (UI_RULES'
/// "a deliberate choice is not a problem to report" applies just as well
/// to a file that simply hasn't been written yet).
pub fn load_enrollment_in(dir: &Path) -> io::Result<Option<EnrolledServer>> {
let path = enrollment_file(dir);
match std::fs::read(&path) {
Ok(bytes) => {
let server = serde_json::from_slice(&bytes).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("{} is not a valid enrollment ({e})", path.display()),
)
})?;
Ok(Some(server))
}
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
pub fn save_enrollment(server: &EnrolledServer) -> io::Result<()> {
save_enrollment_in(&config_dir(), server)
}
pub fn load_enrollment() -> io::Result<Option<EnrolledServer>> {
load_enrollment_in(&config_dir())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_saved_enrollment_reads_back_the_same() {
let dir = tempfile::tempdir().unwrap();
let server = EnrolledServer {
host: "127.0.0.1".to_string(),
port: 8547,
token: "tok".to_string(),
};
save_enrollment_in(dir.path(), &server).unwrap();
let read_back = load_enrollment_in(dir.path()).unwrap();
assert_eq!(read_back, Some(server));
}
#[test]
fn nothing_saved_yet_is_none_not_an_error() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(load_enrollment_in(dir.path()).unwrap(), None);
}
#[test]
#[cfg(unix)]
fn the_saved_file_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let server = EnrolledServer {
host: "h".to_string(),
port: 1,
token: "t".to_string(),
};
save_enrollment_in(dir.path(), &server).unwrap();
let mode = std::fs::metadata(enrollment_file(dir.path()))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
}
#[test]
fn a_corrupt_file_is_named_in_the_error() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(enrollment_file(dir.path()), b"not json").unwrap();
let err = load_enrollment_in(dir.path()).unwrap_err();
assert!(err.to_string().contains("enrollment.json"));
}
pub fn store() -> EnrollmentStore {
EnrollmentStore::new(config_dir())
}
+31 -20
View File
@@ -5,18 +5,20 @@
//!
//! Usage:
//!
//! desktop-app --ca /path/to/ca.pem --link 'aiapp://enroll?host=H&port=P&token=T'
//! desktop-app --ca /path/to/ca.pem # after the first run above
//! desktop-app --link 'aiapp://enroll?host=H&port=P&token=T&ca=B'
//! desktop-app # after the first run above
//! desktop-app --ca /path/to/ca.pem # a link that carries no CA
//!
//! `--link` is the same text `app/ui-sandbox.sh`'s banner prints and a
//! phone would scan as a QR (DECISIONS.md, 2026-09-05) -- pasted rather
//! than scanned, since a desktop has no camera to assume. It is parsed and
//! saved to `config::save_enrollment` once; later runs read it back and
//! `--link` is only needed again to enrol against a different server. The
//! CA is never persisted -- it is a public certificate whose path a
//! caller is expected to already know (`AGENTS.md`'s "prefer exercising
//! the server directly": the same `certs/ca.pem` a `curl --cacert` call
//! uses).
//! saved once; later runs read it back and `--link` is only needed again
//! to enrol against a different server.
//!
//! The CA comes with the link (`wg_app_link::enroll::ca_param`, which
//! `ai-server` now always includes) and is saved with it. `--ca` is the
//! override for a link that carries none, and names the same
//! `certs/ca.pem` a `curl --cacert` call uses.
mod app;
mod config;
@@ -24,7 +26,7 @@ mod config;
use client_core::config::EnrolledServer;
struct Args {
ca_path: std::path::PathBuf,
ca_path: Option<std::path::PathBuf>,
link: Option<String>,
}
@@ -43,13 +45,7 @@ fn parse_args() -> Result<Args, String> {
other => return Err(format!("unrecognised argument '{other}'")),
}
}
Ok(Args {
ca_path: ca_path.ok_or(
"--ca PATH is required (the pinned CA's certificate, e.g. \
~/.config/ai-app/certs/ca.pem)",
)?,
link,
})
Ok(Args { ca_path, link })
}
/// What `app.rs`'s `Client::new` needs to talk to the server: the enrolled
@@ -60,14 +56,17 @@ fn parse_args() -> Result<Args, String> {
/// other way (`DefaultApp::run()` takes no payload).
fn load_startup_config() -> Result<(EnrolledServer, Vec<u8>), String> {
let args = parse_args()?;
let store = config::store();
let server = match args.link {
Some(link) => {
let server = EnrolledServer::parse_link(&link)?;
config::save_enrollment(&server)
store
.save(&server)
.map_err(|e| format!("couldn't save the enrollment: {e}"))?;
server
}
None => config::load_enrollment()
None => store
.load()
.map_err(|e| format!("couldn't read the saved enrollment: {e}"))?
.ok_or_else(|| {
format!(
@@ -77,8 +76,20 @@ fn load_startup_config() -> Result<(EnrolledServer, Vec<u8>), String> {
)
})?,
};
let ca_pem = std::fs::read(&args.ca_path)
.map_err(|e| format!("couldn't read the CA at {}: {e}", args.ca_path.display()))?;
// `--ca` wins where it was given, so a caller can point a link's
// server at a certificate it did not carry -- and so the flag still
// means what it did before the link could carry one.
let ca_pem = match (&args.ca_path, &server.ca_pem) {
(Some(path), _) => std::fs::read(path)
.map_err(|e| format!("couldn't read the CA at {}: {e}", path.display()))?,
(None, Some(pem)) => pem.clone().into_bytes(),
(None, None) => {
return Err("this enrollment carries no CA -- pass --ca PATH (e.g. \
~/.config/ai-app/certs/ca.pem), or enrol again with a link \
minted by a server that includes one"
.to_string());
}
};
Ok((server, ca_pem))
}
+5 -2
View File
@@ -1,6 +1,6 @@
//! (d) of IRIS_TODO.md's "Benchmarks" item: 1,000 image rows, checking that
//! standalone-image bind-group *creation* -- a real `wgpu` resource, unlike
//! the counters in `benches/message_list.rs` -- goes to zero once every
//! the counters in `benches/message_lazy_span.rs` -- goes to zero once every
//! image has loaded. This needs an actual `wgpu` device (`GpuTextures`,
//! `UiRenderNode`), so unlike the rest of the suite it cannot run as a
//! plain binary; run it through `iris/run-headless.sh bench_images`, which
@@ -51,7 +51,10 @@ impl DefaultAppState for State {
}
let span = rsc.ui.widgets.add_strong(span);
let span_weak = span.weak();
let root = rsc.ui.widgets.add_strong(Scroll::new(span.any(), Axis::Y));
let root = rsc
.ui
.widgets
.add_strong(Scroll::new(span.any(), Axis::Y, true));
ui_state.set_root(root.any());
Self {
ui_state,
+10 -9
View File
@@ -1,4 +1,4 @@
//! RUST.md's I3: `iris::widget::List` with 800 rows of varied-length
//! RUST.md's I3: `iris::widget::LazySpan` with 800 rows of varied-length
//! wrapped text, one in twelve carrying a small image, scrollable with the
//! mouse wheel. Run headless with `iris/run-headless.sh message_list --shot
//! /tmp/message_list.png` -- there is no display on this machine, so that
@@ -10,8 +10,8 @@
//! different number of lines -- exactly the "variable-height rows" I3
//! asks for, and the thing a virtualised list gets wrong first if it is
//! wrong at all (a gap, an overlap, a row the wrong colour). This example
//! is also what found `List::place`'s oversized-background bug (see
//! list.rs's module doc and its `a_fill_shaped_background_is_not_left_
//! is also what found `LazySpan::place`'s oversized-background bug (see
//! lazy_span.rs's module doc and its `a_fill_shaped_background_is_not_left_
//! oversized` test) -- a plain unit test could have (and now does) catch
//! it directly, but it was this screenshot rendering as a single blank
//! tinted rectangle that pointed at it first.
@@ -98,17 +98,18 @@ impl DefaultAppState for State {
rsc: &mut DefaultRsc<Self>,
_: Proxy<Self::Event>,
) -> Self {
let mut list = List::new(Axis::Y);
let mut list = LazySpan::new(Dir::DOWN, true);
for i in 0..ROWS {
let row = build_row(rsc, i);
list.push_back(ListRow::new(i as u64, row));
list.push_back(LazyItem::new(i as u64, row));
}
// `.scrollable_to_end`, like anything else that scrolls: the
// wheel and the drag are the `Scroll`'s, and the list only lays
// out. Masked outside it, since a `LazySpan` draws the row
// straddling each edge in full and asserts something clips it.
let root = list
.on(CursorSense::Scroll, |ctx, rsc| {
let delta = ctx.data.scroll_delta.y * 50.0;
ctx.widget(rsc).scroll(delta);
})
.scrollable_to_end(Axis::Y)
.masked()
.background(rect(Color::WHITE))
.add_strong(rsc);
+3 -3
View File
@@ -4,9 +4,9 @@ version.workspace = true
edition.workspace = true
[dependencies]
proc-macro2 = "1.0.103"
quote = "1.0.42"
syn = { version = "2.0.111", features = ["full"] }
proc-macro2 = "1.0.107"
quote = "1.0.47"
syn = { version = "3.0.5", features = ["full"] }
[lib]
proc-macro = true
+15 -3
View File
@@ -18,6 +18,12 @@ struct Input {
}
struct InputFn {
/// Everything written above the `fn` -- in practice a `///` doc
/// comment, which is why this exists: `masked_by` and its siblings
/// are public API and rustdoc is where their contract is read, so a
/// macro that silently rejected `///` sent the explanation into an
/// ordinary `//` comment nobody generating docs ever sees.
attrs: Vec<Attribute>,
sig: Signature,
body: Block,
}
@@ -32,9 +38,10 @@ impl Parse for Input {
input.parse::<Token![;]>()?;
let mut fns = Vec::new();
while !input.is_empty() {
let attrs = input.call(Attribute::parse_outer)?;
let sig = input.parse()?;
let body = input.parse()?;
fns.push(InputFn { sig, body })
fns.push(InputFn { attrs, sig, body })
}
if !input.is_empty() {
input.error("function expected");
@@ -59,10 +66,15 @@ pub fn widget_trait(input: TokenStream) -> TokenStream {
fns,
} = parse_macro_input!(input as Input);
let sigs: Vec<_> = fns.iter().map(|f| f.sig.clone()).collect();
// The attributes go on the trait's own signature, which is the one
// rustdoc renders; the impl gets the bare `fn`.
let sigs: Vec<_> = fns
.iter()
.map(|InputFn { attrs, sig, .. }| quote! { #(#attrs)* #sig })
.collect();
let impls: Vec<_> = fns
.iter()
.map(|InputFn { sig, body }| quote! { #sig #body })
.map(|InputFn { sig, body, .. }| quote! { #sig #body })
.collect();
let Some(GenericParam::Type(state)) = generics.params.first() else {
+4 -4
View File
@@ -33,10 +33,10 @@
# through positionally without disturbing the existing `-- cargo args`
# convention above.
#
# The VM has a virtio-gpu render node (Vulkan 1.4 through Venus, GL 4.6
# through virgl), so wgpu runs on the host's real GPU -- what is missing is
# only a compositor to give winit a surface. So: a headless sway, the same
# trick `emu` uses for the Android emulator, and `grim` to see the result.
# The VM has a real GPU and no display (the `this-machine-graphics` skill
# says what it is and how it fails), so what is missing here is only a
# compositor to give winit a surface. So: a headless sway, the same trick
# `emu` uses for the Android emulator, and `grim` to see the result.
#
# It is deliberately *not* `emu`'s compositor. sway tiles, so adding a window
# to the one an emulator is sitting in resizes that emulator's window, and a
+2 -5
View File
@@ -1,10 +1,7 @@
use crate::platform::OpenUrl;
use android_view::{
View,
jni::{
JNIEnv,
objects::{JObject, JValue},
},
jni::{JNIEnv, objects::JValue},
};
use super::view::HasAndroidUiState;
@@ -78,7 +75,7 @@ fn try_open_url<'local>(
&context,
"startActivity",
"(Landroid/content/Intent;)V",
&[JValue::Object(&JObject::from(intent))],
&[JValue::Object(&intent)],
)?;
Ok(())
}
+98 -34
View File
@@ -88,9 +88,10 @@ pub struct FrameDiagnostics {
}
impl AndroidRenderer {
/// `Err` holds a full, human-readable report -- wgpu's own error text
/// (`UiRenderNode::new`'s doc comment) plus the adapter identity and
/// the limits/downlevel flags bind-group-layout validation checks
/// `Err` holds a full, human-readable report for **every** way this
/// can fail -- no surface, no adapter, no device, or wgpu's own error
/// text (`UiRenderNode::new`'s doc comment) plus the adapter identity
/// and the limits/downlevel flags bind-group-layout validation checks
/// against -- rather than the panic wgpu's default error handler would
/// otherwise raise with no caller able to see it. This is what aborted
/// the P0 bench APK on Iris's phone with only "wgpu error: Validation
@@ -107,49 +108,79 @@ impl AndroidRenderer {
height: u32,
content_scale: f32,
) -> Result<Self, String> {
// `force-gles` (RUST.md's I5 "Where iris's frame time goes") swaps
// the software-Vulkan (SwiftShader) path for GLES/virgl on the same
// build, to isolate whether the backend itself explains the frame
// time gap against Compose. `cfg!` rather than a runtime switch:
// there is no way to hand an env var to an already-launched Android
// process on this machine (see the feature's doc in Cargo.toml).
let backends = if cfg!(feature = "force-gles") {
// `force-gles` (RUST.md's I5 "Where iris's frame time goes") pins
// the build to GLES, to isolate whether the backend itself explains
// the frame time gap against Compose. `cfg!` rather than a runtime
// switch: there is no way to hand an env var to an already-launched
// Android process on this machine (see the feature's doc in
// Cargo.toml).
//
// Otherwise: **Vulkan where it has an adapter at all, GLES where it
// has none.** `Backends::PRIMARY` leaves `GL` out, so a device
// offering only a GLES adapter had no adapter at all and this
// function aborted the process -- this checkout's emulator, whose
// Vulkan ICD carries no adapter behind it (`NotFound {
// active_backends: VULKAN, no_adapter_backends: VULKAN,
// supported_backends: VULKAN | GL }`), and the crash loop in
// RUST.md's queue.
//
// The choice is made *before any surface exists*, with an instance
// that never touches the window, because **an Android window can be
// connected to one graphics API only**. One instance carrying both
// backends does not work: `create_surface` builds a raw surface per
// backend, Vulkan's `vkCreateAndroidSurfaceKHR` claims the window
// first, and the GLES surface made from the same window then fails
// `configure` as lost -- measured here as "In Surface::configure /
// Invalid surface" followed by an abort in
// `Surface::get_current_texture_view`, "Surface is not configured
// for presentation".
let mut backends = if cfg!(feature = "force-gles") {
Backends::GL
} else {
Backends::PRIMARY
};
let instance = Instance::new(&InstanceDescriptor {
// No display handle: an Android surface is built from the
// `NativeWindow` below, and there is no platform connection to hand
// wgpu here the way there is on Wayland.
let mut instance = Instance::new(InstanceDescriptor {
backends,
..Default::default()
..InstanceDescriptor::new_without_display_handle()
});
// A build already pinned to GLES has nowhere to fall back to.
if backends != Backends::GL && instance.enumerate_adapters(backends).block_on().is_empty() {
log::warn!(
"iris renderer: no {backends:?} adapter on this device, falling back to GLES"
);
backends = Backends::GL;
instance = Instance::new(InstanceDescriptor {
backends,
..InstanceDescriptor::new_without_display_handle()
});
}
// SAFETY: the `NativeWindow` outlives the surface built from it --
// android-view drops the old renderer (and this surface with it)
// before handing over a new window, in `surface_changed` below.
let surface = instance
.create_surface(SurfaceTarget::from(AndroidWindowHandle { window }))
.expect("Could not create android surface!");
.map_err(|error| format!("Could not create the android surface: {error}"))?;
// Every step from here to a live device reports rather than
// panics, for the one reason: on the phone these builds run on
// there is no `adb`, so an abort's message reaches a tombstone
// nobody can read and the launcher simply restarts the app --
// which is what a crash loop with no explanation is. The caller
// (`android::view::IrisViewPeer::surface_changed`) puts this
// string on screen and in the app's own log ring instead.
let adapter = instance
.request_adapter(&RequestAdapterOptions {
power_preference: PowerPreference::default(),
compatible_surface: Some(&surface),
force_fallback_adapter: false,
..Default::default()
})
.block_on()
.expect("Could not get adapter!");
// Requesting the device itself still panics on failure: that is a
// `RequestDeviceError` (a limit or feature the adapter cannot grant
// at all), a different and already-diagnosable failure from the one
// this function now recovers from -- `RUST.md`'s "Software mode ...
// crashes for a third, different reason" is exactly that class, and
// its message already names the limit and the requested/allowed
// values with no truncation risk (it never reaches wgpu's
// uncaptured-error path). What this function's `Result` return
// covers is the *next* class of failure: the adapter grants the
// device, and validation only fails once a specific bind group
// layout is checked against it.
.map_err(|error| format!("No usable GPU adapter for backends {backends:?}: {error}"))?;
// Same request as the winit backend's `UiRenderer::new` -- no
// binding-array features, see TEXTURES.md's "Recommended shape".
@@ -161,7 +192,13 @@ impl AndroidRenderer {
..Default::default()
})
.block_on()
.expect("Could not get device!");
.map_err(|error| {
format!(
"The adapter {} ({:?}) refused a device: {error}",
adapter.get_info().name,
adapter.get_info().backend,
)
})?;
// wgpu's default handler for an error raised outside `UiRenderNode::
// new`'s own error scopes (i.e. everything past device creation --
@@ -181,11 +218,27 @@ impl AndroidRenderer {
let info = adapter.get_info();
let adapter_name = info.name.clone();
let adapter_backend = info.backend;
let adapter_driver = if info.driver_info.is_empty() {
info.driver.clone()
} else {
format!("{} {}", info.driver, info.driver_info)
};
// Either half can be empty -- the emulator's GLES adapter reports
// no `driver` and a long `driver_info`, so joining unconditionally
// left a leading space in every log line it appears in.
let adapter_driver = [info.driver.as_str(), info.driver_info.as_str()]
.into_iter()
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join(" ");
// Say which adapter won, in the same words `default::render` uses,
// and at startup rather than only on the Diagnostics page: the
// backend alone (logged by `view.rs` when a renderer is built) does
// not separate the cases that matter. In this checkout's emulator
// `Gl` is the host's real GPU through virgl, and `Gl` under
// `EMU_GPU=software` is SwiftShader on the CPU; on a phone `Vulkan`
// is the device's own driver. A frame time or a screenshot with no
// record of which of those produced it cannot be read, and the
// fallback above is silent by design.
log::info!(
"iris renderer: {adapter_name} ({adapter_backend:?}, {adapter_driver}) on \
{backends:?}"
);
let surface_caps = surface.get_capabilities(&adapter);
let surface_format = surface_caps
@@ -198,6 +251,8 @@ impl AndroidRenderer {
let config = SurfaceConfiguration {
usage: TextureUsages::RENDER_ATTACHMENT,
format: surface_format,
// wgpu 30's new field; `Auto` is what every earlier version did.
color_space: SurfaceColorSpace::Auto,
width,
height,
present_mode: PresentMode::AutoVsync,
@@ -308,6 +363,7 @@ impl AndroidRenderer {
mono={default_mono_family:?}\n\
fonts resolved: regular={regular:?} bold={bold:?} italic={italic:?} \
mono={mono:?}\n\
icon font: {icons:?}\n\
wgpu errors since surface creation:\n {errors_text}\n\n\
{frame_report}",
name = self.adapter_name,
@@ -322,6 +378,7 @@ impl AndroidRenderer {
bold = font.bold_resolved,
italic = font.italic_resolved,
mono = font.mono_resolved,
icons = font.icon_family,
)
}
@@ -370,7 +427,14 @@ impl AndroidRenderer {
/// the GPU actually finishing, so this is "how long the CPU was blocked
/// handing the frame off", not confirmed GPU time.
pub fn draw(&mut self) -> Duration {
let output = self.surface.get_current_texture().unwrap();
let output = match self.surface.get_current_texture() {
CurrentSurfaceTexture::Success(texture)
| CurrentSurfaceTexture::Suboptimal(texture) => texture,
// wgpu 30 turned this Result into an enum; every arm here was an
// `Err` the previous `.unwrap()` panicked on, except `Occluded`,
// which is new.
other => panic!("no surface texture to draw into: {other:?}"),
};
let view = output
.texture
.create_view(&TextureViewDescriptor::default());
@@ -394,7 +458,7 @@ impl AndroidRenderer {
let submit_start = Instant::now();
self.queue.submit(std::iter::once(encoder.finish()));
output.present();
self.queue.present(output);
submit_start.elapsed()
}
+111 -58
View File
@@ -7,7 +7,7 @@ use android_view::{
jni::{
JNIEnv, JavaVM,
objects::{GlobalRef, JValue},
sys::{jint, jlong},
sys::jint,
},
ndk::event::{Axis, Keycode, MotionAction},
};
@@ -20,7 +20,7 @@ use std::{
marker::{PhantomData, Sized},
rc::Rc,
sync::Arc,
time::{Duration, Instant},
time::Instant,
};
use super::{
@@ -312,12 +312,11 @@ pub struct IrisViewPeer<State: AndroidAppState> {
pub(super) render: UiRenderState,
pub(super) state: State,
task_recv: TaskMsgReceiver<AndroidRsc<State>>,
/// `(an Instant, the input-event nanosecond stamp it was taken at)`,
/// captured from the first `MotionEvent` this view receives and never
/// changed after -- how `on_touch_event` dates every touch sample. Its
/// path out is the peer's own drop: it holds nothing but two numbers
/// and is meaningless to any other view.
input_clock: Option<(Instant, jlong)>,
/// Anchored on the first `MotionEvent` this view receives and never
/// re-anchored after -- how `on_touch_event` dates every touch sample.
/// Its path out is the peer's own drop: it holds nothing but three
/// numbers and is meaningless to any other view.
input_clock: Option<PointerClock>,
}
impl<State: 'static, I: RscIdx<AndroidRsc<State>>> std::ops::Index<I> for AndroidRsc<State> {
@@ -406,7 +405,13 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
/// magenta and screenshotting), but no primitive ever appears on top of
/// it -- on both the Vulkan/SwiftShader and GLES/virgl backends. Leave
/// these in until that is root-caused; removing them loses the exact
/// evidence a `logcat` capture needs to reproduce the state.
/// evidence a `logcat` capture needs to reproduce the state. Gated on
/// `iris::diagnostics::trace_enabled` since 2026-09-07 (docs/RUST.md's
/// review, D1): unconditional, they were two `debug!` lines every
/// rendered frame, and `client_core::log_ring`'s `RingLogger` records
/// every level the app's already-`Debug` install lets through
/// regardless of target, so they filled the whole ring in under ten
/// seconds at 120Hz and left `Copy report` nothing else to show.
fn render(&mut self, ctx: &mut CallbackCtx) {
if self.state.android_state().renderer.is_none() {
return;
@@ -441,8 +446,18 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
self.state.on_insets_changed(&mut self.rsc, physical);
}
// Gated the same way `iris::frame`'s own line is (docs/RUST.md's
// "Phone logging" review, D1): a bare `log::debug!` reaches
// `client_core::log_ring`'s ring regardless of level, since
// `RingLogger::enabled` is unconditionally `true` and the app
// installs at `LevelFilter::Debug` -- two of these a rendered
// frame filled the 2000-line ring in under ten seconds at 120Hz,
// leaving `Copy report` nothing but frame spam. See
// `iris::diagnostics`'s module doc.
if crate::diagnostics::trace_enabled() {
let ui_state = self.state.android_state();
log::debug!(
target: "iris::frame",
"render(): root={:?} widgets={} active={} root_px={:?} out_size={:?}",
ui_state.root.is_some(),
self.rsc.widgets().len(),
@@ -453,6 +468,7 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
.and_then(|r| self.render.window_region(r, &self.rsc)),
self.window_size(),
);
}
// iris's own frame-time report (RUST.md's I5 box, "Measurements
// taken" (b)): started here, at the same point a redraw request
// fires, and stopped after `renderer.draw()`'s `queue.submit` +
@@ -460,7 +476,7 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
// both count. See `iris_core::FrameReport`'s own doc for exactly
// what this does and does not measure.
let frame_start = Instant::now();
// Anything moving on its own -- today a `List` coasting through a
// Anything moving on its own -- today a `LazySpan` coasting through a
// fling -- is advanced here, before the draw, and asks for the
// next frame at the end of this one. See
// `UiData::tick_animations`; `default/mod.rs`'s
@@ -498,14 +514,17 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
.android_state_mut()
.frame_report
.record_split(frame_start.elapsed(), submit_to_present);
crate::diagnostics::log_frame(&self.render, frame_start, submit_to_present, animating);
// A frame callback is one-shot, so an animation that wants
// another frame has to say so every frame -- unlike `after_input`,
// which only has to ask when input dirtied something.
if animating {
ctx.view.post_frame_callback(&mut ctx.env);
}
if crate::diagnostics::trace_enabled() {
let ui_state = self.state.android_state();
log::debug!(
target: "iris::frame",
"render(): after update active={} root_px={:?}",
self.render.active_widgets(),
ui_state
@@ -513,6 +532,7 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
.as_ref()
.and_then(|r| self.render.window_region(r, &self.rsc)),
);
}
// I4 (RUST.md): only produces a `TreeUpdate` -- and so only queues
// anything to raise -- when the named set actually changed this
@@ -618,17 +638,30 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
// `CLOCK_MONOTONIC` an `Instant` reads, so a single
// `(Instant, nanos)` pair converts every later sample exactly.
// Anchoring **once** rather than per event is what keeps the times
// ordered: a fresh `Instant::now()` per event, minus each sample's
// age inside it, can date a later event's first historical sample
// before the previous event's last one whenever delivery jitters by
// more than the batch spans -- and `VelocityTracker::add_sample`'s
// debug assert would rightly fire on that. See `CursorState::time`.
// ordered, and anchoring on the first event's *oldest* sample
// rather than on its own time is what keeps that event's batch
// from collapsing onto one instant -- `sense::PointerClock`'s doc
// has both, and owns the arithmetic so it can be unit-tested off a
// device (`sense_tests.rs`). See `CursorState::time`.
let event_time = event.event_time_nanos(&mut ctx.env);
let (anchor_at, anchor_nanos) =
*self.input_clock.get_or_insert((Instant::now(), event_time));
let at = |sample_time: jlong| {
anchor_at + Duration::from_nanos(sample_time.saturating_sub(anchor_nanos).max(0) as u64)
if self.input_clock.is_none() {
let history = event.history_size(&mut ctx.env);
let oldest = if history > 0 {
event.historical_event_time_nanos(&mut ctx.env, 0)
} else {
event_time
};
self.input_clock = Some(PointerClock::anchored(Instant::now(), event_time, oldest));
}
let mut clock = self.input_clock.expect("anchored just above");
// `iris::input`'s own doc (`sense::log_input_event`): collected
// only when tracing is on, since this is otherwise a `Vec` per
// `MotionEvent` for a line nobody is reading -- the JNI reads
// themselves (`historical_axis`/`historical_event_time_nanos`
// below) already happen unconditionally, for the replay this
// function does regardless of tracing.
let trace_input = crate::diagnostics::trace_enabled();
let mut historical_ms: Vec<(u64, f32, f32)> = Vec::new();
// **Historical samples first.** A flick on a 120Hz screen is
// delivered as one or two `MotionEvent`s with the intermediate
@@ -648,31 +681,30 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
// the event's own sample as the newest of the batch; everything
// downstream (`VelocityTracker`, `DragArbiter`'s long-press
// clock) assumes it, so say so here rather than at each reader.
let mut previous = anchor_nanos;
// `PointerClock::sample` is what asserts it, and it carries the
// last sample seen *across* events, so the first sample of
// every event is checked against the previous event's last one
// rather than against the anchor.
for pos in 0..history {
let hx = event.historical_axis(&mut ctx.env, Axis::X, 0, pos);
let hy = event.historical_axis(&mut ctx.env, Axis::Y, 0, pos);
let ht = event.historical_event_time_nanos(&mut ctx.env, pos);
debug_assert!(
ht >= previous,
"historical sample {pos} of {history} is dated {ht}ns, before the {previous}ns \
sample ahead of it -- the input clock is not what this assumes"
);
previous = ht;
let sample_at = clock.sample(ht);
if trace_input {
historical_ms.push((clock.ms_since_anchor(ht), hx, hy));
}
let ui_state = self.state.android_state_mut();
ui_state.cursor.pos = vec2(hx, hy);
ui_state.cursor.time = at(ht);
ui_state.cursor.time = sample_at;
self.run_input_frame(ctx);
}
debug_assert!(
event_time >= previous,
"the event's own sample is dated {event_time}ns, before its last historical \
sample at {previous}ns"
);
}
let event_at = clock.sample(event_time);
let event_ms = clock.ms_since_anchor(event_time);
self.input_clock = Some(clock);
let ui_state = self.state.android_state_mut();
ui_state.cursor.time = at(event_time);
ui_state.cursor.time = event_at;
match action {
MotionAction::Down => {
ui_state.cursor.pos = vec2(x, y);
@@ -682,19 +714,36 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
MotionAction::Move => {
ui_state.cursor.pos = vec2(x, y);
}
// `Cancel` ends the gesture the same way `Up` does, and must:
// a release that never arrives leaves whichever widget took
// pointer capture holding it forever, with every later touch
// delivered to a drag nobody is performing. Confirmed present
// before this pass rather than assumed -- it was one of the
// three suspects listed for the phone's missing fling, and it
// is not the cause.
MotionAction::Up | MotionAction::Cancel => {
MotionAction::Up => {
ui_state.cursor.pos = vec2(x, y);
ui_state.cursor.buttons.left.update(false);
}
// A cancel ends the press -- a release that never arrives
// leaves whichever widget took pointer capture holding it
// forever -- but it is **not** a release, and saying so is
// `CursorState::cancelled`. It used to take the `Up` arm, so
// the system's own swipe up from the bottom edge to leave the
// app (moves, then `ACTION_CANCEL`) reached iris as a flick
// released at speed, and the transcript flung while the app
// was in the background: Iris's 2026-09-08 "leaving and
// reopening the app also randomly moved the vertical scroll".
MotionAction::Cancel => {
ui_state.cursor.pos = vec2(x, y);
ui_state.cursor.buttons.left.update(false);
ui_state.cursor.cancelled = true;
}
_ => return false,
}
if trace_input {
let action_word = match action {
MotionAction::Down => "down",
MotionAction::Move => "move",
MotionAction::Up => "up",
MotionAction::Cancel => "cancel",
_ => "other",
};
crate::sense::log_input_event(action_word, x, y, event_ms, &historical_ms);
}
self.after_input(ctx);
true
}
@@ -811,29 +860,33 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
let content_scale = self.state.android_state().content_scale;
match AndroidRenderer::new(window, width as u32, height as u32, content_scale) {
Ok(renderer) => {
// A genuinely new renderer means a genuinely new GPU device
// and a fresh, empty glyph atlas -- the CPU-side glyph
// cache (`TextData::atlas`) and the texture bookkeeping it
// is built on (`UiData::textures`) both outlive `renderer`
// itself (they live on `self.rsc`, not on `AndroidRenderer`),
// so without this they would keep pointing at the *old*
// device's now-gone textures -- the app-switch counterpart
// to the keyboard-resize glyph wipe this same function's
// `already_live` branch above already fixed by reusing the
// renderer instead of rebuilding it. One mechanism either
// way: this call only runs on the branch that actually
// builds a new renderer, exactly where invalidation is
// needed, never on the reuse branch, where it would throw
// away perfectly valid GPU state for nothing.
// A genuinely new renderer means a genuinely new GPU
// device, holding none of the textures the old one did --
// while the CPU side of them (`UiData::textures`, and the
// glyph atlas built on it) lives on `self.rsc` and
// survives. So every slot has to be uploaded again, and
// `Textures::reupload` queues exactly that, in slot order.
//
// It replaces clearing them, which threw away the *slot
// numbering* as well as the pixels: every `TextureHandle`
// a live widget still held -- one per icon or image on
// screen, and one per folded card at the time -- then
// named a slot nothing recognised, and the next frame
// panicked in `image_bind_group` ("texture slot 89 is not
// a live standalone image: None"). Re-uploading also keeps
// the glyph atlas, so an app switch no longer re-rasterises
// every glyph on screen. This only runs on the branch that
// actually builds a new renderer, never on the reuse
// branch above, where the textures are still on the device
// that holds them.
log::info!(
"iris surface: new renderer built ({:?}), clearing glyph atlas: \
"iris surface: new renderer built ({:?}), re-uploading textures: \
glyphs={} pages={}",
renderer.adapter_backend,
self.rsc.ui.text.atlas.glyph_count(),
self.rsc.ui.text.atlas.page_count(),
);
self.rsc.ui.text.atlas.clear();
self.rsc.ui.textures.reset();
self.rsc.ui.textures.reupload();
self.state.android_state_mut().renderer = Some(renderer);
self.render(ctx);
}
+13 -1
View File
@@ -52,8 +52,16 @@ pub fn recent_click(last_click: &mut Instant) -> bool {
/// rather than reacting to `PressStart` alone the way `click_or_drag`'s
/// consumer used to (Iris, 2026-09-06: "if I swipe over the input bar it
/// brings up the keyboard").
/// `CursorSense::Cancel` is in the set for the same reason `DragGesture`
/// registers it: if a scroll area or a list takes the pointer mid-gesture,
/// this field sees no `PressEnd`, and a `press_origin` left set is then
/// compared against the *next* press -- a stray selection, or a keyboard
/// summoned by a tap somewhere else entirely.
fn press_track() -> CursorSenses {
CursorSense::click() | CursorSense::Pressing(CursorButton::Left) | CursorSense::unclick()
CursorSense::click()
| CursorSense::Pressing(CursorButton::Left)
| CursorSense::unclick()
| CursorSense::Cancel
}
pub struct Selector;
@@ -185,6 +193,7 @@ fn on_press(
state.focus_gained(render.window_region(&id, &*rsc));
}
}
CursorSense::Cancel => id.edit(rsc).text.press_origin = None,
_ => {}
}
return;
@@ -205,6 +214,9 @@ fn on_press(
ctx.text.press_origin = None;
}
}
// The gesture was taken by somebody else, so it is not a tap and
// must not grant focus when it ends out of this widget's sight.
CursorSense::Cancel => id.edit(rsc).text.press_origin = None,
CursorSense::PressEnd(_) => {
let was_tap = id.edit(rsc).text.press_origin.take().is_some();
if was_tap {
+4
View File
@@ -27,6 +27,10 @@ pub struct App<State: AppState> {
impl<State: AppState> App<State> {
pub fn run() {
// The desktop's `main` in everything but name -- see
// `super::logging`'s doc for why the logger goes here and what
// its absence hid.
super::logging::install(log::LevelFilter::Info);
let event_loop = EventLoop::with_user_event().build().unwrap();
let proxy = event_loop.create_proxy();
event_loop
+86
View File
@@ -0,0 +1,86 @@
//! A stderr logger for the desktop entry point.
//!
//! Without one, `log::` calls on this side go nowhere: `log`'s default is
//! a no-op logger, and nothing in `desktop-app` or the examples ever
//! installed a real one. That is how iris came to have a renderer that
//! silently fell back to GLES (and, on this VM, on to llvmpipe when the
//! host took its GPU away) with **no record anywhere of what
//! drew the frame** -- a layer-2 screenshot off llvmpipe and one off the
//! host GPU are the same PNG, and the difference is exactly what a
//! screenshot is being taken to judge.
//!
//! Installed by [`DefaultApp::run`](super::app::DefaultApp::run) rather
//! than by a library call somewhere, because that function already takes
//! over the process -- it owns the event loop and does not return -- so
//! it is the desktop's `main` in everything but name, and one install
//! there covers `desktop-app` and every example at once. `try_init`
//! rather than `init`: a binary that installed its own logger first keeps
//! it, and a second `DefaultApp::run` in one process is not an error.
//!
//! Deliberately not `env_logger`. All this owes the reader is a level and
//! a line, which is a page of code against a dependency plus its own
//! filter dialect; the Android side is `android_logger` for the same
//! reason -- one line per platform's own convention.
use std::io::Write;
use log::{Level, LevelFilter, Log, Metadata, Record};
/// Reads one level name from `RUST_LOG` -- `off`, `error`, `warn`,
/// `info`, `debug`, `trace`, case-insensitively. **Not env_logger's
/// per-module filter syntax**: anything else is ignored and the default
/// stands, rather than being silently read as "off", since a typo that
/// turned logging off would be indistinguishable from a quiet program.
fn level_from_env(default: LevelFilter) -> LevelFilter {
match std::env::var("RUST_LOG") {
Ok(text) => text.trim().parse().unwrap_or(default),
Err(_) => default,
}
}
struct StderrLogger {
level: LevelFilter,
}
impl Log for StderrLogger {
fn enabled(&self, metadata: &Metadata) -> bool {
metadata.level() <= self.level
}
fn log(&self, record: &Record) {
if !self.enabled(record.metadata()) {
return;
}
// One write, not a `writeln!` per part: two threads logging at
// once interleave otherwise, and the frame and input traces are
// both written from whichever thread produced them.
let line = format!(
"{level:<5} {target}: {args}\n",
level = match record.level() {
Level::Error => "ERROR",
Level::Warn => "WARN",
Level::Info => "INFO",
Level::Debug => "DEBUG",
Level::Trace => "TRACE",
},
target = record.target(),
args = record.args(),
);
let _ = std::io::stderr().write_all(line.as_bytes());
}
fn flush(&self) {
let _ = std::io::stderr().flush();
}
}
/// Installs the stderr logger unless this process already has one.
/// Defaults to `info`, which is where the renderer says which adapter it
/// got; `RUST_LOG=debug` adds iris's own per-frame lines.
pub fn install(default: LevelFilter) {
let level = level_from_env(default);
let logger = Box::leak(Box::new(StderrLogger { level }));
if log::set_logger(logger).is_ok() {
log::set_max_level(level);
}
}
+46 -1
View File
@@ -15,6 +15,7 @@ mod access;
mod app;
mod attr;
mod input;
mod logging;
mod platform;
mod render;
@@ -294,6 +295,31 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
ui_state.focus = None;
}
if input_changed {
// The winit half of `iris::input` (`sense::log_input_event`'s
// own doc): no batching here, so `historical` is always empty
// -- winit hands one `WindowEvent` per pointer sample, unlike
// Android's `MotionEvent`. The action is read back off the
// buttons `Input::event` just updated, the same test
// `GestureOutcome`'s callers already use to tell a press from a
// release. Computed only when tracing is on, same reasoning as
// `log_input_event` itself gating on it.
if crate::diagnostics::trace_enabled() {
let action = if cursor_state.buttons.left.is_start() {
"down"
} else if cursor_state.buttons.left.is_end() {
"up"
} else {
"move"
};
let t_ms = cursor_state.time.duration_since(render.epoch()).as_millis() as u64;
crate::sense::log_input_event(
action,
cursor_state.pos.x,
cursor_state.pos.y,
t_ms,
&[],
);
}
let window_size = ui_state.window_size();
render.run_sensors(rsc, state, cursor_state, window_size);
}
@@ -313,11 +339,14 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
// `IrisViewPeer::render`'s `post_frame_callback` does on
// Android. Nothing else in iris moves without an input
// event.
let animating = rsc.ui_mut().tick_animations(std::time::Instant::now());
let frame_start = std::time::Instant::now();
let animating = rsc.ui_mut().tick_animations(frame_start);
let ui_state = state.default_state_mut();
render.update(&ui_state.root, rsc);
ui_state.renderer.update(&mut rsc.ui, render);
let draw_start = std::time::Instant::now();
ui_state.renderer.draw();
crate::diagnostics::log_frame(render, frame_start, draw_start.elapsed(), animating);
if animating {
ui_state.window.request_redraw();
}
@@ -334,6 +363,22 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
render.resize((size.width, size.height));
ui_state.renderer.resize(size)
}
// Dragging the window to a display with a different scale.
// Both copies again, the pair `new` sets at startup -- read
// through `content_scale` rather than from the event, so
// `IRIS_SCALE` still pins the density it was given (the
// `--phone` window must not follow the monitor). winit sends
// the matching `Resized` separately. Before 2026-09-07 this
// event was unhandled, so every `dp` and every rasterised
// glyph stayed at the density the window opened on
// (docs/REVIEW-2026-09-07.md's R5) -- invisible on this
// machine, where every display is 1.0.
WindowEvent::ScaleFactorChanged { .. } => {
let scale = content_scale(ui_state.window.as_ref());
rsc.ui.text.density = scale;
render.set_density(scale);
ui_state.window.request_redraw();
}
WindowEvent::KeyboardInput { event, .. } => {
if let Some(sel) = ui_state.focus
&& event.state.is_pressed()
+69 -7
View File
@@ -29,7 +29,16 @@ impl UiRenderer {
}
pub fn draw(&mut self) {
let output = self.surface.get_current_texture().unwrap();
let output = match self.surface.get_current_texture() {
CurrentSurfaceTexture::Success(texture)
| CurrentSurfaceTexture::Suboptimal(texture) => texture,
// wgpu 30 turned this Result into an enum; every arm here was an
// `Err` the previous `.unwrap()` panicked on, except `Occluded`,
// which is new. Named rather than swallowed: a window that stops
// presenting silently is the state this file's `pre_present_notify`
// comment was written about.
other => panic!("no surface texture to draw into: {other:?}"),
};
let view = output
.texture
.create_view(&TextureViewDescriptor::default());
@@ -59,7 +68,7 @@ impl UiRenderer {
// the compositor's first resize -- intermittently, on about a fifth of
// starts, with nothing left to flush it.
self.window.pre_present_notify();
output.present();
self.queue.present(output);
}
pub fn resize(&mut self, size: &PhysicalSize<u32>) {
@@ -82,19 +91,44 @@ impl UiRenderer {
pub fn new(window: Arc<Window>) -> Self {
let size = window.inner_size();
let instance = Instance::new(&InstanceDescriptor {
// `force-gles` on the desktop too, not just on Android: the
// GLES backend has behaviour of its own (a one-layer array
// texture is a `GL_TEXTURE_2D` -- see
// `GpuTextures::create_array_texture`), and a machine with a
// real GPU is where that is cheap to reproduce and screenshot.
backends: if cfg!(feature = "force-gles") {
let mut backends = if cfg!(feature = "force-gles") {
Backends::GL
} else {
Backends::PRIMARY
},
..Default::default()
};
// The display handle comes from the window rather than being left
// out: wgpu 30 asks for it whenever a GLES surface is going to be
// presented on Wayland, which is exactly what the fallback below
// produces on this machine.
let mut instance = Instance::new(InstanceDescriptor {
backends,
..InstanceDescriptor::new_with_display_handle(Box::new(window.clone()))
});
// The same fallback the Android backend grew in 85869d0, and for
// the same reason: a machine can advertise a Vulkan ICD with no
// device behind it, and refusing to draw at all because the only
// usable adapter is a GLES one is iris's bug rather than the
// machine's. On this VM the Vulkan device disappears whenever
// the host refuses a virtio-gpu context, so `run-headless.sh` --
// layer 2 of the test rig -- aborted with `Could not get
// adapter!` while GL was sitting there working. Probed before the
// surface exists, matching Android, where an instance carrying
// both backends fails worse than one carrying the wrong one.
if backends != Backends::GL && instance.enumerate_adapters(backends).block_on().is_empty() {
log::warn!(
"iris renderer: no {backends:?} adapter on this machine, falling back to GLES"
);
backends = Backends::GL;
instance = Instance::new(InstanceDescriptor {
backends,
..InstanceDescriptor::new_with_display_handle(Box::new(window.clone()))
});
}
let surface = instance
.create_surface(window.clone())
@@ -105,9 +139,35 @@ impl UiRenderer {
power_preference: PowerPreference::default(),
compatible_surface: Some(&surface),
force_fallback_adapter: false,
..Default::default()
})
.block_on()
.expect("Could not get adapter!");
.unwrap_or_else(|error| {
panic!("No usable GPU adapter for backends {backends:?}: {error}")
});
// Say which adapter won, in the same words the Android backend
// uses. Without it a layer-2 screenshot or frame time from this
// window carries no record of what drew it, and the two cases that
// matter look identical in the PNG: the host's real GPU, and
// llvmpipe after this VM lost its virtio-gpu contexts. That
// happened on 2026-09-08, and the only reason anyone noticed is
// that the fallback above did not exist yet and the app aborted
// instead. A silent fallback needs this line to stay honest.
{
let info = adapter.get_info();
log::info!(
"iris renderer: {name} ({backend:?}, {driver}{driver_info}) on {backends:?}",
name = info.name,
backend = info.backend,
driver = info.driver,
driver_info = if info.driver_info.is_empty() {
String::new()
} else {
format!(" {}", info.driver_info)
},
);
}
// No features beyond what wgpu asks for by default, and no
// binding-array limits: the atlas is one texture_2d_array and a
@@ -137,6 +197,8 @@ impl UiRenderer {
let config = SurfaceConfiguration {
usage: TextureUsages::RENDER_ATTACHMENT,
format: surface_format,
// wgpu 30's new field; `Auto` is what every earlier version did.
color_space: SurfaceColorSpace::Auto,
width: size.width,
height: size.height,
// Vsync, because a toolkit aiming at battery life must not present
+83
View File
@@ -0,0 +1,83 @@
//! The trace toggle for the `iris::input`/`iris::frame` diagnostics (Iris's
//! 2026-09-07 request: "add another button to copy input event info ...
//! instrument a lot of the code with timings"), and the one place both
//! call sites' `iris::frame` line is written from.
//!
//! **Why a crate-level flag instead of `log::log_enabled!`/
//! `log::set_max_level`**: the app already installs its logger at
//! `LevelFilter::Debug` (`iris/android-app/src/lib.rs`'s `JNI_OnLoad`), so
//! a `log::Level::Debug` line reaches `client_core::log_ring`'s ring
//! regardless of what this instrument would prefer -- `RingLogger::enabled`
//! is unconditionally `true` by design (its own doc: "the ring wants
//! everything"). So the level alone cannot give these two targets a
//! default-off switch; the gate has to live on this side, checked before
//! `log::debug!` is even reached.
//!
//! **Why default off matters**: the ring is 2000 lines / 256 KiB
//! (`client_core::log_ring::DEFAULT_MAX_LINES`/`DEFAULT_MAX_BYTES`), and a
//! 120Hz session logging both a line per touch sample and a line per frame
//! fills that in seconds -- so a caller turns this on only for the length
//! of whatever is being investigated, and the report says so at its top
//! (a caller's job; see `iris::diagnostics::trace_enabled` used at the top
//! of whatever builds the report).
//!
//! **Not yet wired to a control**: the Diagnostics pane that would hold the
//! switch is in `iris/android-app/src/bench_client.rs`, which another agent
//! has open at the same time this was written. `set_trace` is the whole
//! surface a button needs; wiring one is a follow-up.
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use iris_core::UiRenderState;
static TRACE: AtomicBool = AtomicBool::new(false);
/// Turns the `iris::input`/`iris::frame` `debug!` lines on or off. Off by
/// default -- see the module doc for why turning the level on alone would
/// not do it.
pub fn set_trace(on: bool) {
TRACE.store(on, Ordering::Relaxed);
}
/// Whether the `iris::input`/`iris::frame` lines are enabled right now --
/// what a report's header reads before deciding what to say about the
/// lines it does or doesn't hold (UI_RULES.md: "design the unknown state
/// first").
pub fn trace_enabled() -> bool {
TRACE.load(Ordering::Relaxed)
}
/// One `iris::frame` line, called once per frame from each backend's own
/// frame function -- `android::view::IrisViewPeer::render`,
/// `default::DefaultApp::window_event`'s `RedrawRequested` arm, and
/// `harness::Harness::frame` -- after the draw (or, on the harness, where a
/// draw would be; `draw` is `Duration::ZERO` there since nothing is
/// actually submitted to a GPU).
///
/// `render.update(...)` must already have run this frame: this reads back
/// what it recorded (`UiRenderState::last_layout_duration`/
/// `last_redraw_kind`/`frame_number`) rather than timing anything itself,
/// so a caller's own measurement of the phase around `update()` and around
/// its own draw call are the only two `Instant` pairs in the whole path --
/// see each call site's own comment for why it is not restructured to fit
/// this instead.
pub fn log_frame(render: &UiRenderState, now: Instant, draw: Duration, animating: bool) {
if !trace_enabled() {
return;
}
let since_input = render
.time_since_input(now)
.map(|d| format!("{}ms", d.as_millis()))
.unwrap_or_else(|| "none".to_string());
log::debug!(
target: "iris::frame",
"iris frame: n={} now={}ms since_input={since_input} layout={:?} draw={:?} \
redraw={:?} primitives={} animating={animating}",
render.frame_number(),
now.duration_since(render.epoch()).as_millis(),
render.last_layout_duration(),
draw,
render.last_redraw_kind(),
render.active_primitive_count(),
);
}
+9 -3
View File
@@ -44,13 +44,19 @@ impl<WL: WidgetLike<Rsc, Tag>, Rsc: HasEvents, Tag> Eventable<Rsc, Tag> for WL {
widget_trait! {
pub trait TaskEventable<Rsc: HasEvents + HasTasks>;
fn task_on<'a, E: EventLike, F: AsyncWidgetEventFn<Rsc, WL::Widget>>(
/// No `Data: Send` bound, deliberately: the registered handler below
/// takes `|_, rsc|` and the event's data never crosses into the
/// spawned future -- `AsyncEventIdCtx` carries the widget id and the
/// task handle and nothing else. The bound used to be here anyway, and
/// it was the whole reason `CursorData`'s pointer state was behind a
/// `Mutex` rather than owned by the input handler (Iris, 2026-09-08:
/// never reach for a lock first).
fn task_on<E: EventLike, F: AsyncWidgetEventFn<Rsc, WL::Widget>>(
self,
event: E,
f: F,
) -> impl WidgetIdFn<Rsc, WL::Widget>
where <E::Event as Event>::Data<'a>: Send,
for<'b> F::CallRefFuture<'b>: Send,
where for<'b> F::CallRefFuture<'b>: Send,
{
let f = Arc::new(f);
move |rsc| {
+41 -9
View File
@@ -42,9 +42,11 @@ pub enum TouchAction {
Move,
Up,
/// The gesture taken away by the system (a parent view claiming it, a
/// call arriving). It ends the press exactly as `Up` does -- a
/// release that never arrives leaves pointer capture held forever --
/// which is why a replay file can say it.
/// call arriving, the swipe up from the bottom edge to leave the
/// app). It ends the press, because a release that never arrives
/// leaves pointer capture held forever -- but it is not a release,
/// and nothing follows from it: no tap, no selection, no fling. See
/// `CursorState::cancelled`, which is what it sets.
Cancel,
}
@@ -58,6 +60,18 @@ impl TouchAction {
_ => None,
}
}
/// The inverse of [`Self::parse`] -- what [`Harness::touch`] hands
/// [`crate::sense::log_input_event`], so an `iris::input` line and a
/// `.touch` file agree on one spelling of each action.
pub fn word(self) -> &'static str {
match self {
Self::Down => "down",
Self::Move => "move",
Self::Up => "up",
Self::Cancel => "cancel",
}
}
}
#[derive(Clone, Copy, Debug)]
@@ -127,8 +141,8 @@ impl TouchScript {
}
/// Counts the frames something asked for without drawing any -- the
/// harness's `RequestRedraw`. A `List` coasting through a fling asks for
/// the next frame through this (`List::set_redraw_handle`), so a test can
/// harness's `RequestRedraw`. A `LazySpan` coasting through a fling asks for
/// the next frame through this (`UiData::animate` and `Widget::tick`), so a test can
/// tell "nothing moved" from "nothing was even asked to move".
#[derive(Default)]
pub struct RedrawCounter(AtomicUsize);
@@ -321,7 +335,7 @@ impl Harness {
}
/// The `Instant` this harness means by `t_ms`. Public because a
/// caller driving `List::tick_fling` or `DragGesture` by hand needs
/// caller driving `Scroll::tick` or `DragGesture` by hand needs
/// to date those calls on the same clock the touch samples use.
pub fn at(&self, t_ms: u64) -> Instant {
self.base + Duration::from_millis(t_ms)
@@ -345,13 +359,19 @@ impl Harness {
update(&mut self.state, &mut self.rsc);
}
let now = self.at(t_ms);
self.rsc.ui.tick_animations(now);
let animating = self.rsc.ui.tick_animations(now);
self.render.update(&self.state.root, &mut self.rsc);
// No GPU here, so there is no draw phase to time -- `draw` is
// always zero. `layout`/`redraw`/`primitives` are still real,
// because `render.update` just ran; see
// `iris::diagnostics::log_frame`'s own doc for why this reads
// those back rather than timing anything itself.
crate::diagnostics::log_frame(&self.render, now, Duration::ZERO, animating);
}
/// Frames every `step_ms` up to and including `end_ms` -- what a
/// fling needs, since it moves only while something ticks it
/// (`List::fling`'s doc). Returns the time of the last frame run.
/// (`Scroll::fling`'s doc). Returns the time of the last frame run.
pub fn frames_until(&mut self, from_ms: u64, end_ms: u64, step_ms: u64) -> u64 {
debug_assert!(step_ms > 0, "a frame loop with no step never ends");
let mut t = from_ms;
@@ -375,8 +395,20 @@ impl Harness {
self.cursor.buttons.left.update(true);
}
TouchAction::Move => {}
TouchAction::Up | TouchAction::Cancel => self.cursor.buttons.left.update(false),
TouchAction::Up => self.cursor.buttons.left.update(false),
// The platform taking the gesture away, not the finger
// lifting -- see `CursorState::cancelled`.
TouchAction::Cancel => {
self.cursor.buttons.left.update(false);
self.cursor.cancelled = true;
}
}
// Layer 1's half of `iris::input` (`sense::log_input_event`'s own
// doc): no batching happens here, so `historical` is always empty
// and `t_ms` is the script's own column, which is what makes this
// round-trip through `report_to_touch.py` back into an identical
// `TouchScript`.
crate::sense::log_input_event(action.word(), pos.x, pos.y, t_ms, &[]);
let cursor = self.cursor.clone();
self.render
.run_sensors(&mut self.rsc, &mut self.state, cursor, self.size);
+430 -11
View File
@@ -50,7 +50,18 @@ fn scrolled_rects(
span.push(row.any());
}
let span = rsc.ui.widgets.add_strong(span);
let scroll = rsc.ui.widgets.add_strong(Scroll::new(span.any(), Axis::Y));
let scroll = rsc
.ui
.widgets
// Anchored at the *start*: every test below scrolls down from
// the top and states its sign convention against that. An
// end-anchored area now sits at its end from its first drawn
// frame (`Scroll::draw` measures and places in the same frame),
// so `at_end: true` here would mean scrolling down from a
// position that is already the bottom -- a clamped no-op, which
// reads as "the move path is broken" rather than as the test
// starting somewhere it did not mean to.
.add_strong(Scroll::new(span.any(), Axis::Y, false));
let weak = scroll.weak();
(weak, scroll.any(), rects)
}
@@ -65,7 +76,14 @@ fn an_unchanged_frame_draws_and_rewrites_nothing() {
render.resize((800.0, 20000.0));
render.update(&root, &mut rsc);
render.take_counters(); // discard the first, real draw
// Two, not one: the first offers `Scroll`'s content the container's
// own length as a placeholder (nothing has been measured yet) and
// `Scroll::draw` asks to be drawn again once it knows the real one,
// which the second update is. Only after that is the tree settled --
// see `scrolling_moves_in_o1_without_a_redraw`'s own note on the
// same first draw.
render.update(&root, &mut rsc);
render.take_counters(); // discard the first, real draws
render.update(&root, &mut rsc);
let (draws, rewrites, moves, _shapes) = render.take_counters();
@@ -150,8 +168,8 @@ fn hit_testing_follows_a_scrolled_widget() {
/// `ActiveData::mask` is the mask a widget was drawn **under**, not the one
/// it set for itself -- `redraw` feeds it straight back in as the inherited
/// mask, so storing the set one hands a `Masked` its own mask the second
/// time round and trips `Painter::set_mask`'s nested-mask assert. That was
/// an abort (`assertion failed: self.mask == MaskIdx::NONE`) the first time
/// time round -- which `Painter::set_mask` asserts against, since a mask
/// that chains to itself is a clip loop. That was an abort the first time
/// the composer's new scroll area was redrawn on the emulator; a targeted
/// redraw of a `Masked` is what any real screen does whenever anything
/// inside it changes.
@@ -161,7 +179,10 @@ fn redrawing_a_masked_widget_does_not_nest_its_own_mask() {
ui: UiData::default(),
};
let (_scroll, inner_root, _rects) = scrolled_rects(&mut rsc, 8);
let masked = rsc.ui.widgets.add_strong(Masked { inner: inner_root });
let masked = rsc.ui.widgets.add_strong(Masked {
shape: None,
inner: inner_root,
});
let masked_id = masked.id();
let root = masked.any();
let mut render = UiRenderState::new();
@@ -184,7 +205,10 @@ fn a_mask_stays_put_while_its_scrolled_content_moves() {
ui: UiData::default(),
};
let (scroll, inner_root, _rects) = scrolled_rects(&mut rsc, 500);
let masked = rsc.ui.widgets.add_strong(Masked { inner: inner_root });
let masked = rsc.ui.widgets.add_strong(Masked {
shape: None,
inner: inner_root,
});
let masked_id = masked.id();
let root = masked.any();
let mut render = UiRenderState::new();
@@ -318,7 +342,12 @@ fn a_scroll_measures_the_box_it_was_offered_not_the_window() {
x: None,
y: Some(Len::abs(1000.0)),
});
let scroll = rsc.ui.widgets.add_strong(Scroll::new(tall.any(), Axis::Y));
let scroll = rsc
.ui
.widgets
// Start-anchored, so the `scroll(-37.0)` below has somewhere to
// go -- see `scrolled_rects`' note on the same choice.
.add_strong(Scroll::new(tall.any(), Axis::Y, false));
let scroll_w = scroll.weak();
let scroll_id = scroll.id();
let capped = rsc.ui.widgets.add_strong(MaxSize {
@@ -381,7 +410,12 @@ fn a_panned_widgets_own_hit_box_moves_exactly_once() {
y: Some(Len::abs(1000.0)),
});
let tall_w = tall.weak();
let scroll = rsc.ui.widgets.add_strong(Scroll::new(tall.any(), Axis::Y));
let scroll = rsc
.ui
.widgets
// Start-anchored, so the `scroll(-37.0)` below has somewhere to
// go -- see `scrolled_rects`' note on the same choice.
.add_strong(Scroll::new(tall.any(), Axis::Y, false));
let scroll_w = scroll.weak();
let root = scroll.any();
@@ -418,7 +452,10 @@ fn a_masked_widget_keeps_one_mask_slot_that_is_always_its_own_region() {
ui: UiData::default(),
};
let (_scroll, inner_root, _rects) = scrolled_rects(&mut rsc, 8);
let masked = rsc.ui.widgets.add_strong(Masked { inner: inner_root });
let masked = rsc.ui.widgets.add_strong(Masked {
shape: None,
inner: inner_root,
});
let masked_id = masked.id();
// Placed at the bottom of a `Span::DOWN` behind a `rest(1)` sibling,
// which is what moves the bar away from the provisional slot it is
@@ -453,7 +490,7 @@ fn a_masked_widget_keeps_one_mask_slot_that_is_always_its_own_region() {
);
let mask = *rsc.ui.masks.iter().next().unwrap();
assert_eq!(
mask.region,
render.primitives.instance(mask.primitive).region,
render.active.get(&masked_id).unwrap().region,
"the mask a descendant clips against must be this widget's current box"
);
@@ -565,7 +602,7 @@ fn a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at(
/// A parent that both `mov`s a child (its own layout moved the box it
/// offers) and `reposition`s it inside that box in the same frame -- what
/// `List::place`'s Bottom-known branch does once a row's cached height
/// `LazySpan::place`'s Bottom-known branch does once a row's cached height
/// stops matching what the row reports, which is reachable as soon as a
/// transcript row's blocks wrap (docs/IRIS_TODO.md's "Found by P1a").
struct MoveThenPlace {
@@ -655,3 +692,385 @@ fn a_widget_moved_by_its_parent_and_then_placed_inside_it_lands_at_the_placement
after={after:?}"
);
}
// ---------------------------------------------------------------------
// LAYOUT.md's "Masks with a shape" -- its pass conditions, at layer 1.
//
// The shape a mask clips to is a *primitive already drawn*, never a copy
// of one, so "the child's clipped corner" and "the container's own corner"
// are the same arithmetic. These say so by evaluating both and demanding
// exact equality: an approximate assertion would also pass a second copy
// of the radius that merely happened to agree.
// ---------------------------------------------------------------------
const RADIUS: f32 = 20.0;
/// A rounded container with `.masked_by` it, holding a `Rect::REST` child
/// that fills it -- so the child's own corners are exactly the corners
/// being clipped away. Returns the drawn state, the mask, the child, and
/// the shape primitive the mask points at.
fn rounded_container(rsc: &mut TestRsc) -> (UiRenderState, MaskIdx, WidgetId, u32) {
let child = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let child_id = child.id();
let shape = rsc
.ui
.widgets
.add_strong(Rect::new(UiColor::BLACK).radius(Len::abs(RADIUS)));
let shape_id = shape.id();
let root = rsc
.ui
.widgets
.add_strong(Masked {
shape: Some(shape.any()),
inner: child.any(),
})
.any();
let mut render = UiRenderState::new();
render.resize((200.0, 100.0));
render.update(&root, rsc);
let mask = render
.active
.get(&child_id)
.expect("the child is drawn")
.mask;
assert_ne!(
mask,
MaskIdx::NONE,
"the child was drawn with no clip at all"
);
let slot = render
.first_primitive(shape_id)
.expect("the shape widget drew a rect");
(render, mask, child_id, slot)
}
/// The pass condition: the child's coverage at a corner pixel *equals*
/// the container's own coverage there. Exactly equal, because it is the
/// same primitive evaluated once -- LAYOUT.md's point 1.
#[test]
fn a_masked_child_is_clipped_by_its_container_s_own_corner() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (render, mask, _child, slot) = rounded_container(&mut rsc);
let corners = render.primitive_corners(slot, &rsc);
let radius = render
.primitives
.primitive_data::<RectPrimitive>(slot)
.expect("a mask's shape is a rect")
.radius;
// Across the whole corner arc, not one point on it: a single sample
// is satisfied by a mask that clips to the box and happens to agree
// where the two coincide. Swept from the arc's own centre -- the
// straight chord between the two ends of the arc lies *inside* the
// circle everywhere, so a walk along it never leaves the shape and
// the `outside` count below is what caught that.
let arc_center = corners.top_left + Vec2::new(radius, radius);
let (mut outside, mut inside) = (0, 0);
for i in 0..=20 {
let angle = std::f32::consts::FRAC_PI_2 * i as f32 / 20.0;
let dir = Vec2::new(-angle.cos(), -angle.sin());
for out in [-1.5f32, 0.0, 1.5] {
let pos = arc_center + dir * (radius + out);
let container = rounded_rect_coverage(pos, corners.top_left, corners.bot_right, radius);
assert_eq!(
render.mask_coverage(mask, pos, &rsc),
container,
"at {pos:?} the child's clip and the container's own edge disagree",
);
if container < 0.5 {
outside += 1;
} else {
inside += 1;
}
}
}
assert!(
outside > 0 && inside > 0,
"the sweep stayed on one side of the curve ({outside} out, {inside} in), so it proved \
nothing about the corner"
);
}
/// A hit test asks the same question the pixels do: the corner the
/// container rounded away is not there to be pressed, and a point just
/// inside the curve is. LAYOUT.md's point 4.
#[test]
fn a_mask_s_shape_decides_what_can_be_pressed() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (render, mask, _child, slot) = rounded_container(&mut rsc);
let corners = render.primitive_corners(slot, &rsc);
// The very corner of the box, which the radius cut off.
let cut = corners.top_left + Vec2::new(1.0, 1.0);
assert!(
!render.mask_admits(mask, cut, &rsc),
"the corner the container rounded away is still pressable",
);
// The same distance in along the diagonal, past the curve.
let inside = corners.top_left + Vec2::new(RADIUS, RADIUS);
assert!(
render.mask_admits(mask, inside, &rsc),
"a point well inside the curve is not pressable",
);
// And the middle of an edge, which no radius touches -- the half the
// rounding had no reason to change.
let edge = Vec2::new(
(corners.top_left.x + corners.bot_right.x) / 2.0,
corners.top_left.y + 1.0,
);
assert!(
render.mask_admits(mask, edge, &rsc),
"a straight edge between two corners is not pressable",
);
}
/// Nested masks multiply, so a pixel inside two feathered corners is
/// dimmed by both -- LAYOUT.md's point 2, and the "alpha should be
/// decreased / multiplied" Iris asked for. Written as a product of the
/// two the shader would compute separately, which is what "multiply"
/// means and what an intersection test would get wrong.
#[test]
fn nested_masks_multiply_their_coverage() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let child = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let child_id = child.id();
let inner_shape = rsc
.ui
.widgets
.add_strong(Rect::new(UiColor::BLACK).radius(Len::abs(RADIUS)));
let inner_shape_id = inner_shape.id();
let inner = rsc.ui.widgets.add_strong(Masked {
shape: Some(inner_shape.any()),
inner: child.any(),
});
let outer_shape = rsc
.ui
.widgets
.add_strong(Rect::new(UiColor::BLACK).radius(Len::abs(RADIUS)));
let outer_shape_id = outer_shape.id();
let root = rsc
.ui
.widgets
.add_strong(Masked {
shape: Some(outer_shape.any()),
inner: inner.any(),
})
.any();
let mut render = UiRenderState::new();
render.resize((200.0, 100.0));
render.update(&root, &mut rsc);
let mask = render.active.get(&child_id).expect("drawn").mask;
let one = |render: &UiRenderState, rsc: &TestRsc, id, pos| {
let slot = render.first_primitive(id).expect("a shape rect");
let c = render.primitive_corners(slot, rsc);
let radius = render
.primitives
.primitive_data::<RectPrimitive>(slot)
.unwrap()
.radius;
rounded_rect_coverage(pos, c.top_left, c.bot_right, radius)
};
// A point on the corner arc, where both feathers are partial -- the
// only place a product and a minimum differ.
let slot = render.first_primitive(inner_shape_id).unwrap();
let corners = render.primitive_corners(slot, &rsc);
let pos = corners.top_left + Vec2::new(RADIUS * 0.3, RADIUS * 0.3);
let inner_cov = one(&render, &rsc, inner_shape_id, pos);
let outer_cov = one(&render, &rsc, outer_shape_id, pos);
assert!(
inner_cov > 0.0 && inner_cov < 1.0,
"the sample point is not inside a feather ({inner_cov}), so this proves nothing"
);
assert_eq!(
render.mask_coverage(mask, pos, &rsc),
inner_cov * outer_cov,
"two nested masks must multiply, not intersect",
);
}
/// A plain `.masked()` -- no shape given -- still clips to the widget's
/// own box with square corners, which is what every list and scroll area
/// relies on. The half the shape work had no reason to touch, and the one
/// that would silently round every existing clip if `set_mask` ever wrote
/// a radius of its own.
#[test]
fn a_plain_mask_still_clips_to_a_square_box() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (_scroll, inner_root, _rects) = scrolled_rects(&mut rsc, 8);
let root = rsc
.ui
.widgets
.add_strong(Masked {
shape: None,
inner: inner_root,
})
.any();
let mut render = UiRenderState::new();
render.resize((200.0, 100.0));
render.update(&root, &mut rsc);
let mask = *rsc.ui.masks.iter().next().expect("one mask");
let corners = render.primitive_corners(mask.primitive, &rsc);
let mask_idx = MaskIdx::preset(0);
assert!(
render.mask_admits(mask_idx, corners.top_left + Vec2::new(0.5, 0.5), &rsc),
"a square clip must admit its own corner pixel",
);
assert!(
!render.mask_admits(mask_idx, corners.top_left - Vec2::new(2.0, 2.0), &rsc),
"a square clip must reject a point outside it",
);
}
/// A scroll area created to be *read* opens at the beginning of its
/// content, however many frames it takes to learn how long that content
/// is.
///
/// The bug this pins: `content_len` was `0.0` both for "nothing here" and
/// for "not drawn yet", so the first frame's clamp found a range of zero,
/// read `amt == len` as "sitting at the end", and set `snap_end` -- and
/// the frame after, now knowing the real length, jumped to it. On screen
/// that was a code fence opening at the end of its longest line, in the
/// middle of a word (`iris/run-headless.sh phone`, 2026-09-08).
#[test]
fn a_scroll_area_opens_at_the_start_of_content_it_has_not_measured_yet() {
for (name, at_end, want) in [("read", false, 0.0), ("written", true, 4900.0)] {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let fill = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)).any();
let tall = rsc.ui.widgets.add_strong(Sized {
inner: fill,
x: None,
y: Some(Len::abs(5000.0)),
});
let scroll = rsc
.ui
.widgets
.add_strong(Scroll::new(tall.any(), Axis::Y, at_end));
let weak = scroll.weak();
let root = scroll.any();
let mut render = UiRenderState::new();
render.resize((800.0, 100.0));
// Twice: the first draw is the one that measures the content, and
// the defect only showed on the second. The touch in between is
// what asks for that second draw -- an unchanged frame draws
// nothing at all, which is the point of the frame before it.
render.update(&root, &mut rsc);
let _ = rsc.ui.widgets.get_mut(&weak);
render.update(&root, &mut rsc);
let amt = rsc.ui.widgets.get(&weak).unwrap().amt();
assert!(
(amt - want).abs() < 0.01,
"an area to be {name} should have opened at {want}, got {amt}"
);
}
}
/// docs/IRIS_TODO.md's "A `Span` of `Pad`ded children inside another
/// `Span` places those children a slot out of step", worked around in
/// `transcript-ui/src/tool.rs` by flattening the two spans into one --
/// which costs a tool group the inset its cards should sit inside.
///
/// The shape is the smallest one that reproduced it there: an outer
/// `Span(DOWN)` whose second child is another `Span(DOWN)` whose children
/// are each a `Pad` around a fixed-height rect. Each rect is asserted to
/// be *drawn* where its own box is -- `primitive_corners` rather than
/// `window_region`, since the report is about what is on screen and the
/// two resolve the move chain differently.
#[test]
fn a_span_of_padded_children_inside_a_span_draws_each_where_its_box_is() {
const PAD: f32 = 4.0;
const ROW: f32 = 20.0;
const HEADER: f32 = 30.0;
let mut rsc = TestRsc {
ui: UiData::default(),
};
let header_fill = rsc.ui.widgets.add_strong(Rect::new(UiColor::RED)).any();
let header_id = header_fill.id();
let header = rsc.ui.widgets.add_strong(Sized {
inner: header_fill,
x: None,
y: Some(Len::abs(HEADER)),
});
let mut inner = Span::empty(Dir::DOWN);
let mut rects = Vec::new();
for _ in 0..3 {
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
rects.push(rect.weak());
let sized = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(),
x: None,
y: Some(Len::abs(ROW)),
});
let padded = rsc.ui.widgets.add_strong(Pad {
padding: Padding::uniform(PAD),
inner: sized.any(),
});
let fill = rsc.ui.widgets.add_strong(Rect::new(UiColor::BLUE)).any();
let card = rsc.ui.widgets.add_strong(Stack {
children: vec![fill, padded.any()],
size: StackSize::Child(1),
});
let wide = rsc.ui.widgets.add_strong(Sized {
inner: card.any(),
x: Some(Len::rest(1.0)),
y: None,
});
inner.push(wide.any());
}
let inner = rsc.ui.widgets.add_strong(inner);
let outer = rsc.ui.widgets.add_strong(Span {
children: vec![header.any(), inner.any()],
dir: Dir::DOWN,
gap: Len::ZERO,
});
let mut list = LazySpan::new(Dir::DOWN, true);
list.push_back(LazyItem::new(0, outer.any()));
let list = rsc.ui.widgets.add_strong(list);
let root = rsc
.ui
.widgets
.add_strong(Masked {
shape: None,
inner: list.any(),
})
.any();
let mut render = UiRenderState::new();
render.resize((200.0, 400.0));
render.update(&root, &mut rsc);
render.update(&root, &mut rsc);
let head_slot = render
.first_primitive(header_id)
.expect("the header drew a primitive");
let head_top = render.primitive_corners(head_slot, &rsc).top_left.y;
for (i, rect) in rects.iter().enumerate() {
let want = head_top + HEADER + (ROW + 2.0 * PAD) * i as f32 + PAD;
let slot = render
.first_primitive(rect.id())
.expect("each rect drew a primitive");
let drawn = render.primitive_corners(slot, &rsc);
assert!(
(drawn.top_left.y - want).abs() < 0.01,
"row {i} should be drawn at y={want}, got {drawn:?}"
);
}
}
+1
View File
@@ -20,6 +20,7 @@ pub mod android;
pub mod default;
pub mod attr;
pub mod diagnostics;
pub mod event;
pub mod harness;
pub mod platform;
+1580 -198
View File
File diff suppressed because it is too large. Load diff
+453 -7
View File
@@ -7,7 +7,7 @@
//! impl need no GPU or window.
use crate::prelude::*;
use std::{cell::Cell, rc::Rc};
use std::{cell::Cell, rc::Rc, time::Instant};
struct SenseRsc {
ui: UiData,
@@ -130,7 +130,7 @@ fn a_button_over_a_list_scrolls_the_list_and_still_clicks() {
/// the press, in a gap, or off the loaded content entirely. Before pointer
/// capture, `run_sensors`' hit test simply delivered nothing that frame,
/// so a widget mid-drag never saw its release and never got a chance to
/// start a fling. `UiRenderState::capture_pointer`/`DragGesture` fix this
/// start a fling. `PointerRequests::capture`/`DragGesture` fix this
/// by giving the drag's widget every frame regardless of where the
/// pointer is, including the terminal `Drop` in place of `PressEnd`.
#[test]
@@ -157,7 +157,7 @@ fn a_release_outside_every_hit_region_still_reaches_the_captured_widget() {
// would gate this on a `DragArbiter`/`DragGesture`
// decision, but this test only needs to exercise the
// capture-and-release mechanics themselves.
ctx.data.render.capture_pointer(draggable_weak.id());
ctx.data.pointer.capture(draggable_weak.id());
let _ = rsc;
}
CursorSense::Drop => dropped.set(true),
@@ -175,7 +175,7 @@ fn a_release_outside_every_hit_region_still_reaches_the_captured_widget() {
press.buttons.left = ActivationState::Start;
render.run_sensors(&mut rsc, &mut state, press, (100.0, 100.0).into());
assert_eq!(
render.captured_pointer(),
pointer_input(&mut rsc).holder(),
Some(draggable.id()),
"the press should have taken capture"
);
@@ -192,7 +192,7 @@ fn a_release_outside_every_hit_region_still_reaches_the_captured_widget() {
the widget holding pointer capture"
);
assert_eq!(
render.captured_pointer(),
pointer_input(&mut rsc).holder(),
None,
"Drop must release the capture"
);
@@ -236,7 +236,7 @@ fn capturing_one_widget_starves_every_other_widget_of_events() {
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
render.capture_pointer(a_weak.id());
pointer_input(&mut rsc).set_holder(Some(a_weak.id()));
let mut state = ();
let cursor = cursor_at((50.0, 50.0).into());
@@ -315,5 +315,451 @@ fn a_finger_drag_over_a_scroll_area_pans_it() {
// And the gesture holds the pointer, so the rest of it reaches this
// widget even once the finger leaves its box.
assert_eq!(render.captured_pointer(), Some(scroll.id()));
assert_eq!(pointer_input(&mut rsc).holder(), Some(scroll.id()));
}
/// docs/REVIEW-2026-09-07.md's D4. The first `MotionEvent` a view sees can
/// be a `Move` -- the `Down` went to another view, or the view was attached
/// mid-gesture -- and its batched samples are older than its own
/// timestamp. Anchoring on that timestamp clamped every one of them onto
/// the anchor, so the tracker saw three samples at one instant, the Lsq2
/// fit went degenerate, and the flick read 0 px/s.
#[test]
fn the_first_events_batched_samples_are_dated_apart() {
const MS: i64 = 1_000_000;
let now = Instant::now();
// A 120Hz batch: three historical samples at 0/4/8ms and the event's
// own at 12ms.
let clock = PointerClock::anchored(now, 12 * MS, 0);
assert_eq!(
clock.at(12 * MS),
now,
"the event's own sample is the one that arrived now"
);
let batch = [clock.at(0), clock.at(4 * MS), clock.at(8 * MS)];
assert!(
batch[0] < batch[1] && batch[1] < batch[2] && batch[2] < now,
"the batch must keep the 4ms between its samples, got {:?}",
batch
.iter()
.map(|t| now.duration_since(*t))
.collect::<Vec<_>>()
);
assert_eq!(clock.ms_since_anchor(8 * MS), 8);
}
/// The same clock has to keep ordering *across* events: the sample it
/// compares a new event's first sample against is the previous event's
/// last one, never the anchor.
#[test]
fn the_clock_orders_samples_across_events() {
const MS: i64 = 1_000_000;
let mut clock = PointerClock::anchored(Instant::now(), 12 * MS, 0);
let first = clock.sample(12 * MS);
let second = clock.sample(28 * MS);
assert!(second > first);
assert_eq!(
second.duration_since(first),
std::time::Duration::from_millis(16)
);
}
/// Iris's 2026-09-08 phone report, first half: "it keeps snapping back to
/// some position when horizontally scrolling."
///
/// A `Scroll` that has committed to a pan holds the pointer, so the
/// gesture's end arrives as `CursorSense::Drop` -- and `scrollable_on`
/// used to register `click_or_drag | unclick` only, which `should_run`
/// never matches a `Drop` against. So the widget never learned its own
/// gesture had ended: its `DragArbiter` stayed `Panning` at the position
/// the finger left, and the *next* drag's first frame was measured from
/// there and applied in one step. The registration is
/// `CursorSense::drag_senses()` now, which is the rule for every widget
/// driving a `DragGesture` rather than a fact about this one.
#[test]
fn a_scroll_area_that_captured_the_pointer_learns_its_gesture_ended() {
let mut rsc = SenseRsc {
ui: UiData::default(),
events: EventManager::default(),
};
let scroll_strong = rect(UiColor::WHITE)
.height(Len::abs(1000.0))
.scrollable()
.add_strong(&mut rsc);
let scroll = scroll_strong.weak();
let root = scroll_strong.any();
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
render.update(&root, &mut rsc);
let mut state = ();
let win = Vec2::new(100.0, 100.0);
let mut send = |render: &UiRenderState, rsc: &mut SenseRsc, y: f32, button| {
let mut c = cursor_at((50.0, y).into());
c.buttons.left = button;
render.run_sensors(rsc, &mut state, c, win);
};
// One pan of 40px past the slop, then a release well outside the
// widget -- the ordinary shape of a flick.
send(&render, &mut rsc, 80.0, ActivationState::Start);
send(
&render,
&mut rsc,
80.0 - (DRAG_SLOP + 40.0),
ActivationState::On,
);
let after_first = rsc.ui.widgets.get(&scroll).unwrap().amt();
assert!((after_first - 40.0).abs() < 0.01, "amt={after_first}");
send(&render, &mut rsc, 400.0, ActivationState::End);
assert_eq!(
pointer_input(&mut rsc).holder(),
None,
"the release must give the pointer back"
);
// A second gesture, starting where the first one did. If the arbiter
// were still panning from the release position, this first frame
// would apply the whole distance between the two at once.
send(&render, &mut rsc, 80.0, ActivationState::Start);
let after_second = rsc.ui.widgets.get(&scroll).unwrap().amt();
assert!(
(after_second - after_first).abs() < 0.01,
"a fresh touch-down moved the content by {} -- the previous \
gesture was never closed",
after_second - after_first,
);
}
/// The second half of the same report: "tapping sometimes seems to make
/// the scrolling jump, particularly when tapping on things that have
/// events like horizontal scrolling."
///
/// Two widgets see the same press -- a scroll area and, under it,
/// something tracking the gesture for a list. When the scroll area
/// captures, the other one is cut off completely: no `PressEnd`, no
/// `Drop`. It has to be told, or its gesture stays open at an origin
/// belonging to a finger that has long gone, and the next unrelated touch
/// is measured from it.
#[test]
fn taking_the_pointer_cancels_everyone_else_tracking_the_press() {
let mut rsc = SenseRsc {
ui: UiData::default(),
events: EventManager::default(),
};
// The bystander *contains* the capturer, which is the real shape: a
// transcript's `LazySpan` and one row's own text both track the same
// press, and a `Stack`'s siblings would be on separate layers where
// only the topmost is dispatched to at all.
let capturer = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let capturer_weak = capturer.weak();
let bystander = rsc.ui.widgets.add_strong(Stack {
children: vec![capturer.any()],
size: StackSize::default(),
});
let bystander_weak = bystander.weak();
let capturer_saw = Rc::new(Cell::new(0u32));
{
let capturer_saw = capturer_saw.clone();
rsc.register_event(
capturer_weak,
CursorSense::drag_senses(),
move |ctx, _rsc| {
capturer_saw.set(capturer_saw.get() + 1);
if matches!(ctx.data.sense, CursorSense::Pressing(_)) {
ctx.data.pointer.capture(capturer_weak.id());
}
},
);
}
let cancelled = Rc::new(Cell::new(0u32));
let ended = Rc::new(Cell::new(0u32));
{
let (cancelled, ended) = (cancelled.clone(), ended.clone());
rsc.register_event(
bystander_weak,
CursorSense::drag_senses(),
move |ctx, _rsc| match ctx.data.sense {
CursorSense::Cancel => cancelled.set(cancelled.get() + 1),
CursorSense::PressEnd(_) | CursorSense::Drop => ended.set(ended.get() + 1),
_ => {}
},
);
}
let root = bystander.any();
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
let mut state = ();
let win = Vec2::new(100.0, 100.0);
let mut down = cursor_at((50.0, 50.0).into());
down.buttons.left = ActivationState::Start;
render.run_sensors(&mut rsc, &mut state, down, win);
assert_eq!(cancelled.get(), 0, "nothing has captured yet");
let mut moved = cursor_at((50.0, 20.0).into());
moved.buttons.left = ActivationState::On;
render.run_sensors(&mut rsc, &mut state, moved, win);
assert!(capturer_saw.get() > 0, "the capturer never saw the press");
assert_eq!(
pointer_input(&mut rsc).holder(),
Some(capturer_weak.id()),
"the capture should have been taken on this frame"
);
assert_eq!(
cancelled.get(),
1,
"the widget that lost the gesture must be told exactly once"
);
// And exactly once: the frames after the capture reach the capturer
// alone, so there is nothing left to cancel.
let mut more = cursor_at((50.0, 10.0).into());
more.buttons.left = ActivationState::On;
render.run_sensors(&mut rsc, &mut state, more, win);
let mut up = cursor_at((50.0, 10.0).into());
up.buttons.left = ActivationState::End;
render.run_sensors(&mut rsc, &mut state, up, win);
assert_eq!(cancelled.get(), 1, "cancelled more than once");
assert_eq!(
ended.get(),
0,
"a cancelled widget must not also be told the gesture ended \
normally -- acting on that is the tap it never made"
);
}
/// Iris's rule for nested scrolling, 2026-09-08: "it should only trigger
/// horizontal if you drag left or right, and vertical should fall through
/// if you drag up or down."
///
/// One mechanism does both, and it is `DragArbiter`'s existing axis test:
/// each scroll area's gesture commits only on its own axis, so a drag
/// along the other one is never claimed and the enclosing area's gesture
/// -- which sees the same press, being an ancestor rather than a sibling
/// layer -- is the one that commits and captures. This pins the pair,
/// including the direction the change had no reason to touch.
#[test]
fn a_drag_pans_whichever_nested_scroll_area_owns_its_axis() {
for (name, to, pans, still) in [
("vertical", Vec2::new(50.0, 80.0 - (DRAG_SLOP + 40.0)), 0, 1),
(
"horizontal",
Vec2::new(50.0 - (DRAG_SLOP + 40.0), 80.0),
1,
0,
),
] {
let mut rsc = SenseRsc {
ui: UiData::default(),
events: EventManager::default(),
};
// 1000px square of content in a 100px window: room to pan either
// way, in an X area inside a Y one.
let seen = Rc::new(Cell::new(None));
let record = seen.clone();
let outer_strong = rect(UiColor::WHITE)
.width(Len::abs(1000.0))
.height(Len::abs(1000.0))
.scrollable_on(Axis::X)
// The inner area's own handle, taken as the chain is built --
// the whole point is to exercise `scrollable_on`'s real
// registration on both, so neither is assembled by hand.
.with_id(move |_rsc, id| {
record.set(Some(id));
id
})
.scrollable()
.add_strong(&mut rsc);
let inner = seen.get().unwrap();
let outer = outer_strong.weak();
let root = outer_strong.any();
let areas = [outer, inner];
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
// The second frame, where each area knows its content length --
// LAYOUT.md section 4's one-frame lag, and what drops `snap_end`.
for a in areas {
rsc.ui.widgets.get_mut(&a).unwrap().scroll(0.0);
}
render.update(&root, &mut rsc);
let mut state = ();
let win = Vec2::new(100.0, 100.0);
let mut down = cursor_at((50.0, 80.0).into());
down.buttons.left = ActivationState::Start;
render.run_sensors(&mut rsc, &mut state, down, win);
let mut drag = cursor_at(to);
drag.buttons.left = ActivationState::On;
render.run_sensors(&mut rsc, &mut state, drag, win);
let moved = rsc.ui.widgets.get(&areas[pans]).unwrap().amt();
let unmoved = rsc.ui.widgets.get(&areas[still]).unwrap().amt();
assert!(
(moved - 40.0).abs() < 0.01,
"a {name} drag should have panned the {name} area by the 40px \
past the slop, got {moved}"
);
assert_eq!(
unmoved, 0.0,
"a {name} drag must not move the area that owns the other axis"
);
}
}
/// Iris's 2026-09-08 report: "if I try to scroll vertically while a
/// horizontal scroll animation is still active, it stays locked to the
/// horizontal scroll", with her own diagnosis -- "tapping outside of
/// something that a fling is currently active for should have no code in
/// common with the fling that could influence it."
///
/// She was right that it was global state, and this is where it lived.
/// `run_sensors` runs a widget one more frame *after* the pointer has
/// left it, so a `HoverEnd` can fire ([`ActivationState::End`], which is
/// not `Off`) -- and `should_run` derived a press from the button alone,
/// so that farewell frame also carried a `PressStart`. A widget nowhere
/// near the finger therefore opened a gesture, and a `Scroll` catching
/// its own fling commits with no slop, so it captured the pointer and the
/// whole gesture went to it.
///
/// Two areas side by side here rather than one, because "the press went
/// to the wrong widget" and "the press went nowhere" are different
/// failures and only the second area can tell them apart.
#[test]
fn a_press_does_not_reach_a_widget_the_pointer_has_just_left() {
let mut rsc = SenseRsc {
ui: UiData::default(),
events: EventManager::default(),
};
// Two 1000px-tall scroll areas, stacked: the top half of the window
// is the first, the bottom half the second. Each area's own handle is
// taken as its chain is built (`with_id`, the same way the nested-axes
// test above does it), since what is under test is `scrollable()`'s
// real registration rather than a `Scroll` assembled by hand.
let seen: [Rc<Cell<Option<WeakWidget<Scroll>>>>; 2] = Default::default();
let half = |slot: &Rc<Cell<Option<WeakWidget<Scroll>>>>| {
let record = slot.clone();
rect(UiColor::WHITE)
.height(Len::abs(1000.0))
.scrollable()
.with_id(move |_rsc, id| {
record.set(Some(id));
id
})
.height(Len::rel(0.5))
};
let root = (half(&seen[0]), half(&seen[1]))
.span(Dir::DOWN)
.add_strong(&mut rsc)
.any();
let (top_w, bottom_w) = (seen[0].get().unwrap(), seen[1].get().unwrap());
let win: Vec2 = (100.0, 200.0).into();
let mut render = UiRenderState::new();
render.resize((win.x, win.y));
render.update(&root, &mut rsc);
// The second frame is the first that knows how long the content is --
// see `a_finger_drag_over_a_scroll_area_pans_it`.
for w in [&top_w, &bottom_w] {
rsc.ui.widgets.get_mut(w).unwrap().scroll(0.0);
}
render.update(&root, &mut rsc);
let mut state = ();
// Flick the top area and let go: it is left flinging, and -- because
// the release goes through `run_sensors`' capture branch, which
// returns before the loop that would have updated anybody's hover --
// its sensor is left `On` with the pointer no longer on it. Both
// halves of the real gesture, since both are what the bug needs.
let base = Instant::now();
let mut t = 0;
let sample = |render: &mut UiRenderState,
rsc: &mut SenseRsc,
state: &mut (),
y: f32,
button: ActivationState,
at_ms: u64| {
let mut c = cursor_at((50.0, y).into());
c.buttons.left = button;
c.time = base + std::time::Duration::from_millis(at_ms);
render.run_sensors(rsc, state, c, win);
};
sample(
&mut render,
&mut rsc,
&mut state,
50.0,
ActivationState::Start,
t,
);
for y in [44.0, 32.0, 14.0] {
t += 8;
sample(&mut render, &mut rsc, &mut state, y, ActivationState::On, t);
}
t += 8;
sample(
&mut render,
&mut rsc,
&mut state,
14.0,
ActivationState::End,
t,
);
assert!(
rsc.ui.widgets.get(&top_w).unwrap().is_scrolling(),
"the flick must leave the top area coasting -- the press below is \
only dangerous while something is still moving",
);
let flung_to = rsc.ui.widgets.get(&top_w).unwrap().amt();
// Now press and drag in the *bottom* area: the top area's hover
// decays to `End` on this very sample, which is the frame that used
// to carry a `PressStart` to it.
t += 8;
sample(
&mut render,
&mut rsc,
&mut state,
150.0,
ActivationState::Start,
t,
);
t += 8;
sample(
&mut render,
&mut rsc,
&mut state,
150.0 - (DRAG_SLOP + 40.0),
ActivationState::On,
t,
);
let moved = rsc.ui.widgets.get(&bottom_w).unwrap().amt();
assert!(
(moved - 40.0).abs() < 0.01,
"the area actually under the finger should have panned by the 40px \
past the slop, got {moved}"
);
assert_eq!(
rsc.ui.widgets.get(&top_w).unwrap().amt(),
flung_to,
"the area the pointer had left must not have seen the press at all -- \
a catch would have stopped its fling on the touch-down"
);
assert_eq!(
pointer_input(&mut rsc).holder(),
Some(bottom_w.id()),
"the gesture belongs to the widget under the finger",
);
}
+25 -1
View File
@@ -1,12 +1,36 @@
use crate::prelude::*;
/// Clips `inner` -- and everything below it -- to a shape.
///
/// The shape is a **primitive**, never a rectangle or a radius stored
/// here: with `shape`, the widget named there is drawn behind `inner`
/// filling the same box and the clip is its first primitive, so a rounded
/// container's corner and the corner its content is cut to are the same
/// arithmetic and cannot fall out of step. Without one, this writes an
/// undrawn rect at its own region, which is the plain "clip to my box"
/// every list and scroll area wants. See docs/LAYOUT.md's "Masks with a
/// shape".
pub struct Masked {
/// The widget whose first primitive is the clip, drawn behind
/// `inner`, or `None` for this widget's own box.
pub shape: Option<StrongWidget>,
pub inner: StrongWidget,
}
impl Widget for Masked {
fn draw(&mut self, painter: &mut Painter) -> Size {
painter.set_mask(painter.region());
match &self.shape {
// Layered the way `Stack` layers a background under its
// content, and for the same reason: within one layer the draw
// order is undefined once anything has been freed.
Some(shape) => {
painter.child_layer();
painter.widget(shape);
painter.set_mask_to_widget(shape);
painter.next_layer();
}
None => painter.set_mask(painter.region()),
}
painter.widget(&self.inner)
}
}
-2
View File
@@ -1,5 +1,4 @@
mod image;
mod list;
mod mask;
mod position;
mod ptr;
@@ -8,7 +7,6 @@ mod text;
mod trait_fns;
pub use image::*;
pub use list::*;
pub use mask::*;
pub use position::*;
pub use ptr::*;
File diff suppressed because it is too large. Load diff
+2
View File
@@ -1,5 +1,6 @@
mod align;
mod layer;
mod lazy_span;
mod max_size;
mod offset;
mod pad;
@@ -10,6 +11,7 @@ mod stack;
pub use align::*;
pub use layer::*;
pub use lazy_span::*;
pub use max_size::*;
pub use offset::*;
pub use pad::*;
+533 -77
View File
@@ -1,5 +1,5 @@
use crate::prelude::*;
use crate::sense::{DragGesture, GestureOutcome};
use crate::sense::{DragGesture, Flinger, GestureOutcome, PointerRequests, PressState};
use std::time::Instant;
pub struct Scroll {
@@ -8,87 +8,267 @@ pub struct Scroll {
amt: f32,
snap_end: bool,
container_len: f32,
content_len: f32,
/// Touch panning, from the same `DragGesture` `List` is driven by
/// How long the content is along `axis`, as of the last draw --
/// `None` until this widget has drawn once.
///
/// An `Option` rather than a `0.0` that stands in for both, because
/// the two answers led somewhere different and the code could not
/// tell them apart: on the first frame the clamp in `update_amt`
/// computed a scroll range of zero, concluded from `amt == len` that
/// the area was sitting at its end, and set `snap_end` -- so the next
/// frame, now knowing the real length, jumped to it. A code fence
/// therefore opened at the end of its longest line, mid-word
/// (`iris/run-headless.sh phone`, 2026-09-08).
content_len: Option<f32>,
/// A scroll delta this area has been handed but not yet passed to a
/// child that positions itself ([`Widget::scrolls_itself`]) -- how
/// far a wheel, a drag or a fling asked to move since the last draw.
/// Applied and cleared in `draw`, which is the only place the child's
/// walls are known.
///
/// Nothing accumulates here for an ordinary child, whose position is
/// this widget's own `amt` and is written the moment a delta arrives.
pending: f32,
/// Whether the last draw handed a self-positioning child more than it
/// could take -- the only way this widget learns where that child's
/// content ends, since it has no length to ask for. Reset every time a
/// delta is fully consumed.
hit_wall: bool,
/// Whether the child positions itself, from its own
/// [`Widget::scrolls_itself`], re-read every draw. Cached because it
/// is asked once per scroll delta as well as once per draw, and it is
/// read through `get_dyn` -- taking `&mut` to ask would mark every
/// ordinary child dirty on every tick and cost exactly the O(1) move
/// this widget exists for.
child_scrolls: bool,
/// Touch panning, from the same `DragGesture` `LazySpan` is driven by
/// (`transcript-ui::Selection::drag`) rather than a second copy of its
/// wiring: arbitration, `DRAG_SLOP` and pointer capture all live in
/// `sense.rs` and only what a committed pan *means* is decided here.
/// See [`Self::drag`].
gesture: DragGesture,
/// The momentum a release leaves behind, the same [`Flinger`] a
/// `LazySpan` coasts on. Every scroll area flings, on either axis and
/// with nothing to opt into -- Compose's `scrollable` attaches
/// `ScrollableDefaults.flingBehavior()` on every axis it is given,
/// and Iris asked for the same (2026-09-08: "flinging should be
/// enabled by default in all scroll areas on android to match
/// composes behavior").
fling: Flinger,
/// Physical pixels per dp, copied from the painter on every draw --
/// what a fling's deceleration is computed against. 1.0 until this
/// widget has drawn once, which is also the only state in which
/// nothing can be flung, since there is no content length yet.
density: f32,
}
impl Widget for Scroll {
/// A `Scroll` animates exactly one thing, its fling. The registration
/// that makes this run is `UiData::animate`, which
/// `WidgetLike::scroll_area`'s own drag handler calls the frame a
/// release starts one.
fn tick(&mut self, now: Instant) -> bool {
let delta = self.fling.tick(now);
self.scroll(delta);
// A fling must not keep spending its distance on content that is
// not there. With an ordinary child this widget knows exactly
// where the content ends -- `update_amt` has just clamped `amt`
// into it -- so the wall is read straight off `amt`. With a child
// that positions itself there is no content length to read, and
// the wall arrives instead as the part of a delta the child could
// not take (`hit_wall`, set in `draw`); it is one frame old, which
// costs a fling one extra tick and nothing on screen, since the
// child clamped its own position within the frame that found it.
let at_wall = if self.child_scrolls {
self.hit_wall
} else {
self.amt <= 0.0 || self.amt >= self.scroll_range()
};
if at_wall {
self.fling.stop();
}
self.fling.is_flinging()
}
fn draw(&mut self, painter: &mut Painter) -> Size {
// The region offered to the child is sized using *last* frame's
// content length, not a fresh measurement -- deliberately, so that
// an ordinary scroll tick (`amt` changes, content does not) offers
// the child the exact same size it was last drawn with, only
// shifted. That is what lets `draw_inner` dispatch this as an O(1)
// move (LAYOUT.md section 2) instead of a redraw: sizing the region
// to a *fresh* measurement would require drawing the child first to
// learn it, and a provisional draw almost never matches the
// previously active size, forcing a real redraw on every tick. A
// genuine content-size change (not just a scroll) therefore lags
// one frame before the container's clamp reflects it; the content
// length itself (read below from what was actually drawn) is never
// stale, so this self-corrects the next frame and never leaves the
// scroll range wrong for long. See LAYOUT.md section 4.
//
// Every length here is resolved against the box this widget was
// **offered** (`px_size`), never `output_size`: a `Scroll` is
// routinely smaller than the window -- the composer's field is
// capped at six lines by a `MaxSize` around it -- and measuring
// the window instead would make the pan range, and so where the
// content sits, a function of the screen rather than of the box.
// (What the previous arithmetic here computed came to the same
// number by a longer route, through a `within_len` against a
// window-relative scalar; it read as if the window were the
// container and cost a session working out that it was not.)
let axis = self.axis;
let container_len = painter.px_size().axis(axis);
self.container_len = container_len;
if self.snap_end {
self.amt = self.content_len - self.container_len;
// Learned from the frame rather than passed in: a fling's
// deceleration is a physical quantity and needs the real display
// density, and `draw` is where this widget meets the only thing
// that knows it.
self.density = painter.density();
let was_known = self.child_scrolls;
self.child_scrolls = painter.scrolls_itself(&self.inner);
// A delta that arrived before this widget had ever drawn went to
// `amt` (the ordinary child's path), because what kind of child
// this is cannot be asked until there is a painter to ask through.
// Hand it on rather than dropping it: `amt` means nothing to a
// self-positioning child, so the delta would simply never happen.
if self.child_scrolls && !was_known && self.amt != 0.0 {
self.pending -= self.amt;
self.amt = 0.0;
}
if self.child_scrolls {
self.draw_self_scrolling_child(painter)
} else {
self.draw_moved_child(painter, container_len)
}
}
}
self.update_amt();
let mut region = UiRegion::FULL;
region.axis_mut(axis).end = region.axis(axis).start.offset(self.content_len);
let region = region.offset(Vec2::from_axis(axis, -self.amt, 0.0));
let used = painter.widget_within(&self.inner, region);
impl Scroll {
/// The ordinary case: the child is a fixed lump this widget slides
/// about, and its position is `amt`.
///
/// **The child is drawn twice, and only the second decides anything.**
/// The first is handed last frame's length as a *hint* -- nothing
/// about where the child ends up depends on it, and it exists only so
/// that the usual case, where the content's length did not change,
/// offers the same region twice: `draw_inner` then makes the first
/// call an O(1) `mov` and returns at the first line of the second. A
/// frame on which the content did grow or shrink pays one real extra
/// draw, and that is a frame on which the content was being redrawn
/// anyway.
///
/// The alternative -- place against the hint and let the next frame
/// fix it -- is what Iris found on her phone (2026-09-08): every
/// newline typed into the composer drew the field in a box one line
/// short of its text, and since that text is centred in its box it
/// hung half a line past each end. There was no next frame: nothing
/// dirtied that subtree again, so the stale placement was the last one
/// drawn, until the keyboard closed and its inset rewrite forced a
/// redraw ("it fixes itself"). **Layout is a pure function of the
/// state, not of how many frames have been drawn** (Iris, 2026-09-08)
/// -- a correction that needs a second frame is a frame drawn wrong.
///
/// The container's own length stands in as the hint until anything has
/// been measured: a zero-length region on the first frame would place
/// the child's primitives against a box of no size.
fn draw_moved_child(&mut self, painter: &mut Painter, container_len: f32) -> Size {
let axis = self.axis;
let density = self.density;
let hint = self.content_len.unwrap_or(container_len);
let used = painter.widget_within(&self.inner, self.child_region(hint));
// A child reporting `rel` means "this fraction of what I was
// offered", and what it was offered is this scroll area -- so the
// container, again, is what that resolves against.
self.content_len = used
.axis(axis)
.apply_rest(painter.density())
.to_abs(container_len);
let measured = used.axis(axis).apply_rest(density).to_abs(container_len);
self.content_len = Some(measured);
// Everything that decides the placement, against the length just
// measured: the end-pin, then the clamp `update_amt` shares with
// `scroll` and `drag`. Deliberately not also run before the
// measuring draw above -- clamping against the hint would let a
// stale length reduce `amt` in a way this pass cannot undo, and
// then where the content sits would depend on the previous frame
// after all.
if self.snap_end {
self.amt = measured - container_len;
}
self.update_amt();
// The **content's** size, not the container's. A parent that can
// grow (the composer's bar) should hug the text until its own cap
// stops it, and reporting the container instead would make this
// widget's answer a function of the answer -- the bar is sized
// from what is reported here, so it collapses to nothing and
// never recovers. What keeps the content inside the offered box
// is the mask a caller puts around it (`.scrollable().masked()`),
// not this number.
used
}
// from what is reported here, so it collapses to nothing and never
// recovers. What keeps the content inside the offered box is the
// mask a caller puts around it (`.scrollable().masked()`), not
// this number.
painter.widget_within(&self.inner, self.child_region(measured))
}
impl Scroll {
pub fn new(inner: StrongWidget, axis: Axis) -> Self {
/// The lazy case: the child positions its own content
/// ([`Widget::scrolls_itself`]), so this widget contributes the
/// gesture, the fling and the accounting, and the child contributes
/// the placement.
///
/// Measure, apply, place -- the same measure-then-place idiom
/// [`Self::draw_moved_child`] and `LazySpan::place` use, for the same
/// reason: the child cannot say how much of a delta it can take until
/// it has laid out, and a correction that waits for the next frame is
/// a frame drawn wrong.
///
/// **The measuring draw is free in the common case.** It offers the
/// child the same box as last frame, so with nothing dirty
/// `draw_inner` returns immediately and the walls the child answers
/// against are the ones it last measured -- still correct, because
/// nothing changed. When the content *did* change the child is dirty,
/// really walks, and the walls are fresh, which is exactly when they
/// need to be.
///
/// **Nothing marks the child by hand.** Reaching it through
/// `get_dyn_mut` to hand it the delta is itself what dirties it
/// (`Widgets::get_dyn_mut`), so the placing draw below really draws
/// rather than taking `draw_inner`'s unchanged-region skip.
fn draw_self_scrolling_child(&mut self, painter: &mut Painter) -> Size {
painter.widget_within(&self.inner, UiRegion::FULL);
let requested = std::mem::take(&mut self.pending);
let mut left = requested;
if requested != 0.0 {
painter.apply_scroll(&self.inner, &mut left);
}
let used = painter.widget_within(&self.inner, UiRegion::FULL);
// Read *after* the child has been placed, because that is the
// draw that finds the end of the content: a child that could not
// see its wall yet took the whole delta above and gave part of it
// back while walking. `amt` is therefore the movement that
// actually happened, not the movement that was asked for -- see
// `Widget::scroll_offset`.
//
// Deliberately **not** a distance from the start of the content:
// paging rows in above moves that origin and the child cannot say
// by how much, never having measured them. It is honest for what
// it is used for -- "how far has this moved", a fling's wall --
// and anything wanting an absolute position (a scrollbar) needs a
// real content length first.
// Negated: `scroll_offset` is in the finger's direction while
// `amt` counts *forward through the content*, so that `amt` means
// the same thing and moves the same way whichever kind of child
// this is. What differs, unavoidably, is the origin -- an ordinary
// child's `amt` is measured from the start of its content, and a
// lazy child has no start to measure from, so its `amt` is
// movement from wherever it happened to be when this area was
// built. Direction is comparable; absolute value is not.
let moved = -painter.scroll_offset(&self.inner);
// A delta that came back short, or one the child took and then
// gave part of back, is this widget's only way of hearing that the
// content ran out.
self.hit_wall = requested != 0.0 && (moved - self.amt + requested).abs() > 0.5;
self.amt = moved;
used
}
/// `at_end` starts the area pinned to the end of its content and
/// keeps it there while the content grows -- see
/// `WidgetLike::scrollable_to_end`. `false` starts at the beginning,
/// which is what anything being *read* wants.
pub fn new(inner: StrongWidget, axis: Axis, at_end: bool) -> Self {
Self {
inner,
axis,
amt: 0.0,
snap_end: true,
snap_end: at_end,
container_len: 0.0,
content_len: 0.0,
content_len: None,
pending: 0.0,
hit_wall: false,
child_scrolls: false,
gesture: DragGesture::on(axis),
fling: Flinger::new(),
density: 1.0,
}
}
@@ -106,49 +286,103 @@ impl Scroll {
/// takes the gesture over. Android's own `EditText` behaves the same
/// way -- a vertical drag scrolls, and only a long press selects.
///
/// No fling: unlike `List`, `Scroll` has no per-frame tick to animate
/// one with (`List::set_redraw_handle`/`tick_fling`), and the areas
/// this wraps today -- a six-line composer, a diagnostics pane -- are
/// at most a screenful, where Android does not fling either. The
/// released velocity is deliberately dropped rather than approximated.
/// Answers whether this frame *started a fling*, which is the
/// caller's cue to register the widget for frames
/// (`UiData::animate`) -- see [`Widget::tick`]. Split that way
/// because the two halves have different owners: the velocity is this
/// widget's business and whether anything animates at all is the
/// frame loop's, and `drag` has no `Rsc` to reach the loop through.
pub fn drag(
&mut self,
render: &UiRenderState,
pointer: &PointerRequests,
id: WidgetId,
sense: CursorSense,
pos_window: Vec2,
now: Instant,
) {
// `already_selected: false` -- a scroll area has no selection of
// its own to extend, so a horizontal drag stays `Undecided` and a
// vertical one past the slop pans, which is the whole contract
// here. A caller that *does* own a selection (the transcript's
// `Selection`) drives `DragGesture` itself instead.
) -> bool {
// A scroll area has no selection of its own to extend, so a drag
// across the axis stays `Undecided` and one along it past the
// slop pans, which is the whole contract here. A caller that
// *does* own a selection (the transcript's `Selection`) drives
// `DragGesture` itself instead.
//
// `scrolling` is the other half: a finger put down on content
// that is still coasting means "stop it here", and commits to a
// pan on that very sample with no slop to wait out
// (`DragArbiter::press_start`). The fling is cancelled in the
// same breath, since the curve has no idea a finger came back
// down.
let mut press = PressState::default();
if self.gesture.starts_press(sense) {
press.scrolling = self.fling.is_flinging();
self.fling.stop();
}
match self
.gesture
.handle(render, id, sense, pos_window, now, false)
.handle(pointer, id, sense, pos_window, now, press)
{
// `scroll(dy)`, not `scroll(-dy)` -- `Selection::drag` passes
// `-dy` to `List::scroll` because a `List`'s anchor offset and
// this widget's `amt` run in *opposite* directions (offset is
// where the anchored edge sits; `amt` is how far the content
// has been pulled up past the top), even though `List::scroll`'s
// own doc claims to mirror this one's convention. The rule that
// holds for both, and the one to check a sign against, is that
// the content follows the finger.
// The content follows the finger: `dy` straight through, and
// the same `dy` a transcript's own arbiter (`Selection::drag`)
// hands to this same method. There used to be two conventions
// here -- this one and a `LazySpan::scroll` whose anchor offset
// ran the opposite way while its doc claimed to mirror this
// one -- so every call site had to remember which it was
// talking to. There is one now.
GestureOutcome::Pan(dy) => self.scroll(dy),
// Same sign as `Pan`, since `tick` applies it through the
// same `scroll`.
GestureOutcome::Released(Some(v)) => {
return self.fling.start(v, self.density);
}
GestureOutcome::Undecided
| GestureOutcome::Tapped
| GestureOutcome::SelectStart
| GestureOutcome::SelectExtend
| GestureOutcome::Released(_) => {}
| GestureOutcome::Cancelled
| GestureOutcome::Released(None) => {}
}
false
}
/// How far this area can be panned: the content's length past the
/// container's, or zero when it all fits. The one arithmetic
/// `update_amt`'s clamp and `tick`'s wall both ask for, stated once.
fn scroll_range(&self) -> f32 {
match self.content_len {
Some(len) => (len - self.container_len).max(0.0),
None => 0.0,
}
}
/// Where the child sits for a given content length: a box that long
/// along the scroll axis, pulled back by `amt`. Taken as a parameter
/// rather than read from `content_len`, because `draw` places twice
/// -- once against last frame's length and once against the one it
/// has just measured -- and the two must be the same arithmetic.
fn child_region(&self, content_len: f32) -> UiRegion {
let mut region = UiRegion::FULL;
region.axis_mut(self.axis).end = region.axis(self.axis).start.offset(content_len);
region.offset(Vec2::from_axis(self.axis, -self.amt, 0.0))
}
/// Clamp `amt` into the range the content allows, and re-read whether
/// this area is sitting at its end.
///
/// Both are skipped until the content has been measured: with no
/// length there is no range to clamp into, and "at the end" is a
/// question that cannot be answered yet -- answering it anyway is
/// what `content_len`'s doc describes.
pub fn update_amt(&mut self) {
self.amt = self.amt.max(0.0);
let len = (self.content_len - self.container_len).max(0.0);
self.amt = self.amt.min(len);
// A self-positioning child has no content length and no clamp of
// its own here: it does its own clamping in `apply_scroll`, and
// `amt` is a record of what it did rather than a position to
// correct. Clamping it against a `scroll_range` of zero (which is
// what no `content_len` computes to) would peg it at 0 forever.
if self.child_scrolls || self.content_len.is_none() {
return;
}
let len = self.scroll_range();
self.amt = self.amt.clamp(0.0, len);
self.snap_end = self.amt == len;
}
@@ -159,7 +393,78 @@ impl Scroll {
self.amt
}
/// Whether a fling is coasting here right now -- the same question
/// `Scroll::is_scrolling` answers for the other scrolling widget, under
/// the same name so there is one word for it. What a caller polls to
/// know whether this area is moving on its own (a test, and
/// [`PressState::scrolling`]'s own condition).
pub fn is_scrolling(&self) -> bool {
self.fling.is_flinging()
}
/// Start a fling at `velocity`, in the same direction convention as
/// [`Self::scroll`]. Answers whether one actually started, which is
/// the caller's cue to register this widget for frames
/// (`UiData::animate`) -- see [`Widget::tick`]. Cancels any fling
/// already in progress.
///
/// **Sets the fling; it does not drive it.** A fling moves only while
/// something calls `tick` once per frame, and what does that in a
/// running app is `UiData::tick_animations`, over the ids
/// `UiData::animate` was given. Split that way because the two halves
/// have different owners: the velocity is this widget's business and
/// whether anything animates at all is the frame loop's. Missing the
/// second call is what a finger fling did on Iris's phone for two
/// builds -- the velocity was right and nothing ever advanced it,
/// which looks exactly like a list that stops dead under the finger.
///
/// The density handed to `FlingCalculator` is this area's own, taken
/// from the painter in `draw`, not `1.0`: it does **not** cancel out
/// of the spline, and a hardcoded 1.0 against a 2.75-density screen
/// made a flick that should coast for about a second run for 45.
pub fn fling(&mut self, velocity: f32) -> bool {
self.fling.start(velocity, self.density)
}
/// Cancel any fling in progress with no further movement -- the next
/// touch-down's job, since `AndroidFlingSpline`'s curve has no idea a
/// finger came back down and Android's own `Scroller` relies on the
/// view calling `abortAnimation` for the same reason.
pub fn cancel_fling(&mut self) {
self.fling.stop();
}
/// The velocity a fling in progress is coasting at, `None` when
/// nothing is flinging. What a release's decision looks like from the
/// outside, so a test can read what the gesture measured rather than
/// re-timing the gesture itself.
pub fn fling_velocity(&self) -> Option<f32> {
self.fling.velocity()
}
/// Which way this area pans. For a caller that found the widget
/// rather than built it -- a test walking what is drawn, a scroll
/// indicator asking which edge to sit on.
pub fn axis(&self) -> Axis {
self.axis
}
/// Pan by `amt`, in the finger's direction: positive moves the
/// content the positive way along the axis, which brings **earlier**
/// content into view. One convention, and the one
/// [`Widget::apply_scroll`] carries, so that a delta means the same
/// thing wherever it is handed on.
///
/// A child that positions itself cannot be moved by writing `amt`
/// here -- where it can actually go is a question only its own layout
/// can answer -- so the delta is banked until `draw`, which is where
/// that answer exists. `amt` is then written from what the child
/// really took.
pub fn scroll(&mut self, amt: f32) {
if self.child_scrolls {
self.pending += amt;
return;
}
self.amt -= amt;
self.update_amt();
}
@@ -179,8 +484,8 @@ mod tests {
let mut ui = UiData::default();
let inner = ui.widgets.add_strong(Rect::new(UiColor::WHITE)).any();
let id = inner.id();
let mut s = Scroll::new(inner, Axis::Y);
s.content_len = 1000.0;
let mut s = Scroll::new(inner, Axis::Y, true);
s.content_len = Some(1000.0);
s.container_len = 100.0;
s.amt = 400.0;
s.snap_end = false;
@@ -189,7 +494,7 @@ mod tests {
fn press(
s: &mut Scroll,
render: &UiRenderState,
render: &PointerRequests,
id: WidgetId,
sense: CursorSense,
y: f32,
@@ -201,7 +506,7 @@ mod tests {
#[test]
fn a_vertical_finger_drag_pans_the_content_with_the_finger() {
let (_ui, mut s, id) = area();
let render = UiRenderState::new();
let render = PointerRequests::default();
let t = Instant::now();
press(
&mut s,
@@ -244,7 +549,7 @@ mod tests {
#[test]
fn a_press_that_stays_inside_the_slop_does_not_scroll() {
let (_ui, mut s, id) = area();
let render = UiRenderState::new();
let render = PointerRequests::default();
let t = Instant::now();
press(
&mut s,
@@ -284,7 +589,7 @@ mod tests {
#[test]
fn a_horizontal_drag_does_not_scroll() {
let (_ui, mut s, id) = area();
let render = UiRenderState::new();
let render = PointerRequests::default();
let t = Instant::now();
s.drag(
&render,
@@ -309,7 +614,7 @@ mod tests {
#[test]
fn a_pan_past_the_end_clamps_instead_of_running_off() {
let (_ui, mut s, id) = area();
let render = UiRenderState::new();
let render = PointerRequests::default();
let t = Instant::now();
s.drag(
&render,
@@ -327,4 +632,155 @@ mod tests {
);
assert!((s.amt - 0.0).abs() < 0.01, "amt={}", s.amt);
}
/// Iris, 2026-09-08: "Flinging doesn't work in horizontal scroll
/// areas. Flinging should be enabled by default in all scroll areas
/// on android to match composes behavior." A release with real
/// velocity coasts, decelerating, and settles on its own.
#[test]
fn a_released_pan_flings_and_settles() {
for axis in [Axis::X, Axis::Y] {
let (_ui, mut s, id) = area();
s.axis = axis;
s.gesture = DragGesture::on(axis);
let render = PointerRequests::default();
let t = Instant::now();
let at = |d: f32| Vec2::from_axis(axis, d, 0.0);
s.drag(
&render,
id,
CursorSense::PressStart(CursorButton::Left),
at(0.0),
t,
);
// Four samples 8ms apart, accelerating away from the start --
// three is the fewest `VelocityTracker`'s quadratic fit can
// use, so this is a gesture that genuinely has a velocity.
for (i, d) in [-40.0, -100.0, -180.0, -280.0].into_iter().enumerate() {
s.drag(
&render,
id,
CursorSense::Pressing(CursorButton::Left),
at(d),
t + Duration::from_millis(8 * (i as u64 + 1)),
);
}
let at_release = s.amt;
s.drag(
&render,
id,
CursorSense::PressEnd(CursorButton::Left),
at(-280.0),
t + Duration::from_millis(32),
);
assert!(
s.fling.is_flinging(),
"{axis:?}: a released pan with velocity must fling"
);
// Frames at 8ms until it stops, with each step no longer than
// the one before it -- a coast that does not decelerate is
// the linear-spline bug this crate has had once already.
let mut last_step = f32::INFINITY;
let mut ticks = 0;
let mut now = t + Duration::from_millis(32);
while s.tick(now) {
let before = s.amt;
now += Duration::from_millis(8);
s.tick(now);
let step = (s.amt - before).abs();
assert!(
step <= last_step + 0.01,
"{axis:?}: the fling sped up: {last_step} then {step}"
);
last_step = step;
ticks += 1;
assert!(ticks < 10_000, "{axis:?}: the fling never settled");
}
assert!(
s.amt > at_release,
"{axis:?}: the fling moved the content the wrong way: {at_release} -> {}",
s.amt
);
}
}
/// The wall: a fling must not spend its remaining distance on content
/// that is not there. Released hard toward the start, it settles
/// exactly on it.
#[test]
fn a_fling_stops_at_the_end_of_the_content() {
// Both walls. A positive delta is applied as `amt -= delta`, so a
// positive velocity runs toward the start of the content and a
// negative one toward its end; 1000px of content in a 100px box
// leaves `amt` in 0..=900.
for (velocity, wall) in [(50_000.0f32, 0.0f32), (-50_000.0, 900.0)] {
let (_ui, mut s, _id) = area();
s.fling.start(velocity, 1.0);
let t = Instant::now();
let mut now = t;
for _ in 0..1_000 {
if !s.tick(now) {
break;
}
now += Duration::from_millis(8);
}
assert!(
!s.fling.is_flinging(),
"the fling toward {wall} ran past the content"
);
assert!(
(s.amt - wall).abs() < 0.01,
"it should have settled on {wall}, got amt={}",
s.amt
);
}
}
/// A finger on coasting content stops it there, from the first
/// sample, with no `DRAG_SLOP` to wait out -- the catch
/// `DragArbiter::press_start` describes, which a scroll area needs
/// for the same reason a list does now that it can coast at all.
#[test]
fn a_press_on_a_coasting_area_catches_it() {
let (_ui, mut s, id) = area();
s.fling.start(-4_000.0, 1.0);
let t = Instant::now();
s.tick(t);
s.tick(t + Duration::from_millis(8));
let caught_at = s.amt;
assert!(s.fling.is_flinging(), "the fixture must still be moving");
let render = PointerRequests::default();
let down = t + Duration::from_millis(16);
s.drag(
&render,
id,
CursorSense::PressStart(CursorButton::Left),
Vec2::new(0.0, 0.0),
down,
);
assert!(!s.fling.is_flinging(), "a touch-down must end the fling");
assert!(
(s.amt - caught_at).abs() < 0.01,
"the down itself must not move the content, only stop it"
);
// A move well under `DRAG_SLOP` still tracks the finger, because
// this press caught something that was moving.
s.drag(
&render,
id,
CursorSense::Pressing(CursorButton::Left),
Vec2::new(0.0, 2.0),
down + Duration::from_millis(8),
);
assert!(
(s.amt - (caught_at - 2.0)).abs() < 0.01,
"a caught press must pan from its first sample: {} -> {}",
caught_at,
s.amt
);
}
}
+8
View File
@@ -78,12 +78,20 @@ impl TextView {
}
self.width = width;
let tex = painter.render_text(&mut self.buf, &self.attrs, width);
// Gated on `iris::diagnostics::trace_enabled` since 2026-09-07
// (docs/RUST.md's review, D1): one line per text *shape* (a cache
// miss), unconditional, is many per frame while rows compose --
// see `android::view::IrisViewPeer::render`'s own doc for the same
// finding on its two per-frame lines.
if crate::diagnostics::trace_enabled() {
log::debug!(
target: "iris::frame",
"iris text render: chars={} width={width:?} glyphs={} size={:?}",
self.buf.text().chars().count(),
tex.glyphs.len(),
tex.size,
);
}
self.tex = Some(tex.clone());
self.attrs.changed = false;
self.buf.changed = false;
+53 -13
View File
@@ -87,38 +87,78 @@ widget_trait! {
self.scrollable_on(Axis::Y)
}
// `scrollable` along `axis`. A code fence pans across its own long
// lines exactly the way a transcript pans down its rows, so the two
// are one function with the axis passed in rather than a second copy
// -- `DragArbiter::on` is the other half. (A `///` doc comment here
// is not accepted by `widget_trait!`, which parses its body itself.)
/// `scrollable_on`, but starting pinned to the **end** of its content
/// and staying there while the content grows -- what a composer wants,
/// where the newest line is the one being written.
///
/// Explicit, because the other kind is not a variation on it: a code
/// fence opened at the end of its longest line, which is the middle
/// of a word (seen in `iris/run-headless.sh phone`, 2026-09-08). The
/// two behaviours are one mechanism with the starting edge passed in,
/// and both names say which they are rather than one of them being a
/// default nobody reads.
fn scrollable_to_end(self, axis: Axis) -> impl WidgetIdFn<Rsc, Scroll> where Rsc: HasEvents {
self.scroll_area(axis, true)
}
/// `scrollable` along `axis`. A code fence pans across its own long
/// lines exactly the way a transcript pans down its rows, so the two
/// are one function with the axis passed in rather than a second copy
/// -- `DragArbiter::on` is the other half.
fn scrollable_on(self, axis: Axis) -> impl WidgetIdFn<Rsc, Scroll> where Rsc: HasEvents {
self.scroll_area(axis, false)
}
/// The one implementation behind [`Self::scrollable_on`] and
/// [`Self::scrollable_to_end`] -- see the latter for what `at_end`
/// decides.
fn scroll_area(self, axis: Axis, at_end: bool) -> impl WidgetIdFn<Rsc, Scroll> where Rsc: HasEvents {
move |state| {
Scroll::new(self.add_strong(state), axis)
Scroll::new(self.add_strong(state), axis, at_end)
.on(CursorSense::Scroll, move |ctx, rsc| {
let delta = ctx.data.scroll_delta.axis(axis) * 50.0;
ctx.widget(rsc).scroll(delta);
})
// A finger drag, through the same `DragGesture` the
// transcript's `List` is panned by -- `Scroll::drag`'s doc
// transcript's `LazySpan` is panned by -- `Scroll::drag`'s doc
// has the arbitration and why there is no fling. The wheel
// above and this are the two inputs of one scroll, so they
// are registered together rather than left to each caller.
.on(
CursorSense::click_or_drag() | CursorSense::unclick(),
|ctx, rsc| {
.on(CursorSense::drag_senses(), |ctx, rsc: &mut Rsc| {
let id = ctx.widget.id();
let (sense, pos) = (ctx.data.sense, ctx.data.cursor.pos);
let flung =
ctx.widget(rsc)
.drag(ctx.data.render, id, sense, pos, ctx.data.cursor.time);
},
)
.drag(ctx.data.pointer, id, sense, pos, ctx.data.cursor.time);
// The half that actually makes it move -- a fling is
// set by the widget and driven by the frame loop, and
// only this side can reach the loop. Only when one
// actually started: registering a widget that is not
// animating asks the next frame to find that out.
if flung {
rsc.ui_mut().animate(id);
}
})
.add(state)
}
}
fn masked(self) -> impl WidgetFn<Rsc, Masked> {
move |state| Masked {
shape: None,
inner: self.add_strong(state),
}
}
/// Clip to `shape` rather than to a plain box: `shape` is drawn
/// behind this widget, filling the same region, and what clips is the
/// primitive it drew -- so a rounded background and the corner its
/// content is cut to are one rect, with no radius passed twice.
/// Replaces `.masked().background(w)`, which drew the two but clipped
/// to the box.
fn masked_by<T>(self, shape: impl WidgetLike<Rsc, T>) -> impl WidgetFn<Rsc, Masked> {
move |state| Masked {
shape: Some(shape.add_strong(state)),
inner: self.add_strong(state),
}
}
+407
View File
@@ -0,0 +1,407 @@
//! The CPU rounded-rect SDF and the shader's own must agree.
//!
//! LAYOUT.md's "Masks with a shape" turns on it: the fragment stage clips
//! a masked subtree with `shader.wgsl`'s `rounded_rect_coverage`, and the
//! hit test (`UiRenderState::mask_admits`) clips the *same* subtree with
//! `iris_core::rounded_rect_coverage`, so a corner that cannot be tapped
//! and a corner that is not drawn are the same corner only while the two
//! functions answer the same. Nothing else checks that: both sides are
//! individually plausible and drift shows up as a control that is a pixel
//! or two off, which is exactly what nobody notices.
//!
//! So this runs **the real shader text**, lifted out of
//! `iris_core::SHAPE_SHADER` by name rather than copied here, over a grid
//! of points, and compares what came back with the Rust function at the
//! same points. This is the only test in the workspace that needs a GPU;
//! everything else about masks is layer 1 (docs/RUST.md's "Three test
//! layers"). It fails rather than skips when there is no adapter, because
//! a check that quietly did not run reads exactly like a check that
//! passed.
//!
//! **It is a render pass, and it asks for `iris_core::device_limits()`,
//! because those are the two things iris itself does.** The first version
//! of this test was a compute pass, which meant asking for compute limits
//! that `device_limits()` deliberately zeroes -- docs/RUST.md, 2026-09-05:
//! nothing in `iris`/`iris-core` creates a `ComputePipeline` or writes a
//! `@compute` stage, so the limits stopped being requested rather than a
//! fallback being built for a capability nothing uses. A test that needs
//! a capability the thing under test has never needed is testing the
//! wrong device, which is reason enough.
//!
//! It is **not** why that version crashed; see [`vulkan_instance`] for
//! what that crash actually was and why nothing here has to work around
//! it any more.
// `OnceLock<wgpu::Instance>` needs `Instance: Sync`, and wgpu's type
// graph is deep enough that proving it overflows rustc's default trait
// recursion limit of 128. Nothing here is recursive; the limit is a
// compile-time budget, and this is the documented way to raise it.
#![recursion_limit = "256"]
use std::sync::OnceLock;
use iris_core::{SHAPE_SHADER, rounded_rect_coverage, util::Vec2};
use pollster::FutureExt;
use wgpu::util::DeviceExt;
/// The rect the grid is sampled against, in window pixels. Deliberately
/// off the whole-pixel grid: the shader floors a primitive's corners, but
/// `rounded_rect_coverage` is handed pixels either side of that and has to
/// agree at fractional positions too -- the phone's 2.55 density puts
/// nothing on a whole pixel.
const TOP_LEFT: Vec2 = Vec2::new(10.5, 20.25);
const BOT_RIGHT: Vec2 = Vec2::new(170.75, 90.0);
/// Radii spanning what the widgets actually ask for, plus the two edges of
/// the function's own domain: a square corner, and one large enough that
/// `min(edge, radius)` stops mattering.
const RADII: [f32; 5] = [0.0, 0.75, 8.0, 20.0, 34.0];
/// The grid, as an attachment: one texel per probe point. `GRID_W` is a
/// multiple of 64 so that a row of `R32Float` is 256-byte aligned, which
/// is what `copy_texture_to_buffer` requires; at `STEP` this spans the
/// rect above and about four pixels of margin on every side, so the
/// feather is sampled rather than stepped over.
const GRID_W: u32 = 384;
const GRID_H: u32 = 192;
const STEP: f32 = 0.5;
const ORIGIN: Vec2 = Vec2::new(TOP_LEFT.x - 4.0, TOP_LEFT.y - 4.0);
/// f32 arithmetic in two compilers, not one: `length`/`sqrt` and
/// `smoothstep` are each allowed a unit or two in the last place, and the
/// GPU may contract a multiply-add the CPU does not. A coverage is in
/// [0, 1], so this is about six decimal digits -- four orders of magnitude
/// tighter than the half-pixel feather the hit test reads, which is what
/// the agreement is actually for.
const TOLERANCE: f32 = 1e-5;
#[test]
fn mask_sdf_matches_the_shader() {
let gpu = Gpu::open();
let mut worst = 0.0f32;
let mut worst_at = (Vec2::new(0.0, 0.0), 0.0f32, 0.0f32, 0.0f32);
let (mut inside, mut feather, mut outside) = (0u32, 0u32, 0u32);
for radius in RADII {
let coverage = run_shader(&gpu, radius);
for y in 0..GRID_H {
for x in 0..GRID_W {
let pos = probe_at(x, y);
let got = coverage[(y * GRID_W + x) as usize];
let want = rounded_rect_coverage(pos, TOP_LEFT, BOT_RIGHT, radius);
let diff = (got - want).abs();
if diff > worst {
worst = diff;
worst_at = (pos, radius, want, got);
}
if got > 0.999 {
inside += 1;
} else if got > 0.001 {
feather += 1;
} else {
outside += 1;
}
}
}
}
let (pos, radius, want, got) = worst_at;
assert!(
worst <= TOLERANCE,
"shader.wgsl's rounded_rect_coverage and iris_core's disagree by {worst} at {pos:?} \
(radius {radius}): the CPU says {want}, the GPU {got}. One of the two was edited \
without the other -- they are transliterations and have to stay so, or a masked \
corner stops being tappable where it is drawn.",
);
// The half that would pass on a function returning a constant.
assert!(
inside > 0 && feather > 0 && outside > 0,
"the grid never crossed an edge ({inside} in, {feather} on the feather, {outside} out), \
so agreeing proved nothing",
);
}
/// The probe position of texel `(x, y)` -- the one place the mapping
/// lives, so the CPU side and the fragment stage cannot walk different
/// grids.
fn probe_at(x: u32, y: u32) -> Vec2 {
Vec2::new(ORIGIN.x + x as f32 * STEP, ORIGIN.y + y as f32 * STEP)
}
/// `shader.wgsl`'s own `rounded_rect_coverage`, evaluated at every texel
/// of an `R32Float` attachment: one fragment per grid point, read back
/// whole. A fragment stage because that is the stage the function is
/// really called from, so what this compares is the code path that draws
/// rather than a second one built to be measurable.
fn run_shader(gpu: &Gpu, radius: f32) -> Vec<f32> {
let Gpu { device, queue, .. } = gpu;
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("mask sdf probe"),
source: wgpu::ShaderSource::Wgsl(probe_source().into()),
});
// R32Float, not an 8-bit colour format: a coverage quantised to 1/255
// could not be compared against the CPU's at anything like TOLERANCE,
// and the comparison would then be measuring the texture rather than
// the two functions.
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("mask sdf coverage"),
size: wgpu::Extent3d {
width: GRID_W,
height: GRID_H,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::R32Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
let probe = Probe {
top_left: [TOP_LEFT.x, TOP_LEFT.y],
bot_right: [BOT_RIGHT.x, BOT_RIGHT.y],
origin: [ORIGIN.x, ORIGIN.y],
step: [STEP, STEP],
radius,
_pad: [0.0; 3],
};
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("mask sdf probe"),
contents: bytemuck::bytes_of(&probe),
usage: wgpu::BufferUsages::UNIFORM,
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("mask sdf probe"),
layout: None,
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("probe_vs"),
compilation_options: Default::default(),
buffers: &[],
},
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("probe_fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::TextureFormat::R32Float.into())],
}),
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
multiview_mask: None,
cache: None,
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("mask sdf probe"),
layout: &pipeline.get_bind_group_layout(0),
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: uniform.as_entire_binding(),
}],
});
// `copy_texture_to_buffer` wants each row 256-byte aligned; GRID_W is
// chosen so that it already is, rather than padding and unpicking the
// padding on the way out.
let row_bytes = GRID_W * 4;
assert_eq!(row_bytes % 256, 0, "GRID_W must keep rows 256-byte aligned");
let out_size = u64::from(row_bytes) * u64::from(GRID_H);
let read_buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("mask sdf readback"),
size: out_size,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = device.create_command_encoder(&Default::default());
{
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("mask sdf probe"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
multiview_mask: None,
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &bind_group, &[]);
pass.draw(0..3, 0..1);
}
enc.copy_texture_to_buffer(
texture.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read_buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(row_bytes),
rows_per_image: Some(GRID_H),
},
},
wgpu::Extent3d {
width: GRID_W,
height: GRID_H,
depth_or_array_layers: 1,
},
);
queue.submit([enc.finish()]);
let slice = read_buf.slice(..);
slice.map_async(wgpu::MapMode::Read, |r| r.expect("mapping the readback"));
device
.poll(wgpu::PollType::wait_indefinitely())
.expect("waiting for the probe");
let mapped = slice
.get_mapped_range()
.expect("reading back the mapped probe buffer");
let coverage = bytemuck::cast_slice::<u8, f32>(&mapped).to_vec();
drop(mapped);
read_buf.unmap();
coverage
}
/// One `wgpu::Instance` for the process, created on first use and never
/// destroyed.
///
/// **Why it is a static rather than a value the test owns.** Destroying
/// the last `VkInstance` makes the Vulkan loader `dlclose` the ICD, and
/// Mesa's ICD here registers a `pthread_key_create` destructor pointing
/// into its own text without being linked `-z nodelete`. glibc then calls
/// that destructor when the thread exits -- through an address that is no
/// longer mapped. libtest runs every `#[test]` on a spawned thread, so a
/// test that opens and closes an instance segfaults *after* printing its
/// result, which reads exactly like the test failing. Measured
/// 2026-09-08 with `rigs/gpu-probe`'s `teardown` bin: it
/// needs no wgpu (raw `ash` does it too), no GPU work, and no device --
/// an instance created and destroyed on a spawned thread is enough, and
/// keeping any one instance alive is enough to prevent it.
///
/// Devices, queues and everything else drop normally; only the instance
/// is held, which is what wgpu asks for anyway (one instance per
/// process). So this costs one instance for the length of a test binary
/// and buys ordinary drops everywhere else.
fn vulkan_instance() -> &'static wgpu::Instance {
static INSTANCE: OnceLock<wgpu::Instance> = OnceLock::new();
INSTANCE.get_or_init(wgpu::Instance::default)
}
/// The device this test draws with.
struct Gpu {
device: wgpu::Device,
queue: wgpu::Queue,
}
impl Gpu {
/// Opens the device this test draws with, and reports which adapter
/// answered, because that is not a detail here: a run on llvmpipe and
/// a run on the host's GPU are otherwise indistinguishable in the
/// log, and only one of them is a check of what the phone will do.
fn open() -> Self {
let instance = vulkan_instance();
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions::default())
.block_on()
.expect(
"no wgpu adapter on this machine, so the CPU/shader SDF agreement went \
unchecked. This VM has a virtio-gpu render node (the `this-machine-graphics` \
skill says what it is and how it fails); if that is gone, fix it rather \
than deleting this test.",
);
let info = adapter.get_info();
eprintln!(
"mask_sdf: {} ({:?}, {})",
info.name, info.backend, info.driver
);
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor {
// What iris itself asks for -- see this file's header.
required_limits: iris_core::device_limits(),
..Default::default()
})
.block_on()
.expect("could not get a device from the adapter");
Self { device, queue }
}
}
/// What the fragment stage needs to turn its own texel into a probe
/// position: the rect being sampled, and where texel (0, 0) sits.
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
struct Probe {
top_left: [f32; 2],
bot_right: [f32; 2],
origin: [f32; 2],
step: [f32; 2],
radius: f32,
_pad: [f32; 3],
}
/// The probe module: the two functions **lifted from `shader.wgsl`
/// itself**, plus an entry point that calls the outer one. Lifted rather
/// than copied so there is nothing to keep in step -- an edit to the
/// shader is what this test is for, and a copy here would be edited along
/// with it.
fn probe_source() -> String {
format!(
"{}\n{}\n\
struct Probe {{\n\
top_left: vec2<f32>,\n\
bot_right: vec2<f32>,\n\
origin: vec2<f32>,\n\
step: vec2<f32>,\n\
radius: f32,\n\
}}\n\
@group(0) @binding(0) var<uniform> probe: Probe;\n\
@vertex\n\
fn probe_vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4<f32> {{\n\
var xy = array(vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0));\n\
return vec4(xy[vi], 0.0, 1.0);\n\
}}\n\
@fragment\n\
fn probe_fs(@builtin(position) pos: vec4<f32>) -> @location(0) f32 {{\n\
let at = probe.origin + floor(pos.xy) * probe.step;\n\
return rounded_rect_coverage(at, probe.top_left, probe.bot_right, probe.radius);\n\
}}\n",
wgsl_fn("distance_from_rect"),
wgsl_fn("rounded_rect_coverage"),
)
}
/// One WGSL function's whole text, from its `fn` keyword to the `}` that
/// closes its body, found by matching braces. Panics by name when the
/// function is not there, which is what a rename looks like from here.
fn wgsl_fn(name: &str) -> &'static str {
let start = SHAPE_SHADER
.find(&format!("fn {name}("))
.unwrap_or_else(|| panic!("shader.wgsl has no `fn {name}(` -- renamed, or gone"));
let body = SHAPE_SHADER[start..]
.find('{')
.expect("a wgsl fn signature is followed by its body");
let mut depth = 0usize;
for (i, c) in SHAPE_SHADER[start + body..].char_indices() {
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
return &SHAPE_SHADER[start..start + body + i + 1];
}
}
_ => {}
}
}
panic!("`fn {name}`'s body in shader.wgsl is never closed");
}
+9
View File
@@ -22,3 +22,12 @@ serde_json = { version = "1", features = ["float_roundtrip"] }
[dev-dependencies]
winit = { workspace = true }
# `examples/phone.rs`'s `--typed` only: it spaces its insertions out over
# real frames, and a task spawned through `iris`'s own runtime
# (`iris/src/task.rs`) is where the sleep has to happen. Same version the
# workspace already pins for `iris-android-app`.
tokio = { workspace = true, features = ["time"] }
# For the `iris::input`/`iris::frame` round-trip test: a capturing `log::Log`
# to read back what `iris::diagnostics::log_frame`/`sense::log_input_event`
# wrote, pinned to the same version `iris/Cargo.toml` already carries.
log = "0.4.34"
+78 -5
View File
@@ -11,6 +11,16 @@
//! screenshot here and one from the phone is the renderer, never the
//! data.
//!
//! `--message TEXT` (through `RUN_HEADLESS_ARGS`) starts with that text
//! already in the composer, `\n` for a newline -- the composer's grown
//! and overflowing states are otherwise unreachable here, since this
//! window has no keyboard to type into (UI_RULES.md's "check the states
//! you can't see by default"). `--typed TEXT` *enters* the same text
//! instead, one character per 100ms: laying the composer out from
//! scratch and growing one already on screen are different cases, and
//! only the second reproduced the caret landing in the bar's padding
//! (IRIS.md, 2026-09-08).
//!
//! No server: `transcript-fixture` embeds the transcript. Colour,
//! spacing, type and anything a person has to *see* is answered here;
//! anything with an assertion behind it belongs in `tests/
@@ -19,6 +29,49 @@
use iris::prelude::*;
use winit::{dpi::PhysicalSize, window::WindowAttributes};
/// The `--ime PX` argument: the bottom inset a keyboard would report,
/// applied after the first frame the way Android's `on_insets_changed`
/// does. The composer's keyboard-open layout is otherwise unreachable
/// here, and it is where its mask went wrong before (see
/// `ActiveData::own_mask`).
fn ime_argv() -> Option<f32> {
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
if arg == "--ime" {
return args.next()?.parse().ok();
}
}
None
}
/// The `--message TEXT` argument, with `\n` taken as a newline so a
/// multi-line message survives one shell word.
fn message_argv() -> Option<String> {
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
if arg == "--message" {
return Some(args.next()?.replace("\\n", "\n"));
}
}
None
}
/// The `--typed TEXT` argument: the same text as `--message`, but
/// *entered* rather than preloaded -- one insertion per 100ms, into a
/// focused field, the way a person types. The two are different cases
/// for layout: `--message` is laid out from scratch on the first frame,
/// while this grows an already-drawn composer, which is the path
/// Iris's 2026-09-08 phone report is about.
fn typed_argv() -> Option<String> {
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
if arg == "--typed" {
return Some(args.next()?.replace("\\n", "\n"));
}
}
None
}
fn main() {
DefaultApp::<Client>::run();
}
@@ -47,11 +100,31 @@ impl DefaultAppState for Client {
) -> Self {
let screen = match transcript_fixture::open(rsc, &mut ui_state) {
Ok(opened) => {
// A fling coasts only while something asks for the next
// frame; on the desktop that is the window's own redraw
// request (`List::fling`'s doc).
let handle = rsc.tasks.redraw_handle();
(opened.screen.list)(rsc).set_redraw_handle(handle);
if let Some(message) = message_argv() {
opened.screen.composer.field.edit(rsc).set(&message);
}
if let Some(text) = typed_argv() {
let field = opened.screen.composer.field;
let redraw = rsc.tasks.redraw_handle();
rsc.spawn_task(async move |mut ctx| {
for ch in text.chars() {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
ctx.update(move |state: &mut Client, rsc| {
state.set_focus(Some(field));
let end = rsc[field].text().len();
let mut edit = field.edit(rsc);
if edit.text.caret().is_none() {
edit.set_cursor_byte(end);
}
edit.insert(&ch.to_string());
});
redraw.request_redraw();
}
});
}
if let Some(inset) = ime_argv() {
opened.screen.composer.set_bottom_inset(rsc, inset);
}
Some(opened.screen)
}
// On screen rather than a panic: this window exists to be
@@ -0,0 +1,211 @@
//! Layer 1 of docs/RUST.md's "Three test layers", for catching a fling:
//! the real transcript screen over the real bench fixture, at the phone's
//! size and density, with no window, no compositor and no GPU.
//!
//! docs/IRIS_TODO.md's 2026-09-07 night report -- "sometimes when I try to
//! catch it while it's still moving (particularly if I drag) then it fails
//! to stop & snap to where finger is". The finger goes down on content
//! that is still travelling and the content does not follow it until
//! `DRAG_SLOP` has been crossed, which at a fling's speed is several
//! frames of the content sliding *away* from a finger that is already
//! down. Compose does not do that: a down while `isScrollInProgress`
//! starts the drag immediately (`scrollable`'s `startDragImmediately`).
use iris::harness::{Harness, TouchAction, TouchScript};
use iris::prelude::*;
use iris::sense::DRAG_SLOP;
use transcript_fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
/// The screen open on the fixture, framed twice -- once to draw, once for
/// `LazySpan::repair_anchor` to resolve the opening `snap_end` into a real
/// anchor, which is what every assertion about scroll position reads.
fn opened() -> (Harness, transcript_ui::TranscriptScreen) {
let mut h = Harness::new(phone_size(), PHONE_SCALE);
let opened = transcript_fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
h.frame(0);
h.frame(PHONE_FRAME_MS);
(h, opened.screen)
}
/// Where the content is, in window pixels: the top of whichever row is
/// under the middle of the viewport. `LazySpan` has no travel accessor and
/// this needs none -- a row's own extent moves exactly as far as the
/// content does, and the row is picked once so the two readings compare.
fn tracked_row(h: &mut Harness, screen: &transcript_ui::TranscriptScreen) -> (RowKey, f32) {
let middle = phone_size().y / 2.0;
let list = (screen.list)(&mut h.rsc);
let key = list.key_at(middle).expect("a row under the viewport");
let (top, _) = list.extent(key).expect("that row has an extent");
(key, top)
}
fn row_top(h: &mut Harness, screen: &transcript_ui::TranscriptScreen, key: RowKey) -> f32 {
(screen.list)(&mut h.rsc)
.extent(key)
.expect("the tracked row is still loaded")
.0
}
/// Three finger samples 8ms apart, each moving `STEP` further down the
/// screen. `STEP * 3` is deliberately **under** `DRAG_SLOP`: a gesture
/// this small moves nothing at all on a settled list (the control below),
/// so anything it moves here is the catch and not the slop being crossed.
const STEP: f32 = 2.0;
const SAMPLES: usize = 3;
const CATCH_X: f32 = 540.0;
/// Feeds the down and its `SAMPLES` moves from `y0` at `t0`, asserting
/// after each one that the content moved by exactly the finger's own
/// delta. Returns the release time.
fn drag_from(
h: &mut Harness,
screen: &transcript_ui::TranscriptScreen,
key: RowKey,
y0: f32,
t0: u64,
expect_tracking: bool,
) -> u64 {
let before = row_top(h, screen, key);
h.touch(TouchAction::Down, Vec2::new(CATCH_X, y0), t0);
assert_eq!(
row_top(h, screen, key),
before,
"the down itself must not move the content, only stop it"
);
let mut t = t0;
for i in 1..=SAMPLES {
let moved = STEP * i as f32;
t = t0 + 8 * i as u64;
h.touch(TouchAction::Move, Vec2::new(CATCH_X, y0 + moved), t);
let travelled = row_top(h, screen, key) - before;
if expect_tracking {
assert!(
(travelled - moved).abs() < 0.5,
"sample {i}: the finger has moved {moved}px since the down and the content \
{travelled:.1}px -- it is not pinned to the finger"
);
} else {
assert!(
travelled.abs() < 0.5,
"sample {i}: a {moved}px drag is inside DRAG_SLOP ({DRAG_SLOP}px) and must move \
nothing, but the content moved {travelled:.1}px"
);
}
}
t += 8;
h.touch(
TouchAction::Up,
Vec2::new(CATCH_X, y0 + STEP * SAMPLES as f32),
t,
);
t
}
/// The report itself: flick, let the fling run for 150ms, then put a
/// finger down and drag it a little. From the down onwards the content is
/// pinned to the finger, sample for sample -- no slop, and no coasting
/// past the place the finger stopped it.
#[test]
fn a_press_on_a_flinging_list_pins_the_content_to_the_finger() {
let (mut h, screen) = opened();
let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch"))
.unwrap_or_else(|e| panic!("flick-120hz.touch: {e}"));
h.replay(&flick);
// Frames, not a file: the second half of this gesture has to arrive
// *while* the fling is ticking, and a `.touch` replay inserts no
// frames between its samples, so a fling recorded that way would be
// running on paper and stationary in fact.
let catch_at = flick.end_ms() + 150;
h.frames_until(
flick.end_ms() + PHONE_FRAME_MS,
catch_at - PHONE_FRAME_MS,
PHONE_FRAME_MS,
);
assert!(
(screen.scroll)(&mut h.rsc).is_scrolling(),
"the fling must still be running 150ms in, or this test catches nothing"
);
let (key, _) = tracked_row(&mut h, &screen);
drag_from(&mut h, &screen, key, 1200.0, catch_at, true);
}
/// The other half of the same rule: a catch that never moved at all is a
/// `Released(None)`, not a tap. Compose's scrollable consumes that DOWN,
/// so no click detector under it ever sees the gesture -- stopping a
/// fling with a finger must not also follow the link it landed on, and
/// must not hand the list a velocity to start again with.
#[test]
fn a_catch_that_never_moved_is_not_a_tap_and_does_not_fling() {
let (mut h, screen) = opened();
let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch"))
.unwrap_or_else(|e| panic!("flick-120hz.touch: {e}"));
h.replay(&flick);
let catch_at = flick.end_ms() + 150;
h.frames_until(
flick.end_ms() + PHONE_FRAME_MS,
catch_at - PHONE_FRAME_MS,
PHONE_FRAME_MS,
);
assert!(
(screen.scroll)(&mut h.rsc).is_scrolling(),
"the fling must still be running 150ms in, or this test catches nothing"
);
let (key, _) = tracked_row(&mut h, &screen);
h.touch(TouchAction::Down, Vec2::new(CATCH_X, 1200.0), catch_at);
let stopped_at = row_top(&mut h, &screen, key);
h.touch(TouchAction::Up, Vec2::new(CATCH_X, 1200.0), catch_at + 8);
assert_eq!(
(screen.scroll)(&mut h.rsc).fling_velocity(),
None,
"a press that stopped a fling and moved nothing must not start another"
);
h.frames_until(catch_at + 16, catch_at + 500, PHONE_FRAME_MS);
assert!(
(row_top(&mut h, &screen, key) - stopped_at).abs() < 0.5,
"the content moved after a catch was released without moving"
);
assert_eq!(
h.state.opened_urls,
Vec::<String>::new(),
"a catch is not a tap: nothing under it may be followed"
);
}
/// The half this change had no reason to touch: on a list that is *not*
/// moving, the same tiny drag is still inside `DRAG_SLOP` and still moves
/// nothing. Without this, making every press pin the content would pass
/// the test above and take the slop away from every ordinary press.
#[test]
fn the_same_small_drag_on_a_settled_list_moves_nothing() {
let (mut h, screen) = opened();
let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch"))
.unwrap_or_else(|e| panic!("flick-120hz.touch: {e}"));
h.replay(&flick);
// Long past the spline's own 2071ms for this recording.
let settled = h.frames_until(
flick.end_ms() + PHONE_FRAME_MS,
flick.end_ms() + 4000,
PHONE_FRAME_MS,
);
assert!(
!(screen.scroll)(&mut h.rsc).is_scrolling(),
"the fling must have stopped, or this is the same case as the test above"
);
let (key, _) = tracked_row(&mut h, &screen);
drag_from(
&mut h,
&screen,
key,
1200.0,
settled + PHONE_FRAME_MS,
false,
);
}
@@ -0,0 +1,230 @@
//! Layer 1 for Iris's 2026-09-08 "flinging doesn't work in horizontal
//! scroll areas": a real markdown fence in the real transcript screen,
//! flicked sideways, has to keep moving after the finger leaves.
//!
//! The fence is pushed here rather than hunted for in the bench fixture,
//! so the test knows which row it is pressing and where. The `Scroll` it
//! asserts on is found by walking what is actually drawn -- there is no
//! handle to it from the outside, and a coordinate would only prove that
//! *something* moved.
use iris::harness::{Harness, TouchAction};
use iris::prelude::*;
use transcript_fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
/// The horizontal scroll area drawn inside `top..bottom`, with the box
/// it was drawn at -- a fence is the only thing in a transcript that pans
/// sideways. Found by walking what is actually drawn, because there is no
/// handle to a fence's own `Scroll` from the outside and a bare
/// coordinate would only prove that *something* moved.
fn fence_scroll_in(h: &Harness, top: f32, bottom: f32) -> Option<(WidgetId, PixelRegion)> {
h.render
.active
.keys()
.copied()
.filter(|&id| {
h.rsc
.ui
.widgets
.get_dyn(id)
.and_then(|w| w.as_any().downcast_ref::<Scroll>())
.is_some_and(|s| s.axis() == Axis::X)
})
.find_map(|id| {
let r = h.render.window_region(&id, &h.rsc)?;
(r.top_left.y >= top && r.bot_right.y <= bottom).then_some((id, r))
})
}
fn is_scrolling(h: &Harness, id: WidgetId) -> bool {
h.rsc
.ui
.widgets
.get_dyn(id)
.and_then(|w| w.as_any().downcast_ref::<Scroll>())
.expect("the fence's scroll area is still drawn")
.is_scrolling()
}
fn amt(h: &Harness, id: WidgetId) -> f32 {
h.rsc
.ui
.widgets
.get_dyn(id)
.and_then(|w| w.as_any().downcast_ref::<Scroll>())
.expect("the fence's scroll area is still drawn")
.amt()
}
#[test]
fn a_flick_across_a_code_fence_keeps_moving_after_the_finger_leaves() {
use client_core::transcript_fold::{TranscriptItem, TranscriptRow};
let mut h = Harness::new(phone_size(), PHONE_SCALE);
let opened = transcript_fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
let screen = opened.screen;
h.frame(0);
h.frame(PHONE_FRAME_MS);
let fence = TranscriptRow::Single(TranscriptItem::AssistantMsg {
seq: 9_000_000,
text: "```\none two three four five six seven eight nine ten eleven twelve \
thirteen fourteen fifteen sixteen seventeen eighteen twenty twentyone\n```"
.to_string(),
settled: true,
});
screen.push_row(&mut h.rsc, &fence);
(screen.list)(&mut h.rsc).jump_to_end();
h.frame(100);
h.frame(108);
let key = transcript_ui::row::row_key(&fence.key());
let (top, bottom) = (screen.list)(&mut h.rsc)
.extent(key)
.expect("the fence row is on screen");
let (fence_scroll, box_) = fence_scroll_in(&h, top, bottom)
.expect("the pushed fence draws a horizontal scroll area of its own");
assert_eq!(amt(&h, fence_scroll), 0.0, "a fence opens at its start");
// Down the middle of the fence's own box, so the press is on the
// text inside the scroll area rather than on the row's sender label.
let y = (box_.top_left.y + box_.bot_right.y) / 2.0;
// A flick sideways: four samples 8ms apart, accelerating, then the
// finger leaves.
h.touch(TouchAction::Down, Vec2::new(900.0, y), 200);
for (i, x) in [860.0, 800.0, 720.0, 620.0].into_iter().enumerate() {
h.touch(TouchAction::Move, Vec2::new(x, y), 208 + 8 * i as u64);
}
h.touch(TouchAction::Up, Vec2::new(620.0, y), 240);
let at_release = amt(&h, fence_scroll);
assert!(
at_release > 0.0,
"the flick itself must have panned the fence, got {at_release}"
);
// Frames for the next half second, with nothing touching the screen.
let mut t = 240;
while t <= 740 {
h.frame(t);
t += PHONE_FRAME_MS;
}
let coasted = amt(&h, fence_scroll);
assert!(
coasted > at_release + 1.0,
"the fence stopped dead at the release: {at_release} -> {coasted}"
);
// ...and it settles rather than running forever.
let settled = coasted;
while t <= 4_000 {
h.frame(t);
t += PHONE_FRAME_MS;
}
let after = amt(&h, fence_scroll);
assert!(
after >= settled,
"a fling must not run backwards: {settled} -> {after}"
);
let last = after;
h.frame(t);
assert_eq!(last, amt(&h, fence_scroll), "the fling never settled");
}
/// Iris's 2026-09-08 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."
///
/// Her own diagnosis was the right one -- "tapping outside of something
/// that a fling is currently active for should have no code in common
/// with the fling that could influence it" -- and
/// `sense_tests::a_press_does_not_reach_a_widget_the_pointer_has_just_left`
/// is the mechanism in isolation. This is the same thing over the real
/// screen, which is where it was found: the finger goes down on an
/// ordinary row 500px above a coasting fence, and what must move is the
/// list, while the fence carries on coasting untouched.
#[test]
fn a_drag_away_from_a_coasting_fence_scrolls_the_list_and_leaves_it_coasting() {
use client_core::transcript_fold::{TranscriptItem, TranscriptRow};
let mut h = Harness::new(phone_size(), PHONE_SCALE);
let opened = transcript_fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
let screen = opened.screen;
h.frame(0);
h.frame(PHONE_FRAME_MS);
let fence = TranscriptRow::Single(TranscriptItem::AssistantMsg {
seq: 9_000_000,
text: format!(
"```\n{}\n```",
(1..=200)
.map(|i| format!("word{i}"))
.collect::<Vec<_>>()
.join(" ")
),
settled: true,
});
screen.push_row(&mut h.rsc, &fence);
(screen.list)(&mut h.rsc).jump_to_end();
h.frame(100);
h.frame(108);
let key = transcript_ui::row::row_key(&fence.key());
let (top, bottom) = (screen.list)(&mut h.rsc)
.extent(key)
.expect("the fence row is on screen");
let (fence_scroll, box_) = fence_scroll_in(&h, top, bottom)
.expect("the pushed fence draws a horizontal scroll area of its own");
let y = (box_.top_left.y + box_.bot_right.y) / 2.0;
// Flick the fence sideways and let go, exactly as above.
h.touch(TouchAction::Down, Vec2::new(900.0, y), 200);
for (i, x) in [860.0, 800.0, 720.0, 620.0].into_iter().enumerate() {
h.touch(TouchAction::Move, Vec2::new(x, y), 208 + 8 * i as u64);
}
h.touch(TouchAction::Up, Vec2::new(620.0, y), 240);
h.frame(248);
assert!(
is_scrolling(&h, fence_scroll),
"the fence has to still be coasting for this to be the reported case",
);
// A row well clear of the fence, taken by its own extent rather than
// by a coordinate: the gaps between rows are pointer-transparent, so a
// y picked by hand lands on nothing often enough to make a green run
// meaningless.
let probe = box_.top_left.y - 500.0;
let row = (screen.list)(&mut h.rsc)
.key_at(probe)
.expect("a row that far up the screen");
let (row_top, row_bottom) = (screen.list)(&mut h.rsc).extent(row).expect("its extent");
let from = (row_top + row_bottom) / 2.0;
let list_before = (screen.list)(&mut h.rsc).anchor_position_display();
let fence_before = amt(&h, fence_scroll);
h.touch(TouchAction::Down, Vec2::new(540.0, from), 256);
let mut t = 264;
for i in 1..=8 {
h.touch(
TouchAction::Move,
Vec2::new(540.0, from + 20.0 * i as f32),
t,
);
t += 8;
}
h.touch(TouchAction::Up, Vec2::new(540.0, from + 160.0), t);
assert_ne!(
list_before,
(screen.list)(&mut h.rsc).anchor_position_display(),
"the drag was nowhere near the fence, so it belongs to the list",
);
assert!(
amt(&h, fence_scroll) > fence_before,
"the fence's fling must carry on through a gesture that was never \
its own: {fence_before} -> {}",
amt(&h, fence_scroll),
);
}
@@ -0,0 +1,206 @@
//! Layer 1 of docs/RUST.md's "Three test layers" for a gesture the
//! *platform* takes away, over the real transcript screen and the real
//! bench fixture.
//!
//! Both halves of Iris's 2026-09-08 report about the transcript moving on
//! its own live here. A cancel is not a release, so nothing may follow it
//! -- and every widget that was tracking the press has to hear about it,
//! or the next press anywhere on screen is measured from the origin the
//! abandoned one left behind.
use iris::harness::{Harness, TouchAction, TouchScript};
use iris::prelude::*;
use transcript_fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
fn opened() -> (Harness, transcript_ui::TranscriptScreen) {
let mut h = Harness::new(phone_size(), PHONE_SCALE);
let opened = transcript_fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
h.frame(0);
h.frame(PHONE_FRAME_MS);
(h, opened.screen)
}
fn script(name: &str, text: &str) -> TouchScript {
TouchScript::parse(text).unwrap_or_else(|e| panic!("{name}: {e}"))
}
/// Where the content actually is, in window pixels: the top of whichever
/// row is under the middle of the viewport, tracked by key. The anchor's
/// own `idx/off` display is not that -- the list rehomes its anchor to a
/// different row without the content moving at all -- so a test asserting
/// "nothing moved" reads a row's own extent, the way `catch_a_fling.rs`
/// does.
fn tracked_row(h: &mut Harness, screen: &transcript_ui::TranscriptScreen) -> (RowKey, f32) {
let middle = phone_size().y / 2.0;
let list = (screen.list)(&mut h.rsc);
let key = list.key_at(middle).expect("a row under the viewport");
let (top, _) = list.extent(key).expect("that row has an extent");
(key, top)
}
fn row_top(h: &mut Harness, screen: &transcript_ui::TranscriptScreen, key: RowKey) -> f32 {
(screen.list)(&mut h.rsc)
.extent(key)
.expect("the tracked row is still loaded")
.0
}
/// The system's own swipe up from the bottom edge to leave the app is
/// delivered to the app as moves and then `ACTION_CANCEL`. Read as a
/// release it hands the list that swipe's velocity, and the transcript
/// flings while nobody is looking -- "leaving and reopening the app also
/// randomly moved the vertical scroll".
#[test]
fn a_cancelled_flick_does_not_fling() {
let (mut h, screen) = opened();
let flick = script(
"flick-cancelled",
include_str!("../touch/flick-cancelled.touch"),
);
h.replay(&flick);
assert_eq!(
(screen.scroll)(&mut h.rsc).fling_velocity(),
None,
"a gesture the platform took away must not fling"
);
// ...and it must not be moving on its own over the following second
// either, which is what a fling started some other way would look
// like.
let (key, settled) = tracked_row(&mut h, &screen);
let end = flick.end_ms() + 1_000;
let mut t = flick.end_ms();
while t <= end {
h.frame(t);
t += PHONE_FRAME_MS;
}
let now = row_top(&mut h, &screen, key);
assert!(
(now - settled).abs() < 0.5,
"the list kept moving after a cancelled gesture: {settled} -> {now}"
);
}
/// The other half, and the one that made a *later* touch snap: a cancel
/// has to reach every widget that was handed a frame of the press, so the
/// gesture it was driving forgets its origin. Without it the arbiter is
/// still open with the abandoned press's touch-down as its origin, and
/// the next press is measured from there -- a jump the size of the
/// distance between two unrelated touches.
#[test]
fn a_press_ended_by_a_cancel_leaves_no_origin_for_the_next_one() {
let (mut h, screen) = opened();
// Press near the top of the transcript and let the platform take it.
h.touch(TouchAction::Down, Vec2::new(540.0, 700.0), 0);
h.touch(TouchAction::Cancel, Vec2::new(540.0, 700.0), 8);
let (key, before) = tracked_row(&mut h, &screen);
// A plain tap, a long way down the screen from where that press
// started. It must move nothing at all.
h.touch(TouchAction::Down, Vec2::new(540.0, 1900.0), 200);
h.touch(TouchAction::Up, Vec2::new(540.0, 1900.0), 250);
let after = row_top(&mut h, &screen, key);
assert!(
(after - before).abs() < 0.5,
"a tap after a cancelled press panned the list by {}px, the distance between them",
after - before
);
assert_eq!(
(screen.scroll)(&mut h.rsc).fling_velocity(),
None,
"and it must not have flung either"
);
}
/// The report itself: "if I scroll in a horizontal area and then tap in a
/// vertical area, it seems to snap."
///
/// A markdown fence pans sideways through its own `Scroll`, which takes
/// pointer capture the moment it commits. Everything else that was handed
/// a frame of that press is told so with `CursorSense::Cancel` -- and the
/// widget the press actually landed on is the fence's own text block,
/// which drives `transcript_ui::Selection`'s shared `DragGesture`. A
/// block that does not register `Cancel` never hears it, so the gesture
/// stays open with the fence's touch-down as its origin and the next
/// press anywhere is measured from there.
///
/// The fence is pushed here rather than hunted for in the fixture, so the
/// test knows exactly which row it is pressing and where.
#[test]
fn panning_a_code_fence_then_tapping_elsewhere_moves_nothing() {
use client_core::transcript_fold::{TranscriptItem, TranscriptRow};
let (mut h, screen) = opened();
let fence = TranscriptRow::Single(TranscriptItem::AssistantMsg {
seq: 9_000_000,
text: "```\none two three four five six seven eight nine ten eleven twelve\n\
thirteen fourteen fifteen sixteen seventeen eighteen nineteen\n```"
.to_string(),
settled: true,
});
// A plain paragraph under it, because the tap has to land on
// ordinary text: a tap that happens to hit a tool group's header
// toggles it, and a row changing height moves the list for a reason
// that has nothing to do with this.
let para = TranscriptRow::Single(TranscriptItem::AssistantMsg {
seq: 9_000_001,
text: "A plain paragraph with nothing to tap in it, only words, so that a \
press here is a press on ordinary text and nothing else."
.to_string(),
settled: true,
});
screen.push_row(&mut h.rsc, &fence);
screen.push_row(&mut h.rsc, &para);
(screen.list)(&mut h.rsc).jump_to_end();
h.frame(100);
h.frame(108);
// Press in the middle of the fence's own row, so the gesture starts on
// the text block inside the scroll area rather than in a gap.
let key = transcript_ui::row::row_key(&fence.key());
let (top, bottom) = (screen.list)(&mut h.rsc)
.extent(key)
.expect("the fence row is on screen");
let y = (top + bottom) / 2.0;
assert!(
y > 0.0 && y < phone_size().y,
"the fence row has to be on screen to be pressed: {top}..{bottom}"
);
// Sideways, well past `DRAG_SLOP`, so the fence commits and captures.
h.touch(TouchAction::Down, Vec2::new(800.0, y), 200);
for (i, x) in [760.0, 700.0, 620.0, 540.0].into_iter().enumerate() {
h.touch(TouchAction::Move, Vec2::new(x, y), 208 + 8 * i as u64);
}
h.touch(TouchAction::Up, Vec2::new(540.0, y), 248);
let (tracked, before) = tracked_row(&mut h, &screen);
// A tap on the paragraph, a long way down the screen from where that
// pan started.
let para_key = transcript_ui::row::row_key(&para.key());
let (ptop, pbottom) = (screen.list)(&mut h.rsc)
.extent(para_key)
.expect("the paragraph row is on screen");
h.touch(
TouchAction::Down,
Vec2::new(540.0, (ptop + pbottom) / 2.0),
400,
);
h.touch(
TouchAction::Up,
Vec2::new(540.0, (ptop + pbottom) / 2.0),
450,
);
let after = row_top(&mut h, &screen, tracked);
assert!(
(after - before).abs() < 0.5,
"a tap after panning a code fence moved the transcript by {}px",
after - before
);
}
@@ -0,0 +1,189 @@
//! Layer 1 of docs/RUST.md's "Three test layers", for the diagnostics
//! themselves rather than a widget: `iris::diagnostics::set_trace` gates
//! `iris::input`/`iris::frame` (Iris's 2026-09-07 request, "add another
//! button to copy input event info ... instrument a lot of the code with
//! timings"), and `docs/REVIEW-2026-09-07.md`'s D1 found that the switch
//! existed but four older per-frame `debug!` lines were not wired to it,
//! filling the app's 2000-line log ring with frame spam before `Copy
//! report` had a chance to include anything else. This is what a fix to
//! that has to prove, both directions:
//!
//! 1. **Off** (the default): replaying a real gesture through a real
//! screen leaves the ring holding nothing below `info` -- so the
//! lines D1 named, and everything this pass gated the same way, really
//! are silent by default rather than merely "usually quiet."
//! 2. **On**: the same replay produces `iris::input` lines that
//! `report_to_touch.py` turns back into the exact `TouchScript` that
//! was replayed, and `iris::frame` lines with real, non-zero
//! durations dated on the harness's own clock.
//!
//! **Single capturing logger, single test function** (this file's only
//! `#[test]`): `log::set_logger` can succeed exactly once per process, and
//! AGENTS.md's "tracing caches callsite interest process-wide" lesson is
//! the general form of why every exercise of a logging path has to share
//! one subscriber -- so if a second test here ever needs the ring's
//! contents, it must extend this one rather than install its own.
use std::process::{Command, Stdio};
use std::sync::{Mutex, OnceLock};
use iris::harness::{Harness, TouchScript};
use transcript_fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
/// Records every line's level and formatted message -- enough to answer
/// both "is the ring quiet" (no line at `Debug` or below) and "what did
/// tracing actually write" (the `iris::input` lines, read back by
/// `report_to_touch.py`).
struct CaptureLogger {
lines: Mutex<Vec<(log::Level, String)>>,
}
static LOGGER: OnceLock<CaptureLogger> = OnceLock::new();
impl log::Log for CaptureLogger {
fn enabled(&self, _metadata: &log::Metadata) -> bool {
true
}
fn log(&self, record: &log::Record) {
self.lines
.lock()
.unwrap()
.push((record.level(), record.args().to_string()));
}
fn flush(&self) {}
}
/// Installs the capture logger at `Debug` -- the same level
/// `iris/android-app/src/lib.rs`'s `JNI_OnLoad` installs at, which is
/// exactly why `iris::diagnostics::trace_enabled` has to be the gate
/// (its own module doc) rather than the level.
fn logger() -> &'static CaptureLogger {
let logger = LOGGER.get_or_init(|| CaptureLogger {
lines: Mutex::new(Vec::new()),
});
// Ignore "already set": a previous call in this same test binary
// already won, and it is the same logger either way.
let _ = log::set_logger(logger);
log::set_max_level(log::LevelFilter::Debug);
logger
}
fn drain(logger: &CaptureLogger) -> Vec<(log::Level, String)> {
std::mem::take(&mut *logger.lines.lock().unwrap())
}
fn opened() -> (Harness, transcript_ui::TranscriptScreen) {
let mut h = Harness::new(phone_size(), PHONE_SCALE);
let opened = transcript_fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
h.frame(0);
h.frame(PHONE_FRAME_MS);
(h, opened.screen)
}
#[test]
fn tracing_is_silent_off_and_round_trips_the_flick_on() {
let logger = logger();
// --- (1) off: a real flick through a real screen leaves the ring
// with nothing at `Debug` or below.
iris::diagnostics::set_trace(false);
drain(logger); // whatever `opened()` itself logged while building
let (mut h, screen) = opened();
drain(logger); // and whatever opening logged
let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch"))
.unwrap_or_else(|e| panic!("flick-120hz.touch: {e}"));
h.replay(&flick);
let _ = (screen.list)(&mut h.rsc); // touch the screen the same way a real caller would
let quiet = drain(logger);
let debug_lines: Vec<_> = quiet
.iter()
.filter(|(level, _)| *level == log::Level::Debug)
.collect();
assert!(
debug_lines.is_empty(),
"tracing is off, but the ring would still have held these `debug!` lines: {debug_lines:#?}"
);
// --- (2) on: the same replay, from a fresh screen so the anchor and
// sequence numbers match `flick-120hz.touch` exactly again.
iris::diagnostics::set_trace(true);
let (mut h, screen) = opened();
drain(logger);
h.replay(&flick);
let _ = (screen.list)(&mut h.rsc);
let traced = drain(logger);
iris::diagnostics::set_trace(false); // leave it off for any test after this one
let input_lines: Vec<&str> = traced
.iter()
.filter(|(_, msg)| msg.contains("iris input: action="))
.map(|(_, msg)| msg.as_str())
.collect();
assert_eq!(
input_lines.len(),
flick.samples.len(),
"expected one `iris::input` line per replayed sample, got:\n{input_lines:#?}"
);
let frame_lines: Vec<&str> = traced
.iter()
.filter(|(_, msg)| msg.starts_with("iris frame:"))
.map(|(_, msg)| msg.as_str())
.collect();
assert!(
!frame_lines.is_empty(),
"expected at least one `iris::frame` line once tracing was on"
);
for line in &frame_lines {
// `layout=` and `draw=` are `{:?}`-formatted `Duration`s, so a real
// one reads like `12.34µs`/`1.2ms`, never the bare `0ns` a
// no-op frame would print.
assert!(
!line.contains("layout=0ns"),
"a frame that redrew should not report zero layout time: {line}"
);
}
// --- the round trip: pipe every `iris::input` line through
// `report_to_touch.py` and parse the result back into a `TouchScript`,
// which must equal the one that was replayed. `report_to_touch.py`
// is prefix-agnostic (it `search`es for the marker), so handing it
// the bare message is the same as handing it a real ring line.
let report = input_lines.join("\n");
let script_path = concat!(env!("CARGO_MANIFEST_DIR"), "/../benches/report_to_touch.py");
let mut child = Command::new("python3")
.arg(script_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("python3 must be on PATH to run report_to_touch.py");
{
use std::io::Write;
child
.stdin
.take()
.unwrap()
.write_all(report.as_bytes())
.unwrap();
}
let output = child.wait_with_output().expect("report_to_touch.py exited");
assert!(
output.status.success(),
"report_to_touch.py failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let touch_text = String::from_utf8(output.stdout).expect("report_to_touch.py wrote UTF-8");
let round_tripped =
TouchScript::parse(&touch_text).unwrap_or_else(|e| panic!("round-tripped script: {e}"));
assert_eq!(
round_tripped.samples.len(),
flick.samples.len(),
"round trip produced a different number of samples:\n{touch_text}"
);
for (original, back) in flick.samples.iter().zip(round_tripped.samples.iter()) {
assert_eq!(original.t_ms, back.t_ms);
assert_eq!(original.action, back.action);
assert_eq!(original.pos, back.pos);
}
}
+123 -15
View File
@@ -13,7 +13,7 @@ use iris::prelude::*;
use transcript_fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
/// The screen open on the fixture, framed twice: once to draw, once for
/// `List::repair_anchor` to resolve the opening `snap_end` into a real
/// `LazySpan::repair_anchor` to resolve the opening `snap_end` into a real
/// anchor, which is what every assertion about scroll position reads.
fn opened() -> (Harness, transcript_ui::TranscriptScreen) {
let mut h = Harness::new(phone_size(), PHONE_SCALE);
@@ -34,7 +34,7 @@ fn offset(h: &mut Harness, screen: &transcript_ui::TranscriptScreen) -> String {
/// (a) and (b) together, because the second is only meaningful if the
/// first happened: the recorded flick must release with a real velocity
/// (`GestureOutcome::Released(Some(v))`, which is the only thing that
/// puts a value in `List::fling_velocity`), and the list must then
/// puts a value in `Scroll::fling_velocity`), and the list must then
/// actually travel and stop on the spline's own schedule.
#[test]
fn a_recorded_flick_releases_with_a_velocity_and_flings_the_list() {
@@ -44,24 +44,61 @@ fn a_recorded_flick_releases_with_a_velocity_and_flings_the_list() {
let flick = script("flick-120hz", include_str!("../touch/flick-120hz.touch"));
h.replay(&flick);
let velocity = (screen.list)(&mut h.rsc)
let velocity = (screen.scroll)(&mut h.rsc)
.fling_velocity()
.expect("the flick must release as a pan with a velocity, not a tap");
// Compose's own answer for this recording's five samples, printed by
// `iris/benches/velocity_reference.py` -- not a number read off this
// code. **Positive** because the flick runs *down* the screen and a
// delta now carries the finger's own direction the whole way, from the
// gesture through `Selection::drag` (which passes it straight to
// `Scroll::fling`) to the anchor. It read -15250 while the transcript
// negated the velocity on its way into a `LazySpan` whose anchor
// offset ran the other way; the magnitude is the number that came from
// `velocity_reference.py` and it has not changed.
// The 2026-09-07 before/after: the old average estimator read
// 12250px/s here, which is the fling Iris reported as too slow.
assert!(
velocity.abs() > 1_000.0,
"a 188px, 16ms flick is thousands of px/s; got {velocity}"
(velocity - 15_250.0).abs() < 20.0,
"expected ~15250px/s from velocity_reference.py, got {velocity}"
);
// Android's own spline says how long a fling at this speed runs. The
// list learns its density from the painter, so this is the same
// curve it is using.
let expected = FlingCalculator::new(PHONE_SCALE).duration(velocity);
let end = flick.end_ms() + expected.as_millis() as u64 * 2;
// `iris/benches/fling_spline_reference.py`'s own line for this exact
// case -- `density=2.55 v=15250.0: distance=11057.424px
// duration=2.0716s`. **Not** `FlingCalculator::new(PHONE_SCALE)`,
// which is the calculator under test: bounding a fling with the thing
// being measured is the "compared the code with itself" shape 73f956f
// found in the spline's own tests, and it left this one able to fail
// in the "ran too long" direction only -- never in the "stopped dead"
// direction, which is what Iris actually reported
// (docs/REVIEW-2026-09-07.md's T1).
const REFERENCE_MS: u64 = 2071;
const REFERENCE_PX: f32 = 11057.0;
let end = flick.end_ms() + REFERENCE_MS * 2;
let mut settled_at = None;
let mut t = flick.end_ms();
// Travel in pixels, measured from a row's own on-screen extent, since
// `LazySpan` has no travel accessor and this needs none: follow whatever
// row is under the viewport's middle until it leaves, then pick
// another. Deliberately an *under*-count -- the frame a row leaves on
// contributes nothing -- which is why it is only ever a lower bound.
let middle = phone_size().y / 2.0;
let mut travelled = 0.0f32;
let mut tracked: Option<(RowKey, f32)> = None;
while t <= end {
h.frame(t);
if settled_at.is_none() && !(screen.list)(&mut h.rsc).is_scrolling() {
let list = (screen.list)(&mut h.rsc);
tracked =
match tracked.and_then(|(key, was)| list.extent(key).map(|(now, _)| (key, was, now))) {
Some((key, was, now)) => {
travelled += (now - was).abs();
Some((key, now))
}
None => list
.key_at(middle)
.and_then(|key| list.extent(key).map(|(top, _)| (key, top))),
};
if settled_at.is_none() && !(screen.scroll)(&mut h.rsc).is_scrolling() {
settled_at = Some(t);
}
t += PHONE_FRAME_MS;
@@ -74,10 +111,24 @@ fn a_recorded_flick_releases_with_a_velocity_and_flings_the_list() {
);
let settled_at = settled_at.expect("the fling must stop on its own, not run forever");
let ran_for = settled_at - flick.end_ms();
// Both directions. The lower bound is the one that fails when a fling
// settles on its first tick; the upper is the one that was here.
assert!(
ran_for <= expected.as_millis() as u64 + PHONE_FRAME_MS * 2,
"the fling ran {ran_for}ms against the spline's own {}ms",
expected.as_millis()
ran_for >= REFERENCE_MS - PHONE_FRAME_MS * 2,
"the fling stopped after {ran_for}ms against the spline reference's {REFERENCE_MS}ms"
);
assert!(
ran_for <= REFERENCE_MS + PHONE_FRAME_MS * 2,
"the fling ran {ran_for}ms against the spline reference's {REFERENCE_MS}ms"
);
// 80% of the reference, against 10527px measured today -- the 5%
// shortfall is the frames a tracked row leaves the screen on. A fling
// that moves one row's worth fails this; scaling `tick_fling`'s delta
// by 0.01 reports 111px, which is how it was confirmed to fail in the
// direction the bug goes.
assert!(
travelled >= REFERENCE_PX * 0.8,
"the fling travelled {travelled:.0}px against the spline reference's {REFERENCE_PX:.0}px"
);
}
@@ -91,7 +142,7 @@ fn a_tap_on_a_row_moves_nothing() {
h.replay(&script("tap", include_str!("../touch/tap.touch")));
assert_eq!(
(screen.list)(&mut h.rsc).fling_velocity(),
(screen.scroll)(&mut h.rsc).fling_velocity(),
None,
"a tap must not fling"
);
@@ -173,3 +224,60 @@ fn the_composer_sits_above_a_simulated_ime_inset() {
closed - open
);
}
/// A newline typed into the composer must leave the caret inside the
/// bar's own padding, not flush against its bottom edge.
///
/// Iris's phone, 2026-09-08: "when typing with the keyboard up and
/// entering enough newlines ... the text drops down close to the bottom
/// and seems to ignore the padding. If I close (and optionally reopen)
/// the keyboard it seems to fix itself." The cause was `Scroll::draw`
/// placing its child against *last* frame's content length and stopping
/// there: each newline drew the field in a box one line short of its
/// text, and since the text is centred in its box it hung half a line
/// past each end, putting the caret's line box a full padding below the
/// bar's inside edge. Nothing dirtied that subtree again, so the stale
/// placement was simply the last one drawn -- until the keyboard closed
/// and the inset rewrite forced a redraw, which is the "fixes itself"
/// half of the report. No settling frame here on purpose: the placement
/// is corrected within the frame that typed, so the first frame drawn
/// after a keystroke is already right.
#[test]
fn a_newline_leaves_the_caret_inside_the_composers_padding() {
let (mut h, screen) = opened();
let height = h.size().y;
let ime = 1000.0;
screen.composer.set_bottom_inset(&mut h.rsc, ime);
h.frame(PHONE_FRAME_MS * 2);
h.state.set_focus(Some(screen.composer.field));
screen.composer.field.edit(&mut h.rsc).set_cursor_byte(0);
// Past `composer::MAX_LINES`, so the bar is capped and scrolling
// rather than still growing -- the state the report is about.
for _ in 0..12 {
screen.composer.field.edit(&mut h.rsc).insert("a\n");
h.frame(PHONE_FRAME_MS);
}
// The caret is the last primitive `TextEdit::draw` emits.
let caret = {
let slot = *h
.render
.debug(h.rsc.widgets(), "Message")
.flat_map(|a| a.primitives.iter().map(|p| p.slot))
.collect::<Vec<_>>()
.last()
.expect("the focused field draws a caret");
h.render.primitive_corners(slot, &h.rsc)
};
// The bar sits directly on the IME, so its inside edge is one
// `FIELD_PAD_DP` above `height - ime`. Stated in pixels rather than
// read back from the composer, which is the thing under test.
let bar_bottom = height - ime;
let padding = 12.0 * PHONE_SCALE;
assert!(
caret.bot_right.y < bar_bottom - padding / 2.0,
"the caret is in the bar's bottom padding: it ends at {}, the bar's edge is {bar_bottom} \
and its padding is {padding}px",
caret.bot_right.y,
);
}
+335
View File
@@ -0,0 +1,335 @@
//! Layer 1 of docs/RUST.md's "Three test layers", for the transcript's
//! own edges: the real screen over the real fixture, under a header bar
//! like the bench app's, driven by `iris::harness`.
//!
//! What these are about is docs/IRIS_TODO.md's 2026-09-07 phone report --
//! rows scrolled above the viewport still drawn, over the header, and a
//! blank band where the row straddling the top edge should be. Both are
//! one rule (`LazySpan::intersects_viewport`): a row is drawn if any part of
//! it is inside the list's own box, and nothing outside that box reaches
//! the screen.
use iris::harness::Harness;
use iris::prelude::*;
use transcript_fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
/// A header band above the transcript, as `bench_client.rs` puts one --
/// the surface the rows were drawing over on the phone. Its exact height
/// does not matter; what matters is that the list's own box does not
/// start at the top of the window, so "above the viewport" and "off the
/// screen" are different places.
const HEADER_H: f32 = 300.0;
const HEADER: UiColor = UiColor::new(28, 28, 34, 255);
fn opened() -> (Harness, transcript_ui::TranscriptScreen) {
let mut h = Harness::new(phone_size(), PHONE_SCALE);
let (opened, tree) = transcript_fixture::build_screen(&mut h.rsc).expect("the fixture folds");
let content = WidgetPtr::new().add(&mut h.rsc);
content(&mut h.rsc).set(tree);
let root = (rect(HEADER).height(abs(HEADER_H)), content.height(rest(1)))
.span(Dir::DOWN)
.add_strong(&mut h.rsc)
.any();
h.state.set_root(root);
h.frame(0);
h.frame(PHONE_FRAME_MS);
(h, opened.screen)
}
/// The list's own on-screen box, in window pixels.
fn list_box(h: &Harness, screen: &transcript_ui::TranscriptScreen) -> PixelRegion {
h.render
.window_region(&screen.list.id(), &h.rsc)
.expect("the list is on screen")
}
/// Every row the list drew this frame, as `(top, bottom)` window pixels,
/// topmost first. A `LazySpan`'s direct children are exactly its rows, and
/// `draw_inner`'s old-children diffing means a row it did not place this
/// frame is not among them.
fn drawn_rows(h: &Harness, screen: &transcript_ui::TranscriptScreen) -> Vec<(f32, f32)> {
let mut rows: Vec<(f32, f32)> = h
.render
.active
.get(&screen.list.id())
.expect("the list is drawn")
.children
.iter()
.filter_map(|id| h.render.window_region(id, &h.rsc))
.map(|px| (px.top_left.y, px.bot_right.y))
.collect();
rows.sort_by(|a, b| a.0.total_cmp(&b.0));
rows
}
/// Scrolls `amount` and runs the frame it asks for, returning the time of
/// the next one. **Positive walks back through older rows** -- the
/// finger's own direction, and `Scroll::scroll`'s, which is the one
/// convention a delta has anywhere in iris since the transcript's scroll
/// position moved out of the `LazySpan` and into the `Scroll` around it.
/// It used to be the opposite here, because a `LazySpan`'s anchor offset
/// ran the other way.
fn scrolled(h: &mut Harness, screen: &transcript_ui::TranscriptScreen, amount: f32, t: u64) -> u64 {
(screen.scroll)(&mut h.rsc).scroll(amount);
h.frame(t);
t + PHONE_FRAME_MS
}
/// (a) of docs/IRIS_TODO.md's reproduction: with a row across the top
/// edge, that row is placed -- the viewport's first pixel belongs to
/// something. A rule that culled a row once its *top* left the viewport
/// would leave a blank band here, which is the second of Iris's two
/// screenshots.
#[test]
fn the_row_across_the_top_edge_is_drawn() {
let (mut h, screen) = opened();
let top = list_box(&h, &screen).top_left.y;
let mut t = PHONE_FRAME_MS * 2;
// 40px a frame, the shape a finger pan arrives in, through a straddle
// and out the other side of it many times over.
for _ in 0..60 {
t = scrolled(&mut h, &screen, 40.0, t);
let rows = drawn_rows(&h, &screen);
let first = *rows.first().expect("something is on screen");
assert!(
first.0 <= top + 0.5,
"a band of {:.1}px under the header belongs to no row: rows start at {:.1}, the list \
at {top:.1}",
first.0 - top,
first.0,
);
assert!(
first.1 > top,
"the row across the top edge was culled: it ends at {:.1}, above the list's own \
{top:.1}",
first.1,
);
}
}
/// (b): what falls outside the list's box is clipped rather than drawn
/// over whatever is there. The straddling row above is drawn *in full*,
/// so the only thing between its earlier lines and the header bar is this
/// mask -- with none, the phone drew `version = "0.1.0"` behind the "Run
/// benchmark" button.
#[test]
fn the_list_is_clipped_to_its_own_box() {
let (h, screen) = opened();
let active = h.render.active.get(&screen.list.id()).expect("drawn");
assert!(
active.mask != MaskIdx::NONE,
"the transcript's list is drawn with nothing clipping it",
);
let clip = h.render.mask_region(active.mask, &h.rsc);
let list = list_box(&h, &screen);
assert!(
clip.top_left.y >= list.top_left.y - 0.5 && clip.bot_right.y <= list.bot_right.y + 0.5,
"the clip {clip:?} reaches outside the list's own box {list:?}, so a row straddling an \
edge still draws past it",
);
// And the mask has to *reach* what the rows draw. The two above say a
// mask exists and sits in the right place; neither says any primitive
// references it, so a broken `Mask::parent` chain -- what d507ae4
// introduced -- would leave them green while a code fence inside a row
// drew unclipped again (docs/REVIEW-2026-09-07.md's T3).
let rows = h
.render
.active
.get(&screen.list.id())
.expect("the list is drawn")
.children
.clone();
let mut checked = 0;
for row in rows {
for prim in primitives_under(&h, row) {
assert!(
mask_chain(&h, prim).contains(&active.mask),
"a primitive of row {row:?} clips to {:?}, a chain that never reaches the list's \
own mask {:?}",
mask_chain(&h, prim),
active.mask,
);
checked += 1;
}
}
assert!(
checked > 0,
"no row primitive was checked, so this test asserted nothing",
);
}
/// Every primitive `id` and its descendants drew, as `MaskIdx`es -- images
/// excluded, since they live in a separate instance array with their own
/// indices (`Primitives::free`).
fn primitives_under(h: &Harness, id: WidgetId) -> Vec<MaskIdx> {
let Some(active) = h.render.active.get(&id) else {
return Vec::new();
};
let mut out: Vec<MaskIdx> = active
.primitives
.iter()
.filter(|p| p.binding != IMAGE_BINDING)
.map(|p| h.render.primitives.instance(p.slot).mask_idx)
.collect();
for child in &active.children {
out.extend(primitives_under(h, *child));
}
out
}
/// The chain the fragment stage walks from `mask`, outermost last.
fn mask_chain(h: &Harness, mask: MaskIdx) -> Vec<MaskIdx> {
let mut chain = Vec::new();
let mut at = mask;
while at != MaskIdx::NONE {
assert!(
!chain.contains(&at),
"the mask chain from {mask:?} loops back to {at:?}",
);
chain.push(at);
at = h.rsc.ui.masks[at.idx()].parent;
}
chain
}
/// A row that has left the viewport entirely is not drawn at all. Before
/// the fix the walk ran from the anchor -- which `scroll` leaves wherever
/// it was, however far outside the viewport that ends up -- and drew
/// every row on the way: 8 scrolls of 3000px left **64 rows** placed for
/// a 2012px viewport, ~59 of them off screen and painting over the
/// header.
///
/// The box is asserted on every leg *except the first*, because a row
/// whose height has never been measured has to be drawn to be measured
/// (`LazySpan::place`'s doc), which on the first walk back is every row
/// entering from the top. Every later leg crosses the same rows with
/// every height already known -- including the second walk *back*, which
/// is there because a regression that draws rows in the wrong place while
/// travelling backwards would otherwise be checked only by the row count
/// (docs/REVIEW-2026-09-07.md's T2). That is also the ordinary state of a
/// transcript being panned around in. The bound on how many rows are
/// placed at once holds on all three.
#[test]
fn rows_that_have_left_the_viewport_are_not_drawn() {
let (mut h, screen) = opened();
let list = list_box(&h, &screen);
let mut t = PHONE_FRAME_MS * 2;
let bounded = |rows: &[(f32, f32)], leg: &str, step: usize| {
// A handful of rows whatever distance has been travelled -- the
// module doc's own claim about this widget.
assert!(
rows.len() <= 24,
"{leg} {step}: {} rows drawn for one 2012px viewport",
rows.len(),
);
};
let inside = |rows: &[(f32, f32)], leg: &str, step: usize| {
for &(top, bottom) in rows {
assert!(
bottom > list.top_left.y - 0.5 && top < list.bot_right.y + 0.5,
"{leg} {step}: a row at ({top:.1}, {bottom:.1}) is outside the list's box \
{list:?} and was drawn anyway",
);
}
};
for step in 0..40 {
t = scrolled(&mut h, &screen, 400.0, t);
bounded(&drawn_rows(&h, &screen), "measuring", step);
}
for step in 0..40 {
t = scrolled(&mut h, &screen, -400.0, t);
let rows = drawn_rows(&h, &screen);
bounded(&rows, "forward", step);
inside(&rows, "forward", step);
}
for step in 0..40 {
t = scrolled(&mut h, &screen, 400.0, t);
let rows = drawn_rows(&h, &screen);
bounded(&rows, "back", step);
inside(&rows, "back", step);
}
}
/// The end the fix had no reason to touch: the row across the *bottom*
/// edge, where the composer starts. Same rule, other direction -- and the
/// list opens pinned there, so this is the ordinary state of the screen
/// rather than a scrolled-to one.
#[test]
fn the_row_across_the_bottom_edge_is_drawn() {
let (mut h, screen) = opened();
let list = list_box(&h, &screen);
let mut t = PHONE_FRAME_MS * 2;
for _ in 0..40 {
t = scrolled(&mut h, &screen, 37.0, t);
let rows = drawn_rows(&h, &screen);
let last = *rows.last().expect("something is on screen");
assert!(
last.1 >= list.bot_right.y - 0.5,
"a band of {:.1}px above the composer belongs to no row",
list.bot_right.y - last.1,
);
assert!(
last.0 < list.bot_right.y,
"the row across the bottom edge was culled: it starts at {:.1}, below the list's own \
{:.1}",
last.0,
list.bot_right.y,
);
}
}
/// Panning past the first row settles *on* it rather than beyond it. The
/// list is scrolled far further back than the fixture is long, which is
/// what a hard fling toward the top does; before the clamp existed it
/// stayed wherever that left it -- the phone's "black from the header
/// down", and a whole blank screen in `iris`'s own
/// `fling_toward_the_start_stops_at_the_first_row`.
#[test]
fn scrolling_past_the_first_row_settles_on_it() {
let (mut h, screen) = opened();
let list = list_box(&h, &screen);
let mut t = PHONE_FRAME_MS * 2;
for _ in 0..60 {
t = scrolled(&mut h, &screen, 100_000.0, t);
}
// No settling frame on purpose: the draw that discovers the gap gives
// it back inside that same frame (`LazySpan::overscroll_gap`), so the last
// frame `scrolled` drew is already flush with the first row. Adding
// one here would hide a regression to the old next-frame correction.
let rows = drawn_rows(&h, &screen);
let first = *rows.first().expect("the first row is on screen");
assert!(
(first.0 - list.top_left.y).abs() < 0.5,
"the transcript is parked {:.1}px past its own first row, so the top of the list is blank",
first.0 - list.top_left.y,
);
}
/// The same clamp at the other end, which is where Iris met it second
/// ("you shouldn't be able to scroll below the bottom (or above top)").
/// The list opens flush with its newest row, so this drags *forward* off
/// the end of the content and back.
#[test]
fn scrolling_past_the_last_row_settles_on_it() {
let (mut h, screen) = opened();
let list = list_box(&h, &screen);
let mut t = PHONE_FRAME_MS * 2;
for _ in 0..20 {
t = scrolled(&mut h, &screen, -100_000.0, t);
}
let rows = drawn_rows(&h, &screen);
let last = *rows.last().expect("the last row is on screen");
assert!(
(last.1 - list.bot_right.y).abs() < 0.5,
"the transcript is parked {:.1}px past its own last row, so the bottom of the list is \
blank",
list.bot_right.y - last.1,
);
}
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff. Show more