ae0af8f5e314ae1c44be5b9d690e5e621189a8d6
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ae0af8f5e3 |
iris: ScrollArea measures its content instead of drawing it twice
A container that probes a child's size was still doing it with a real draw, so `Painter::measure` existed and almost nothing used it. Tracing every draw of one streamed frame: 1,083 `Widget::draw` calls over 113 distinct widgets, the worst drawn 11 times at nesting depth 7-8, every one of them mode `Draw` and none of them dirty. The cache was working -- each of the 11 was offered a genuinely different region, alternating between an oversized probe box and a real one. ScrollArea::draw was one of the two sources. It drew its whole content at a box built from a stale hint, read the length back, corrected the scroll position, and drew the content again where it belonged. The first of those is now a measurement. The other half is the measure fast path. A widget's reported size is a function of its own state and the size it was offered, not of where it was offered -- so an undirtied widget already drawn at a region of this size has already answered, and `active.size` is the answer. This is the same assumption `mov` makes one branch further down (same offered size, therefore identical output, therefore a translation); it is only stated as a size here rather than acted on as a move. Without it a measurement costs a full recursive walk, which is what made the nesting compound. A measurement now also peeks at the redraw mark instead of consuming it -- it is not the redraw the mark asked for, and swallowing it would leave the widget stale until something marked it again. 453 draws from 1,083, and the streamed frame is p50 1.18ms (from 1.22ms, and 2.20ms before this run of work). The headless phone render is byte-identical to the previous commit's on the real GPU. What is deliberately NOT here: the same change to `Span::draw`'s phase 1, which is the remaining 2x and which moves the layout by a few pixels. The layout stays intact -- it is a position difference, not a broken frame -- but which of the two is correct was not established, and the suspicion (that phase 2 now takes `mov`, and `mov` accumulates deltas where a redraw recomputes) points at a bug in `mov` rather than in `Span`. Written up in docs/IRIS_TODO.md with Iris's target shape for `Span`: no probe phase at all for `abs` children, and a `rest` child forcing a reposition pass rather than a redraw. |
||
|
|
18c5f9aaac |
iris: a measurement is a mode on the painter, not a discarded draw
Painter::draw_twice(child, first, |used| second) becomes Painter::measure plus an ordinary draw. Iris's objection was the shape it forced on the caller rather than the two draws themselves: the arithmetic that picks the real region had to happen inside a closure, and anything it wanted to keep came back out through a captured &mut. LazySpan::place was the only caller, and it now reads as the three statements it is. DrawMode::Measure is that draw with everything it writes switched off -- no arena slot, no mask, no move slot, nothing left in `active`, nothing marked dirty. Only the returned Size survives, and the widget is left exactly as it was, so the real draw that follows is an ordinary first draw or redraw. That last part is load-bearing: a measurement that left an ActiveData behind would let the following draw hit draw_inner's "already at this region" fast path and return having drawn nothing. A measurement also does not consume a redraw mark, since it is not the redraw the mark asked for, and it takes none of the fast paths, since "already drawn here" cannot report a size. Every Painter method that writes now returns early on the mode -- a widget's own draw never checks, which is the point. A debug_assert at the end of draw_inner catches one that forgot, because the failure otherwise is a single leaked primitive per measured widget per frame, which a screen redrawn every frame turns into an arena that grows without bound. What this is worth, and what it is not. The amplification it applies to, measured on a streamed frame: 1,083 Widget::draw calls over 113 distinct widgets, with the worst drawn 11 times at nesting depth 7-8 -- it is not two draws but two to the power of how many measuring ancestors a widget has. Only the writes go away; the walk and the region arithmetic still happen 11 times, and removing those needs a size answerable without a draw, which LAYOUT.md section 5 rules out. Streamed frame p50 1.39ms -> 1.22ms, p99 4.75ms -> 3.58ms. The upload numbers do not move, because slot recycling had already made the discarded writes free in arena terms. Also extracts move_slot_for from draw_inner, since measuring must not allocate one and the reuse-in-place rule wanted saying once. Verified: run-tests.sh, iris's suite, clippy and rustfmt clean, and the headless phone render is byte-identical to the previous commit's on the real GPU (Venus, RX 7900 XT -- checked, not llvmpipe). |
||
|
|
a428cba41a |
iris: record the streamed-row redraw as an open item
arena_churn says a streamed frame uploads 72.7% of the instance arena and that this is the floor, against 3.3% for a fling over the same content -- so the rows are being redrawn where a scroll would write one move_offsets delta. The upload half landed in 3c7d3db; this is the layout half, with the measurement, the control that makes it convincing, and where to look first. |
||
|
|
3c7d3db370 |
iris: the arenas upload deltas, and stop being 11x bigger than the tree
Changing any primitive re-uploaded every primitive. Measured over the bench fixture by the new arena_churn rig: 758 MB across a fling and 1.2 GB across 401 streamed deltas, p50 3.0 MB per streamed frame. Three separate things were wrong, and only the first is what it looked like from the outside. ArrBuf reallocated on every length change. A fresh Buffer's contents are undefined, so adding one glyph -- which a streamed reply does constantly -- forced a full rewrite, and no partial upload could have been correct in the first place. It has a capacity now, growing geometrically and never shrinking, and update() answers whether the Buffer identity moved so a caller can rebuild its bind group and force the whole range dirty. That alone took the glyph array from 95% re-uploaded to 3%, and stopped primitive_group being rebuilt on every frame the arena changed. A redraw freed its primitives and pushed new ones. Freed slots are not reusable until the end of the frame -- a layer's draw order still names them -- and Painter::draw_twice is how a container learns a child's size, so with containers nested the arena's high-water was the transient push count rather than the live one: 17 million pushes across 401 deltas, 127,443 slots for 11,569 live primitives, growing linearly with the transcript. A redraw now gets its old handles back as a recycle pool (Painter::take_recycled, Primitives::recycle) and writes into the slots it already holds; the pool is consumed in order and whatever the draw does not claim is freed when it ends. The arena is exactly the live count now. The CPU frame improved with it, from p50 2.20ms to 1.39ms on the stream run, because the freeing and the draw-order renumbering went away. Nothing tracked which entries changed. util::Dirty is a bitset per uploaded array, coalesced into ranges at a 1 KiB gap. Marking is O(1) and allocation-free; reading it back is one word per 64 entries. Both alternatives were measured and rejected: a min..max span is nearly the whole buffer, since a frame's changes land in 5-20 scattered runs, and a Vec of indices would mean an allocation and a sort per frame at several thousand marks. It replaces Primitives::updated -- one bool that covered the instances and the per-primitive data together, so rewriting a rect's region re-uploaded every glyph -- and TrackedArena::changed. The trap only the rig could catch: writing an entry is not changing it. Recycling rewrote every glyph of every moved row with identical bytes, marking 73% of the glyph array against 0.6% genuinely changed, because what moves is the instance's region and not the glyph. PrimitiveVec::set and Primitives::set_instance compare before marking. Every array now uploads within a hair of its floor: fling instances 3.4% against 3.3%, fling glyphs 0.9% against 0.8%, stream glyphs 0.6% against 0.6%. Stream instances are at 72.7%, which *is* the floor and is a layout question rather than an upload one -- the list is pinned to the newest end, so a growing reply moves every row, and that should be one move_offsets write rather than a redraw. Noted in RUST.md as the next thing. Also: draw_inner's four old_* parameters become one Retained struct, so the recycle pool is a field rather than an eleventh positional argument next to three others of the same shape; and free_primitive is the one place a slot and its draw-order position are retired together. The rigs move to scripts/rigs/ui-profile, a crate of their own so a rig's dependencies stay out of the app's -- arena_churn needs bytemuck, which nothing in ai-app does. arena_churn prints floor, uploaded and whole side by side per array, because any two of those alone are misleading and the 122x over-marking above was invisible until all three were on screen together. |
||
|
|
77cee6a8fa |
The bench fixture streams a reply shaped like a real one, and keeps the run-on as stress
Iris, on the two findings from the incremental-text investigation: "let's switch to new lines for the test, and also let's keep the single line around for stress + could be something to try to optimize later." The streamed tail now takes a blank line every 4-12 deltas, so it is 53 markdown blocks with a longest of 502 characters instead of one block of 14,888 -- against a measured p50 of 147 and a largest-ever 1,580 over 7,706 blocks of real assistant messages. Layer 1's streaming frame went from p50 3.86ms / p90 8.65ms / worst 10.95ms to p50 2.20 / p90 5.90 / worst 8.78. The run-on message is kept as the first two backlog events, 14,824 characters in one block, just under text_cap's 16 KiB so it draws in full. The *streaming* pathology stays in frame_profile.rs rather than the fixture: it needs a growing block, and iterating on it there costs a second instead of a two-minute phone run. Adding it is purely additive -- the random state is saved and restored around those two events, so every other backlog event is byte-identical. That is not tidiness: the first attempt shifted the backlog and broke `a_long_press_and_drag_selects_text`, which replays a real recording at (300, 1000) and needs the content it was recorded against to still be there. BACKLOG_COUNT is 3202 now, in generate.py, fixture.rs and BenchFixture.kt, which split the file by line index. And the answer to Iris's question, which the code already had: the newest message does *not* cap. `build_row`'s `cap` is false for the live tail because a row that grew while capped would appear to stop growing, and a reply growing past the cap is never caught either since it grows through apply_delta. So a streamed block's shaping cost has no ceiling -- ~29ms per delta at 50k characters, ~58ms at 100k. Recorded but not chased: the emulator's `stream: build p50` did not move (10.4 -> 10.5ms) while layer 1's frame nearly halved, so most of a streaming frame on a GPU path is the whole-arena primitive re-upload layer 1 never performs -- 11,568 primitives rewritten per delta, with the fling phase as the control at 0.4ms for the same primitives moved through move_offsets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
43a3a345e4 |
Incremental text: parley cannot, the app already does it, and the 9.5ms is the fixture
Iris asked to look into incremental text rendering, hoping parley supported it. It does not, by design: a `Layout` re-linebreaks and re-aligns freely but "if the text content or the styles applied to that content change then a new `Layout` must be created", its LRU cache holds harfrust's per-font shaper data rather than shaped runs, and its own `PlainEditor` rebuilds the whole layout from the whole buffer on every keystroke. The app already does what incremental layout would buy: `RowBlocks:: apply_delta` keeps one `TextEdit` per markdown block and re-shapes only the one a delta landed in. Re-splitting the markdown to find it is 18µs at 18,000 characters; comparing the blocks is 470ns. What is left is one `TextBuffer::shape` of that block, linear in its length at ~0.23ms per 1,000 characters here -- and the bench fixture's streamed message is 14,888 characters in a *single* block, a run-on paragraph with no blank line in it, so every delta reshapes all of it. That is 3.5ms of the measured 3.86ms frame. Real replies are not that: across 7,706 top-level blocks from 3,675 real assistant messages on this machine (lengths only, no content copied anywhere), p50 147 characters, p90 449, p99 836, largest 1,580, nothing above 4,000; code fences p50 126, largest 589. At those sizes a reshape is 48µs to 372µs here, roughly 0.12-0.93ms on the phone -- inside a 120Hz budget with no incremental anything. So the recommendation is not to build it, and to give the fixture's streamed message the paragraph structure a real reply has instead. Three runs added to `frame_profile.rs` so none of this is re-derived: what reshaping a growing message costs (including at the sizes real replies reach), where a delta's cost is, and what the fixture actually streams. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9bf714fa2e |
The frame report says what it measured: idle is not stutter, waiting is not late
Iris's phone came back "now THAT is smooth", and reading that run against the bench's own timings found three things the report was getting wrong -- two of them shipped yesterday in the fix for the last three. `missed vsyncs` counted idleness. Every gap between frames was treated as cadence, so the bench's own pauses read as stutter: 276 for sixteen 300ms rests between flings, 2410 for twelve hundred 50ms keystroke gaps, 821 for four hundred 50ms stream gaps -- each within a few percent of the arithmetic. A gap now measures anything only if the frame before it had asked for another one. `late` counted the swapchain wait as cost. A well-paced loop spends each frame blocked in the acquire, so its total sits at exactly one refresh period and every frame lands on the budget boundary -- 0.4ms of work and 5.7ms of waiting is not a late frame. It is judged on `FrameParts::work`. And the refresh rate is the larger of what the platform claims and what the run sustained, because each can only be wrong one way. `Display.getRefreshRate()` answered 60 for a run that drew 3405 frames in 33.1s, since a phone that varies its rate answers with whatever mode it is in when asked. The first attempt at measuring it instead took the fastest tenth of the gaps and reported 88Hz for this repo's 60Hz emulator, whose app manages 54 -- a budget no frame there could meet, invented out of the app's best moments, and caught only by running the corrected report on the emulator before shipping it. A sustained rate is a floor and cannot do that. Both are printed when they disagree. Also corrected in the docs: "103fps on a 120Hz screen" divided the fling phase by its whole duration, rests included. Both runs sustained ~120.3fps through the motion, so the callback ordering was never costing frames -- what changed is the clock, which moves no frame count at all, which is exactly why nothing in a report could show it. `fling_profile.rs` is `frame_profile.rs` and gained a stream run, which says where the frame time now is: folding an arriving event is 0.35ms and applying the diff 0.41ms, while the frame is 3.86ms here and 9.5ms on the phone. 401 events move the item count 652 -> 654, so nearly every one is a delta into the same row -- the cost is re-shaping one growing message, not `fold_event`'s per-event clone, which was the hypothesis and is what measuring it ruled out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
42d54eec95 |
Fling: the vsync clock, the frame ask, and a report that can say what it measured
Iris, from her phone: "some stuttering when flinging in particular. Harder to notice with my finger directly moving the scroll." Her fling phase was 103fps on a 120Hz screen at p50 6.3ms. Two of the four things found are corrections to the instrument, not the renderer. The swapchain acquire -- `get_current_texture`, which *blocks* until the compositor frees an image -- was inside the span the report called iris's CPU work, so a fling comfortably ahead of the display read as milliseconds of being slow. A frame is now three measured parts (`FrameParts`: build, acquire, submit), per phase as well as per run. And nothing could say a frame was never *produced*: `late` counts frames that cost too much, which a reader does not see, while a frame that never happens leaves the last one up for two refreshes, which is the stutter. `PhaseStats::missed` counts vsyncs nothing was drawn for. It closes on the emulator: 1548 frames + 452 missed over 33.0s at 60Hz is 1980 vsyncs. The other two are the frame loop. `Choreographer.postFrameCallback` schedules for the next vsync after the call, and iris asked at the *end* of the callback -- so any frame whose work ran past the boundary registered too late and got the vsync after, one frame over budget silently costing a second. It is asked for immediately after `tick_animations` now, on both backends. And the fling was advanced on `Instant::now()` rather than the vsync `do_frame` carries: frames are presented on an even cadence whatever clock computes them, so sampling the spline at "whenever the callback ran" moves the content unevenly with no frame late enough to appear in any report -- and a drag never had it, which is the asymmetry Iris described. `PointerClock` is `DeviceClock` and the view keeps one, anchored by whichever of a touch or a frame comes first, so a fling is advanced on the clock its velocity was measured on. `opt-level` for the Android release build goes from "s" to 3. The table in RUST.md picked "s" on bytes alone; over the same warm fling eight times iris's own per-frame work is p90 0.15ms/p99 0.42ms at "s" against p90 0.09ms/p99 0.26ms at 3, for 1.8 MB of arm64 APK. `app-rust/tests/fling_profile.rs` is the rig that established what a fling frame actually costs and is kept for next time (Iris: "please keep the profiling rig around for future use"): only one frame in six lays anything out, and the multi-millisecond spikes are all first-pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4ccfda6b8e |
Delete the decisions and design logs; scripts, rigs and xtask off the root
Iris: "remove both decisions and iris.md. I've decided to instead make decisions when planning with agents rather than after they do things, and they're both too long for me to wanna read, + don't cover all the decisions I'll wanna make about the code anyways. I'll just naturally run into things for now. Todo is important though." So docs/DECISIONS.md (850 lines) and docs/IRIS.md (1,986) are gone, and AGENTS.md now says not to start another: raise a choice while planning it with her, otherwise decide it and put the reasoning at the code it governs. The TODO lists stay. docs/SUBAGENTS_DECISIONS.md went with them -- same artefact, same reasoning, and she did not name it, so its six decisions were folded into docs/SUBAGENTS.md rather than deleted. Deleting the logs left ~30 citations dangling in code comments and docs. Each states its reason inline and cited the file only for provenance, so they now read "decided 2026-09-07" or name the module doc that carries the reasoning. The root had six things that were not a program or a document. Moved, per "I only meant top level sh files": run-tests.sh, test-wg-tunnel.sh, wg-setup-host.sh -> scripts/ rigs/ -> scripts/rigs/ xtask/ -> scripts/xtask/ A project's own scripts stayed with the project: app/*.sh, app-rust/*.sh, iris/*.sh and server/enroll-link.sh did not move. `target/` at the root is deleted and cannot come back: there was never a workspace there, and the 29 MB was only xtask's scratch space, now in scripts/xtask/target/. `cargo xtask apk` still runs from the repo root and now publishes to scripts/build/outputs/apk/<mode>/ -- one directory deep, because that is what Dev Updater's `*/build/outputs/apk/*/*.apk` discovery pattern needs, and scripts/xtask/build would have been two. Verified: ./scripts/run-tests.sh and `cd iris && cargo test` green, clippy and fmt clean everywhere, `cargo xtask apk debug --abi x86_64` builds and signs an APK carrying lib/x86_64/libai_app.so at the new publish path, and the repo root is now eleven entries with no build output among them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
09778346a0 |
Prune the docs of work already done: 18,252 -> 7,567 lines
Iris: "the documentation is also pretty crazy too. Can you go through it
and remove everything that's already done and decided? There's entire md
files iirc for projects already complete. And many with checkboxes already
ticked off that just fill up context."
docs/RUST.md 8503 -> 905 the framework bake-off (options,
recommendation, twelve closed
experiment boxes) and two superseded
"where things stand" sections, out;
what the experiments settled kept as
one line each
docs/IRIS_TODO.md 1383 -> 229 fifty closed items and six
phone-report sections whose defects
are all fixed
docs/LAYOUT.md 1116 -> 829 the pre-implementation framing: the
old trait, the checklist, the
migration list, the pass conditions
docs/TEXTURES.md 496 -> 240 the prior-art survey, the proposal
and its review, all implemented
docs/REVIEW-*.md 673 -> 0 two completed review passes; the two
findings left open on purpose (mask
hit-testing, the phone's font set)
moved into RUST.md
What survives a prune is what cannot be cheaply re-derived: measurements
(the APK-size table, the phone bench reports), dead ends, invariants and
their reasons, and the design of what exists now rather than the route to
it. AGENTS.md now says that, so the next session prunes as it goes rather
than appending; docs/IRIS_TODO.md's header says items are deleted when
they land rather than ticked.
Deleting the two review files left eighteen citations dangling in code
comments that state their reason inline and cited the file for provenance
only — those now read "(review, 2026-09-06)" and carry no dead pointer.
The emulator's measured GPU capabilities moved to the this-machine-android
skill, where machine facts belong. IRIS.md and DECISIONS.md are dated
records and were not rewritten; each gained one note that paths in older
entries predate the 2026-09-08 crate merge, pointing at the mapping.
Not touched, deliberately: docs/DECISIONS.md's entries (that file *is* the
queue of things for Iris to review, so deleting decided items would remove
what it exists for) and iris/readme.md and iris/TODO, which are hers.
Verified: ./run-tests.sh and `cd iris && cargo test` green, clippy and fmt
clean in every workspace, and every remaining docs/*.md cross-reference
resolves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
6d5a231f5c |
iris is the framework alone; the app is one crate in app-rust/
Iris: "the organization of the rust rewrite is a mess right now... there shouldn't be anything related to the app inside of iris. Iris is supposed to be the UI framework alone." And, on the crate count: "I'm confused why the app only code needs more than one crate though." Nine cargo workspaces become three, and the port's project code -- which sat in five places, four of them inside the framework -- becomes one crate, `ai-app`, in `app-rust/`: client-core -> app-rust/src/client iris/transcript-ui -> app-rust/src/ui iris/transcript-fixture -> app-rust/src/ui/fixture.rs + tests/ + touch/ iris/desktop-app -> app-rust/src/desktop + src/bin_desktop.rs iris/android-app -> app-rust/src/android + android-project/ android-shell -> app-rust/src/shell iris/ keeps core, macro, the iris crate, tabs-ui and rig-input, and now mentions no session, transcript, setup or server anywhere. Only two of the old splits had a reason that survived reading. event-model stays a crate at the repo root because server/ depends on it too, so a crate is what makes the backend and the app agree by construction. The two Android .so names looked like a hard constraint -- a package produces one library artifact -- until P2 turned out to already plan merging those two Android apps into one; both faces now come out of libai_app.so, picked apart by features so `--no-default-features --features shell` keeps wgpu, parley and iris out of the Compose app's APK. docs/RUST.md's "One app crate" has the rest, including what each remaining feature is for. DECISIONS.md and SUBAGENTS.md move into docs/ with everything else. Verified: ./run-tests.sh and `cd iris && cargo test` green, clippy and fmt clean in all five workspaces, `cargo ndk -t x86_64` links libai_app.so, build-apk.sh produces an APK that installs and launches on this checkout's emulator (Gl ... virgl, as expected), and the phone-sized headless screenshot renders the transcript unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
bf8658c404 |
iris: a scroll delta's sign is a screen direction, not a logical one
Positive scrolls the reader up or left and negative down or right, whichever way the widget receiving it lays its content out (Iris, 2026-09-08: "that way it always works as the user would expect"). `LazySpan` took the delta straight into the direction-relative space its walk works in, so a `Dir::UP` span -- whose later content is *above* -- panned the opposite way from every other scrollable in iris for the same number. `flip_delta` is the conversion, the counterpart of the `flip_pos` that positions already went through, and the two places that meet the outside world (`apply_scroll` and `moved`) are the only ones that use it. Nothing built a `Dir::UP` span yet, so this was latent; the existing sign test could not have found it either, since it asserts in the walk's own space where both halves agree with each other while disagreeing with the screen. The new test compares the two `dir`s against where rows were actually drawn. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
00e0a63887 |
docs: SCROLL.md, the standing reference for how iris scrolls
For the next session, since this one is about to be cleared. Current design only -- `Scroll` owns the position, the gesture and the fling; a child is either moved or answers `Widget::scrolls_itself` and is handed deltas; one sign convention, the finger's. It carries the things that are expensive to rediscover and easy to undo by accident: why the two `&self` capability methods must not be `&mut` (`get_dyn_mut` marks dirty), why `scroll_offset` exists beside `apply_scroll`'s remainder, why the measuring draw is free, why nothing is marked by hand, why the height cache stays in the container, why the transcript builds its `Scroll` by hand instead of through `.scrollable_to_end()`, and the measured numbers behind "a `LazySpan` is not a `Span`". Also names the one thing still open -- the pin -- with the two ways to close it and an instruction to ask Iris rather than guess. `scroll.rs` and `lazy_span.rs` now point at it from their module docs rather than restating it, AGENTS.md lists it beside the other design documents, and IRIS_TODO.md's in-progress entry defers to it. 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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
9c560e3492 |
docs: tick the drawn chevron
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> |
||
|
|
af1b0c5ab2 |
docs/RUST.md: what the folded-card sanity check found
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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
|
||
|
|
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. |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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
|
||
|
|
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> |
||
|
|
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
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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
|
||
|
|
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
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
84a13e806b |
iris: a fling starts at Compose's velocity, which is a curve fit and not an average
Iris, from the phone on the
|
||
|
|
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> |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
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
|
||
|
|
038f6a3832 |
docs: the test rig's layers 1 and 2, with their commands and their limits
RUST.md's "Three test layers" section rewritten in place with what was built: the `cargo test -p transcript-fixture` command and the five assertions with the mutation that fails each, the `run-headless.sh --phone [--replay …]` commands and the 15s/18s they take, and a paragraph on what still cannot be answered below layer 3 (anything about pixels, any frame time, anything JNI). Also the two traps that cost time -- `swaymsg seat - cursor` reaching nothing on a compositor with no input devices, and a leftover window tiling beside the new one so a screenshot looks like a duplicated-primitive bug. IRIS.md gains the public surface: `iris::harness`, `TouchScript`, `List::fling_velocity`, the fling's clock, and the desktop backend's move to physical-pixel layout with `content_scale`/`IRIS_SCALE`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
232de0ec53 |
iris: a phone-shaped desktop window, driven by the same touch recordings
Layer 2 of docs/RUST.md's "Three test layers":
./run-headless.sh phone --phone --shot /tmp/p.png -- -p transcript-fixture
opens `transcript-fixture`'s screen -- the same fixture and the same
fold the headless tests and the Android bench use -- in a window at the
phone's own 1080x2424 and `content_scale` 2.55, and screenshots it. 15
seconds, warm. `--replay FILE` drives one of the `.touch` recordings
into it and writes `<shot>-before.png` too, so "the list moved" is two
pictures: the flick carries it back about seven turns of the fixture.
Two things this needed.
**The desktop backend now lays out in physical pixels with a density,
exactly as Android does** (`default::content_scale`, overridable with
`IRIS_SCALE`, which is how `--phone` hands it the phone's). It used to
divide winit's coordinates into a separate "logical" space, which left
`UiRenderState::resize` (physical, from `WindowEvent::Resized`) and the
window uniform (logical) disagreeing on any display whose scale factor
is not 1.0, and rasterised glyphs at one resolution to show them at
another. At 1.0 -- every display here -- the numbers are unchanged, and
the `tabs` screenshot is identical.
**`rig-input`'s `replay-touch`** puts a gesture on screen. This
machine's compositor has no pointer to move: sway runs on the headless
backend with no input devices, so `swaymsg seat - cursor press` reports
success and `swaymsg -t get_seats` shows `capabilities: 0`. wlroots 0.19
dropped `WLR_HEADLESS_INPUTS` and ydotool's uinput device would be
ignored by a compositor not reading libinput, so the virtual-pointer
protocol is what is left. It parses the *same* `TouchScript` the
harness does, so one recording drives both layers.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
||
|
|
e430880cde |
docs: phone report 2026-09-07, rows at the transcript's top edge culled early or drawn through the header
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
a999bd106a |
docs: masks with a shape (LAYOUT.md, decided 2026-09-07) and the orchestrator queue in RUST.md
Iris: masks should carry a shape, rounded rectangle first, or take a container widget as the mask, with corner alpha multiplied rather than cut. Design: the mask evaluates the same SDF draw_rounded_rect uses, nested masks chain and multiply like moves, and a rounded Rect's .masked() makes the container the mask with one radius by construction. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
6840edf61e |
iris-android-app: the bench's fixture half comes from transcript-fixture
The fixture bytes, the backlog/tail split and the fold into a screen were `bench_client.rs`'s alone; they are `transcript-fixture`'s now, so the Android bench, the headless harness and the phone-shaped desktop window open one screen from one copy (AGENTS.md: nothing UI-shaped in a platform crate). What stays here is the JNI half -- clipboard, battery, IME, the report and the four phases. Built with `cargo ndk -t arm64-v8a -P 29 build --features "transcript-screen bench"`; the two warnings it prints (bench_jni's unused overlay methods, the unused `tabs-ui` dependency under this feature set) predate this change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
333220196e |
iris: a headless in-process harness, and the bench fixture as a shared crate
Layer 1 of docs/RUST.md's "Three test layers": `iris::harness` opens a real screen with no window, no compositor and no GPU, on an explicit clock and a replayed touch stream -- a trivial `t_ms action x y` file, so the batched 120Hz flick shape from Iris's phone report is reproducible as a test. The emulator cannot produce that shape at all: a `ui-trace` swipe is many evenly-spaced events, a finger is five samples in 20ms. `transcript-fixture` is the fixture-loading and fold-driving half of `iris-android-app`'s `bench_client.rs`, moved out of the platform crate so the harness, a desktop window and the Android bench open the same screen from the same bytes (AGENTS.md's sharing rule). Two supporting changes in iris itself, both about reading a clock that was not handed in: `Fling::started_at` is now set on the first `tick_fling` rather than at the release, so a driver running frames on its own clock does not start every fling at the wall clock and advance it on a different one; and `List::fling_velocity` exposes what the release measured, which is where `Released(Some(v))` lands. Four tests, each confirmed to fail without its subject: dropping `animate(id)` from `Selection::drag` (the phone's own "fling does nothing" defect) and reverting `started_at` each fail the flick test alone; flinging on `Tapped` fails only the tap test; a 5s `LONG_PRESS` fails only the selection test; a `set_bottom_inset` that ignores its argument fails only the composer/IME test. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |