6d5a231f5caa1d24bb6667d162711de2a64bf553
51
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e9a6562dc6 |
iris: masking is opt in, and a LazySpan only culls
Iris, correcting the previous commit: "Why does the mask matter at all. If you want a mask then you add .masked(). It should just prevent rows that aren't in its region at all from drawing ... Just like the opt in scrollable, masking should be opt in." So `LazySpan` sets no mask. It culls -- a row entirely outside the box it was offered is never drawn, which `intersects_viewport` already did -- and draws a straddling row in full, because virtualisation decides which rows and never how much of one. Cutting off that overhang is `.masked()`, added by whoever wants it. The transcript wants it (it is a list under a header bar) and opts back in; the benchmark does not and needs no ceremony. `top_edge.rs` goes back to reading the mask the list *inherited*, which is now also the test that the transcript is still asking for one. The previous commit had the span mask itself, which fixes the panic and is still the widget deciding what is not its to decide. |
||
|
|
afbc2ad132 |
Cap what the transcript draws, and let a LazySpan clip itself
Four things Iris asked for on 2026-09-08.
**A LazySpan no longer cares about masks.** It asserted that something
around it had called `.masked()` and refused to draw otherwise, which is
why a plain full-screen list -- the benchmark, any simple app -- panicked.
It cared only because it draws a row straddling an edge in full and relied
on somebody else to cut off the overhang; it clips itself to the box it
was offered now. Strictly stronger than the assert, which a mask *larger*
than the list's box satisfied while letting the overhang through anyway --
the fault it was written for. The transcript's `.masked()` wrapper goes
with it, and `Painter::is_masked` with that.
**Everything on the transcript screen is capped.** One rule in one place,
`client_core::text_cap`, mirrored as `TextCap.kt` with the same numbers so
a bench comparing the apps compares renderers rather than policies:
a tool call's input 80 lines or 4 KiB -> "Show all N lines"
a tool call's output 80 lines or 4 KiB -> (already was, in iris)
a message 200 lines or 16 KiB -> "Show all N lines"
The input is what the edit-card report needed: an Edit's old_string and
new_string arrive whole and are routinely the biggest text on screen.
Messages are capped in both apps, user and agent alike.
Three rules that took a screenshot to get right. A message is cut on a
block boundary, never mid-block -- cut to its own opening line a fence
renders as an empty panel, which reads as a fault rather than as a cap --
except a message that is one enormous block, which is truncated, since
dropping it would leave the row blank. A reply still streaming is never
capped. And the input's two blocks share one "Show all", while input and
output have their own.
**Compose stops wrapping raw text**, per Iris's call: a tool's leftover
input fields and its output pan sideways like the command already did.
`on_tap` and hold-the-edge move to `transcript-ui/src/tap.rs`, since a
message's "Show all" needs exactly what a tool card's tap already had.
|
||
|
|
1318e149f5 |
iris: redrawing one widget cost O(its own primitives squared)
Iris's report was that expanding a tool card holding a long,
horizontally-scrolling edit lags on her phone. The cause is not text
layout: shaping and rasterising a 51,200-glyph block is 20ms, and the
frame that drew it took 1.37 seconds.
A widget redrawn in place frees every primitive it owned and writes
fresh ones. Freeing compacts each layer's draw order with swap_remove,
so ~N primitives are renumbered, and finding the handle to renumber was
a linear scan of everything that widget drew -- O(N^2) in the widget's
own primitive count. A paragraph never notices; one text widget holding
a whole old_string and new_string is every glyph in the card.
The arena now records, per slot, where that slot's handle sits in its
owner's ActiveData::primitives, written at the one place a handle is
taken (Painter::own), and apply_free indexes straight to it.
50,000 glyphs, redrawn: before 636ms after 2.4ms
per glyph: before 12.7us after 0.043us, flat in N
benches/message_list.rs gains scenario (g) for it, reporting per-glyph
because flat is the pass condition and a total hides it. That file had
also stopped running entirely: scenarios (a) and (e) built a LazySpan
with no mask around it, which the span now asserts against, so the
benchmark panicked on its second line. Fixed here too.
Also, on Iris's instruction: the copied report no longer inlines a tail
of the app log. Dev Updater's Runtime tab reads the same ring through
devlog's provider, so it was the same lines twice; the diagnostics pane
still names the provider's authority to read them from.
|
||
|
|
4fdabc39d0 |
iris: one ScrollController, a Scrollable trait, and Pin
Iris's three points on docs/SCROLL.md, in the shape she proposed: a controller both scrolling widgets *contain*, rather than a protocol between them. "I don't like adding methods to widget, it seems like we can structure things better instead." `Scroll` becomes `ScrollArea`, because it only scrolls a predefined area. `ScrollController` holds everything that is not a particular widget's layout -- the position, the pending delta, the travel left each way, the pin, the DragGesture and the Flinger -- and `Scrollable` is the trait over it, one required pair of methods with the rest defaulted. `Widget` loses `scrolls_itself`, `apply_scroll` and `scroll_offset`. They existed only so a `Scroll` could drive a `LazySpan` it had no business wrapping; the span owns its own controller now, so the wrapper, the measure/apply/place dance between two widgets and `amt`'s two meanings all go with them. The transcript's tree loses a node: `list` is the layout and the position. `.scrollable(axis, pin)` replaces `scrollable`/`scrollable_on`/ `scrollable_to_end` -- one mechanism whose arguments had been hidden in three names. `LazySpan` has an inherent `scrollable()` that shadows it, since Rust resolves inherent methods before trait ones: the same word at the call site, and the wrapping version cannot reach the one widget that must not be wrapped. `Pin` says which end either way round: `Start`/`End` are content-relative and `Neg`/`Pos` axis-absolute, so a caller can say "the bottom" and mean it whichever way the content runs. They differ only for a reversed span, which is the whole reason both exist. One behaviour changes: a delta is applied by the next draw rather than where it arrives, since the layout is the only thing that knows where the content ends. Nothing on screen differs -- input is followed by a frame -- but `amt` no longer moves between draws, which several tests were reading. This also closes SCROLL.md's open question about the pin living in two places. Verified: cargo test --workspace (all green, including the layer-1 transcript-fixture fling/selection/top-edge tests), clippy --all-targets clean, fmt clean, `cargo ndk` check of android-app, and `run-headless.sh phone --phone --replay flick-120hz.touch`, whose before/after screenshots show the recorded flick carrying the transcript back from turn 270 to turn 258 on the Vulkan adapter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
84a13e806b |
iris: a fling starts at Compose's velocity, which is a curve fit and not an average
Iris, from the phone on the
|
||
|
|
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> |
||
|
|
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. |
||
|
|
4274b8b8d0 |
Merge remote-tracking branch 'origin/rustify' into worktree-agent-ace98b0bdaf33ffff
# Conflicts: # docs/IRIS.md # docs/RUST.md |
||
|
|
73f956f8e0 |
iris: the fling curve was the identity function, and the keyboard was a targetSdk
Iris's 2026-09-07 phone report on
|
||
|
|
1121d7cc83 |
docs/LAYOUT.md: masks reference a drawn primitive instead of copying a shape, and hit-testing applies the shape (Iris, 2026-09-07)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
ba0f2ea93f |
docs: the 22:16 report reconciled with what was actually run
RUST.md's "Shell lost" section and IRIS_TODO.md's matching paragraph both
said item 4's fix was written but never built or tested. It was committed
in
|
||
|
|
7e7cbb5402 |
Tool-call cards and grouping, with the state a result never arrived in
P1b (docs/RUST.md). `transcript-ui/src/tool.rs` draws a card per tool call and a group per run: collapsed, a card is its name and the one-line summary `parse_tool_input` derives; open, it is the description, the input (highlighted, on the verbatim surface) and the output, capped with a "Show all N lines". A run is one surface with a heading and a chevron bar at its foot, so it closes from either end. Three things worth knowing. **A collapsed card lays out its summary line and nothing else.** The fixture's tool outputs are tens of kilobytes and a collapsed card never builds a widget for one -- `collapsed_cards_shape_only_their_summary_ lines` opens a three-card group over 88 kB of output each and asserts the text-shape count equals the same group's over three bytes (17 either way; 17 against 20 when the discipline is deliberately broken, so the test is real). **A result arriving replaces one card.** `ToolRow::apply_calls` is the group's half of `RowBlocks::apply_delta`'s rule, and `build_row` now hands back one `TailRow` -- blocks for a message, cards for a run -- rather than two mechanisms chosen at each call site. **Every tap is a tap**: `GestureOutcome::Tapped` out of the `DragArbiter` `Selection` already owns, so a drag that started on a card scrolls the transcript instead of opening it. Three defects found by looking at the render, all recorded with their repro in docs/IRIS_TODO.md: a `Span` of padded children inside another `Span` places them a slot out of step (worked around by building the group as one span, which costs the 4dp inset); `scrollable_on(Axis::X)` on a non-editable text draws nothing, so a card's command is clipped rather than pannable; and `NotoSans-Regular` has no U+25B8/25BE/25B4 at all, so the expander mark is set in the monospace face. Screenshots: docs/bench/p1b-2026-09-06/. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
69525bd131 |
iris: a Rect is not size-independent, and P1a's block appearance verified
The defect P1a's screenshots found, and the one that mattered: `Rect::is_size_independent()` answered `true`. A `Rect` fills whatever region it is handed, so its content *is* the region -- and `draw_inner`'s fast path, which rewrites a widget's primitives with `r.outside(&from).within(®ion)` instead of redrawing it, cannot reproduce that once a region carries both `rel` and `abs`. What it looked like: a fenced code block's background kept the height of the provisional full-region draw `Span` does in its first phase, so one fence's panel covered every block below it and every row below that, with the text underneath laid out correctly. Likely the same cause as RUST.md's older "the composer bar's grey background is not drawn". Also here: a quote's bar is a `Stack` background behind padded text rather than a two-child `Span(Dir::RIGHT)` (one widget fewer and no provisional pass), and `transcript-ui`'s `transcript` example gains a row holding one of every block kind -- the fixture's own heading, paragraph, fence and table source, plus a list and a quote, which the fixture has neither of. docs/bench/p1a-2026-09-06/ has the pairs and docs/RUST.md's P1a box names what still differs. The iris half is from the desktop backend because this emulator cannot draw iris's glyphs at all (solid boxes, reproduced on the previous commit, with Compose drawing text correctly on the same AVD); both routes to Vulkan on this AVD were tried and both fail. Bench stream phase, assertions live, no abort: p50 53.0ms p90 108.6ms p99 132.0ms against 52.8/108.1/137.3 before -- unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
20303e0b4c |
IRIS.md: take_counters gained a fourth number, text shapes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
e1030d69f6 |
iris: a transcript row is a column of markdown blocks, so a streamed delta costs one block
A row was one TextEdit holding the whole message, so every delta re-shaped every paragraph of a long reply through parley -- the one phase where iris trails Compose on the phone (p50 18.2ms vs 13.4ms, bench v2). - client-core/src/markdown_blocks.rs: split a message into its top-level blocks with their source, through the same pulldown-cmark the renderer parses with so the two cannot disagree about where a block starts, plus common_prefix. Appending markdown can rewrite an earlier block (a trailing --- turns the paragraph above into a heading), so the fast path compares the prefix it keeps rather than assuming it -- with the test that says so. - transcript-ui: a row is a Span of one TextEdit per block; RowBlocks::apply_delta replaces the block a delta lands in; TranscriptScreen keeps the tail row's blocks, seeded in build_tree as well as push_row (a screen opened onto a streaming reply took the rebuild path for its first delta otherwise, with nothing to say so). - A block is the selection unit: Selection is keyed by (RowKey, u32), which is reading order at both levels, and the pointer-captured half of a drag resolves the block under the finger from its drawn box (Selection::locate) instead of from the row's extent. Pass condition: a_delta_into_a_long_reply_redraws_the_same_widgets_as_a_short_one drives a real UiRenderState and asserts the draw count for a delta into a 100-paragraph (3,000+ char) reply equals the count for a one-paragraph one. 30 either way; it read 630 against 30 twice on the way there. Emulator stream phase, same AVD before and after: p50 61.5 -> 54.5ms, p90 211.7 -> 113.1ms, p99 342.6 -> 137.4ms, worst 403.6 -> 143.0ms, 202 -> 293 frames in the same 21 seconds. Selection across blocks verified with a real long-press drag. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
167862ca1b |
iris: the composer scrolls on a finger -- a dp cap worth zero, a stale mask slot, a hit box moved twice
Wrapping the composer's field in .scrollable().masked() needed three layout defects fixed first, each with a headless regression test that was confirmed to fail without its fix: - MaxSize/Sized reported a caller's declared dp length unresolved, and Span places a child from the abs/rel of what it reported, so dp(168) was worth zero: the bar got a slot of nothing the moment its content passed six lines and the Scroll inside measured its container at -63px (container=-63 content=415.8 amt=478.8 on the emulator). Len::fold_dp, used on the way out, plus a debug_assert in draw_inner that a reported Size carries no dp -- the rule is about every widget, not those two. - Masked allocated a fresh mask slot per draw, and draw_inner's unchanged-region fast path does not revisit descendants, so they kept clipping against a box the bar had moved away from: four live mask entries, none of them current, and the field drew nothing. ActiveData::own_mask, allocated once and rewritten in place. - mov updates active.region and accumulates the same delta on the move slot, and resolved_region added both, so a panned widget's own hit box sat at twice the pan -- the composer's field was untappable after a drag. ActiveData::move_applied. Scroll itself measured the right number by a misleading route; it is written against painter.px_size() now and still reports its content's size, since reporting the container makes the answer a function of itself. Verified on this checkout's emulator: swipe 540 1200 -> 540 1460 moved the field's Message box 31,1041..1048,1509 -> 31,1131..1048,1651 with its height unchanged at 468px. run-bench.sh polled logcat for a prefix copy_report also logs at startup, so it printed a report that had never been run. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
fb6b459c2c |
iris: Scroll pans on a finger drag; a vertical drag in a focused field scrolls rather than selects
IRIS_TODO.md's "the composer has no touch-drag scroll". `Scroll::drag`
takes its pan from the same `sense::DragGesture` `List` is driven by --
arbitration, DRAG_SLOP, velocity and pointer capture all stay in sense.rs
and only what a committed pan *means* is decided per caller -- and
`WidgetLike::scrollable()` registers it beside the wheel handler it already
registered, so every scroll area pans on a finger with nothing added at the
call site. No fling: `Scroll` has no per-frame tick to animate one and the
areas it wraps are at most a screenful. `Scroll::amt()` exposes the pan
position.
`attr.rs`'s `on_press` treated an already-focused field as the plain
click_or_drag case, so every Pressing frame extended a selection. It now
applies the same DRAG_SLOP rule its unfocused branch already did: a press
past the slop vertically abandons its pending selection for the rest of the
gesture, so the scroll area around the field wins it. That is Android
EditText's own behaviour and it is what lets a swipe up over the composer
scroll instead of dragging a highlight through what you typed.
Also fixed, found doing it: `ActiveData::mask` stored the mask a widget
*set* rather than the one it was drawn *under*, and `redraw` feeds that
field back in as the inherited mask -- so a targeted redraw of any `Masked`
handed it its own mask and aborted on `set_mask`'s nested-mask assert. A
real abort on the emulator, `assertion failed: self.mask == MaskIdx::NONE`.
And the per-frame orphan guard from
|
||
|
|
3e72a4ef19 | docs: the defect pass's findings -- RUST.md boxes, IRIS_TODO ticks, DECISIONS and IRIS entries | ||
|
|
1f379e8384 |
docs/REVIEW-2026-09-06.md: fix all ten review findings; RUST.md/IRIS_TODO.md: DragGesture merge checks
Finding 1 (the real crash): Selection::clear() drops rows and anchor, called from TranscriptScreen::apply's Rebuild arm right before List::clear() -- push_row re-registers survivors as it rebuilds each row. Fixes a WeakWidget outliving the row group_tool_runs regrouped away, which panicked the next long-press anywhere. New apply_tests test builds a real TranscriptScreen, forces the regroup, and confirms no panic. Findings 2-5: debug_assert!s on List::place's slot, List::fling and FlingCalculator's velocity finiteness, VelocityTracker::add_sample's chronological order, and FrameReport::mark_phase's non-decreasing start_index. Finding 7: bench_client.rs's battery_line guard restructured so the empty check can't be separated from its unwraps by a future edit. Findings 9/10: new List tests pinning tick_fling's per-tick deceleration and replace_back's evicted-key cleanup with a different key than the existing tests use. IRIS.md's replace_back/clear/apply entry gained the side-table-clearing note the Docs finding asked for. Also records this pass's DragGesture-merge verification in RUST.md (tap stays vs swipe doesn't, a real fling keeps moving after release, keyboard cycles confirmed via on_insets_changed) and annotates the two IRIS_TODO.md phone-report items it targets. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
27ca5b2349 | Merge remote-tracking branch 'origin/rustify' into worktree-agent-a9002910a315fe719 | ||
|
|
20b12255e1 |
iris/android: composing text sync, tap-vs-swipe focus, composer rebuild, atlas reset on app-switch
Four fixes from Iris's phone report on the
|
||
|
|
03c6be80a3 |
iris android-app: header-duplicate investigation, ime-inset fix for keyboard confirmation
Two follow-ups after the keyboard/dp/header pass, both requested against the P0 box: (a) The header row rendering a second time inside the transcript area after a keyboard-triggered resize: reproduced reliably (tap the composer, screenshot after the keyboard opens). Ruled out one concrete hypothesis -- on_insets_changed rebuilding top_bar on every ime_bottom change, unrelated to the header's own status-bar padding -- with a guard (last_top_pad) that reproduced the identical duplicate afterward, so repeated rebuilding is not the cause. Kept the guard as a real (if insufficient) fix for needless rebuilds. Not root-caused: Span's two-phase provisional/real draw and the redraw_all-vs-redraw_updates split are the two live suspects, but pinning which one (or something else) produces the duplicate needs instrumenting draw_inner directly or the phone. Full writeup in RUST.md's P0 box. (b) Why on_insets_changed's ime_bottom never confirmed the keyboard being shown, on either the auto-diagnostics or the new bench keyboard phase: MainActivity.java uses windowSoftInputMode="adjustResize", under which WindowInsets.Type.ime()'s own inset amount is defined to read zero (the window already resized to avoid the overlap that inset would describe) -- the same trap AGENTS.md already names for the Compose side. Fixed to read insets.isVisible(ime()) instead, a boolean unaffected by resize-vs-pan. This alone did not make the callback re-fire on this emulator, which still shows no insets callback after the initial one at attach -- named but unconfirmed hypothesis: a non-edge-to-edge Activity may not get insets redelivered for a pure IME toggle handled via resize, needing an edge-to- edge opt-in this pass did not attempt given the risk to adjustResize's own behavior. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
c589a75fa0 |
Merge remote-tracking branch 'origin/rustify' into worktree-agent-a1ff0294b6c29127e
# Conflicts: # docs/RUST.md |
||
|
|
80c2eadec9 |
docs: record the keyboard-wipe fix, the dp unit and the header fix
docs/IRIS.md's 2026-09-06 entry (public API), docs/LAYOUT.md's "Density: Len::dp" design section, IRIS_TODO.md's density-unit item ticked, and docs/RUST.md's P0 box gets the investigation: the keyboard-wipe hypothesis and confirmation, the blur root cause and why the dp unit turned out to be the same fix, the header cause, and what remains unverified (an emulator screenshot of the keyboard fix, and Iris's real phone). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
f06ee259b4 |
iris: List::fling with Android's spline physics, and fix the drag-slop scroll jitter
Adds VelocityTracker and a port of AOSP SplineOverScroller's fling curve (FlingCalculator, cited at the definition) to iris::sense, and wires List::fling/is_scrolling/cancel_fling/tick_fling through Selection::drag's release path -- a pan's release now decelerates instead of stopping dead on the finger lifting, matching IRIS_TODO.md's "swiping has no momentum" ask. Clamped at the loaded content's start/end and cancelled by the next touch-down. Also fixes the scroll jitter DragArbiter's slop release caused: crossing DRAG_SLOP applied the whole pre-threshold drag (measured from press_start) in one step, since nothing pans while a gesture might still resolve to a selection. Now only the excess past DRAG_SLOP is applied on that frame, the same way Android's own touch handling consumes touch slop rather than replaying it. Root-caused by reading DragArbiter's state machine and covered by new unit tests (fling distance against the closed-form spline result within 1%, cancel-on-touch, start/end clamp, the slop-crossing regression); no emulator was used this pass, so an on-device trace/feel-check is still open, and Benchmark v2's four-phase bench_client.rs spec was not attempted. docs/IRIS.md, docs/IRIS_TODO.md and docs/RUST.md's P0 box record what's done and what's left. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
560a74caf8 |
docs: record the phone-report fixes, follow-ups and the bundled-font API
RUST.md's P0 box gets Iris's first real-phone report (no crash) and the four defects it found (glyph-wipe-on-first-touch, missing bold glyphs, text far too small, status-bar inset not applied), what was fixed and how it was verified on the emulator, and what's still open (item 1's root cause, and the top-row height anomaly noted in the last commit). IRIS_TODO.md gets a new "From the phone, 2026-09-06" section for the two items explicitly deferred to a follow-up agent: no scroll momentum/fling, and occasional jitter scrolling down. IRIS.md gets the public-API entry for TextData's bundled fonts/ font_diagnostics, UiRenderNode::new/resize's new window_size parameter, AndroidUiState::content_scale, AndroidAppState::on_insets_changed, and iris_core::WgpuErrorLog. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
46246ea511 |
iris: turn the phone bind-group-layout crash into a diagnostic, drop force-gles from phone builds
UiRenderNode::new used to let a wgpu validation error reach the default
uncaptured-error handler and panic, which is what aborted the P0 bench APK
on Iris's phone in AndroidRenderer::new with only "wgpu error: Validation
Error" surviving into the truncated crash report. It now wraps creation in
wgpu error scopes and returns Result<Self, String>; the Android backend
turns a failure into the adapter's identity, the limits/downlevel flags a
layout validates against, and wgpu's own error chain, logged as one logcat
line and shown on screen (IrisView.showRendererError) instead of crashing.
Auditing every bind-group-layout entry against wgpu-core's own validation
source names the likely cause: masks_layout's move_offsets storage buffer
is visible to the vertex stage, which Vulkan grants unconditionally but
GLES gates on the driver's own vertex-stage SSBO support -- and the
delivered APK was built with force-gles, a flag meant only to force the
*emulator* onto GLES for one frame-time measurement, that build-apk.sh's
default feature list applied to every arm64 build regardless of target.
Its default no longer includes force-gles.
Testing the diagnostic (by inducing an artificial validation error) also
found and fixed a real reentrancy bug: calling Activity.setContentView
synchronously from inside a ViewPeer callback re-enters the same peer's
RefCell borrow through onFocusChanged, aborting with "RefCell already
borrowed". Deferred through the same push_dynamic_deferred_callback
mechanism raise_if_enabled already uses.
Full audit, verification, and the named hypothesis are in RUST.md's P0
box ("iris bench crash on the phone, 2026-09-06"); the API change is in
IRIS.md. Nobody on this session has the phone, so this is unconfirmed
against real hardware -- the point of (1) is that the next run says so
either way.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
||
|
|
46d3a6fd41 |
docs: record the streaming-rebuild fix, its numbers, and the new scripts
RUST.md's P0 box gets the fix, the before/after streaming-phase numbers (with their caveats), the build-apk.sh/run-bench.sh scripts, and what the dropout-fix pass's three remaining verifications are blocked on (the sandbox ai-server currently fails to build, unrelated to this change). IRIS.md gets the List::replace_back/clear and TranscriptScreen::apply API entries. AGENTS.md's rigs section gets one sentence on each script. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
800da46188 |
Merge remote-tracking branch 'origin/rustify' into worktree-agent-a27094a7db775552a
# Conflicts: # docs/IRIS.md |
||
|
|
00767eed4d |
docs: P0's iris half done -- bench feature, emulator smoke run, APK
RUST.md's P0 box gets the iris-half account: the fixture, the scroll/stream mechanism, the report fields, build commands (all clean), packaging (no cargo xtask apk yet, so a new Gradle release build type on top of cargo ndk), and the emulator smoke run's report next to Compose's own. Used a second, differently-named AVD rather than contend with the session already on this checkout's own emulator. DECISIONS.md's P0 entry gets a matching summary bullet. IRIS.md records AndroidAppState::platform_ready. IRIS_TODO.md notes the one gap found: no read-only selectable text primitive, so the bench report's TextEdit picks up a keyboard on tap it has nothing to type into. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
d01c105037 |
iris: stop requesting compute-shader limits nothing uses
adapter.request_device asked for Limits::default(), which requests desktop-tier compute-shader limits unconditionally even though nothing in iris/iris-core creates a ComputePipeline or writes a @compute stage. That crashed device creation outright on a downlevel GL adapter reporting OpenGL ES 3.0 (no compute at all) -- the Android emulator's EMU_GPU=software/force-gles path, and any real GLES-3.0-only device. New iris_core::device_limits(), shared by both platform backends, zeros exactly the six max_compute_* fields rather than switching to a downlevel Limits preset -- downlevel_webgl2_defaults() also zeros max_storage_buffers_per_shader_stage, which shader.wgsl's vertex stage needs. rigs/gpu-probe's own mirrored limits were updated to match. Not verified against the actual SwiftShader-ES-3.0 crash on-device this pass: the cold boot needed would have force-restarted this checkout's emulator while another session had its own app running on it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
e6924298bc |
iris: fix the intermittent touch-scroll dropout (missed ACTION_DOWN hit-test)
Root-caused via temporary logcat tracing (touch events, DragArbiter state, Selection::drag dispatch), reproduced against a real sandbox session: a gesture's ACTION_DOWN can land on a row's own padding/gap or its header, which CursorSense has no sensor over, so the widget that ends up handling the gesture only ever sees Pressing frames and DragArbiter never gets press_start -- leaving it stuck in Idle (answers Undecided forever) for the rest of that gesture. Not the previously-suspected coalesced first ACTION_MOVE, which is now ruled out. DragArbiter::is_idle() lets Selection::drag notice a Pressing frame with no matching press_start and recover the press there instead. Four new unit tests, one of which fails on the pre-fix code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
e49d0e606f |
RUST.md, DECISIONS.md, IRIS.md: iris's host-GPU frame time, 2026-09-05
Takes the -gpu host pair the earlier software-mode comparison flagged as
missing. Under real GPU rendering (--features force-gles: the default
Vulkan backend has no adapter at all under plain host-GPU boot, confirmed
by the exact wgpu error), iris's median frame (15.0ms) is faster than
Compose's (20.0ms) on the same session content -- the opposite shape from
the software-mode table. The new redraw-to-submit/submit-to-present split
shows iris's own CPU work is a median 0.2ms per frame; almost the whole
frame is time handing off to the driver, consistent with (but not proof
of) the software-mode gap being mostly SwiftShader's CPU rasterisation
cost rather than iris-specific slowness.
A same-mode software force-gles run, meant to isolate the backend, hit a
third distinct crash instead (SwiftShader's GL path reports itself as
OpenGL ES 3.0, which has no compute shaders, and iris's device request
assumes them unconditionally) -- real scope to fix, not done here, so the
software-mode question stays open. A real intermittent touch-scroll
dropout was also reproduced (six consecutive swipes produced zero
redraws while taps kept working; an identical retry then succeeded) and
is not explained. The idle-redraw and virtualised-culling findings from
the software-mode pass were confirmed to hold under real GPU rendering
too.
DECISIONS.md's DEFERRED item carries the updated table; the iris-vs-
Masonry choice itself is still Iris's to make. IRIS.md records the
FrameReport::record_split/FrameStats::cpu_p50/gpu_wait_p50 API from the
prior commit (
|
||
|
|
1e7b1cddb7 |
RUST.md, IRIS.md, IRIS_TODO.md, DECISIONS.md: record I5's frame report and holddrag results
FrameReport gave a real, measured on-device number (frames=34, janky%=61.76, p50=26.5ms p90=48.0ms p99=98.1ms worst=98.1ms) and long-press-then-drag-to-select is now confirmed on-device (logcat plus a screenshot of the highlighted selection). Neither closes I5's box to [x] yet: the frame number is real but not the clean single 24-swipe loop comparable to Compose's, because gestures against this checkout's EMU_GPU=software emulator intermittently delivered zero touch input this session -- a new, separately named finding (candidate cause: the emulator's own software rasterisation measured at ~78% of a CPU core continuously), not yet root-caused. DECISIONS.md's DEFERRED item is updated with these numbers rather than a decision made here. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
d17040b601 |
RUST.md, IRIS.md, IRIS_TODO.md, DECISIONS.md: record I5's Android integration and measurements
I5's transcript screen now runs on-device against a real ai-server on iris-android-app's new transcript-screen feature (extends I2's shell rather than a third one), with real scrolling, real touch-drag panning and tap-by-name accessibility all confirmed by screenshot/log evidence. I4's own emulator-side check (tap-by-name on the tabs demo) closed the same session, so its box ticks [x] now. Still [~], not [x]: the render-time number RUST.md's recommendation wants for iris couldn't be produced this pass, for a precise and recorded reason rather than a vague one -- dumpsys gfxinfo cannot see a SurfaceView's own GPU-drawn frames at all (0 frames reported across a gesture loop that visibly scrolled), and a SurfaceFlinger --latency fallback gave no per-frame history either on this Android version. The Compose side of the same loop did produce a real number under identical conditions (8.96% janky, 99th percentile 150ms), so this is now a one-sided number rather than a missing one on both sides. Also found and recorded: the AVD's saved snapshot carries a GPU config across restarts, so switching between the documented Vulkan boot recipes needs a cold boot (clearing snapshots/) that the emu wrapper does not force -- cost three different-looking crashes before the pattern was the snapshot, not the code. DECISIONS.md's DEFERRED item is updated with the numbers Iris needs to weigh the iris-vs-Masonry call; the call itself stays hers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |