Author SHA1 Message Date
iris 4274b8b8d0 Merge remote-tracking branch 'origin/rustify' into worktree-agent-ace98b0bdaf33ffff
# Conflicts:
#	docs/IRIS.md
#	docs/RUST.md
2026-09-07 15:33:25 -04:00
irisandClaude Fable 5.1 73f956f8e0 iris: the fling curve was the identity function, and the keyboard was a targetSdk
Iris's 2026-09-07 phone report on ed04d4c: the resume glyph corruption is
fixed (item 4 closed with her evidence), flinging "seems to just be linear
velocity with an abrupt stop", and the keyboard still does not push
anything up. docs/RUST.md's new "The 2026-09-07 phone report" section has
the derivation and every number.

**The fling was arithmetically linear.** `android_fling_spline::
distance_fraction(t)` returned `t` for every `t`. Two halves of AOSP's
`SplineOverScroller` static initialiser had been transposed -- the
bisection solved the tension curve and the sample evaluated the P1/P2 one,
where AOSP does the opposite -- which made SPLINE_POSITION and SPLINE_TIME
identical; the lookup then bracketed `t` between SPLINE_TIME entries
instead of between even time steps, and the two cancelled to the identity.
Ported exactly now from OverScroller.java and androidx.compose.animation
1.12.0's SplineBasedDecay.kt, which agree line for line, as one table
indexed by even steps of time (AOSP's second table serves only
`adjustDuration`, which nothing here has, so it is deliberately not built
-- one array, one indexing rule). `FlingCalculator::velocity_at` is new
beside `position_at`, and `List::tick_fling` logs `iris fling tick:` with
the per-frame delta and speed.

Every existing test compared the calculator with itself -- monotonic,
signed, integrates to the closed form, deltas non-increasing -- and all of
them pass on a straight line. iris/benches/fling_spline_reference.py is an
independent hand transcription of both sources and supplies the numbers
now checked into `the_spline_matches_aosps_own_table` and
`a_flick_decelerates_the_way_aosp_says_it_does`;
`tick_fling_applies_shrinking_incremental_deltas` went from
"non-increasing" to "the last delta is under 80% of the first". Negative
control: with `sample` forced back to `t`, exactly those three fail.

Emulator (API 36, debug, force-gles): a released v=3750 decelerates
3746 -> 2624 -> 1834 -> 1144 -> 752 -> 449 -> 243 -> 83px/s over 32 frames
to t=0.664s; a flick into the end of the list stops there in one tick with
no overshoot; a tap 200ms into a fling ends it at 11 ticks.

**The keyboard: `targetSdk = 34`** in iris/android-app/app/build.gradle,
against compileSdk 37 and the Compose app's 37 -- and that app's keyboard
does push up on her phone. Below target 35 a window keeps the legacy
behaviour where adjustResize shrinks it for the IME, so
getInsets(ime()).bottom measures an already-shrunk window and is zero;
setDecorFitsSystemWindows(false) opts out of that and still takes on the
API 36 emulator here, which is why every test run passed. Now targetSdk 37.

That is a reading and not a measurement, so the other half is making the
phone able to answer it. MainActivity also registers a
WindowInsetsAnimation.Callback (onEnd re-reads getRootWindowInsets, so an
interrupted animation cannot freeze a value), which delivers the height
where only the animation path carries it and makes the push-up animate:
ime_bottom now arrives 509, 663, 833, 881, 883 instead of one jump.
`insets::Shared::updates` counts every dispatch and
`AndroidUiState::insets_report()` puts it in the Diagnostics pane --
screenshot-verified, `insets: dispatches=27 left=0 top=142 right=0
bottom=63 ime_bottom=0 ime_visible=false`. Iris has no logcat, and "the
listener never fired" and "it fired with a zero height" are otherwise the
same picture; dispatches=0 says so in words rather than showing defaults.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:44:01 -04:00
irisandClaude Fable 5.1 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>
2026-09-07 12:39:53 -04:00
irisandClaude Fable 5.1 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>
2026-09-07 12:38:55 -04:00
irisandClaude Fable 5.1 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>
2026-09-07 12:38:19 -04:00
irisandClaude Fable 5.1 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>
2026-09-07 12:35:20 -04:00
irisandClaude Fable 5.1 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>
2026-09-07 12:34:19 -04:00
irisandClaude Fable 5.1 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>
2026-09-07 12:27:04 -04:00
irisandClaude Fable 5.1 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>
2026-09-07 12:24:54 -04:00
irisandClaude Fable 5.1 7f4ea7e8fd docs/TODO.md: Compose app crash from Iris's phone log export, reversed AnnotatedString range in ToolInput.highlighted
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:22:47 -04:00
irisandClaude Fable 5.1 591128eef1 AGENTS.md: the phone app and the planned desktop app share widgets and styling; only screen layout differs
Iris, 2026-09-07. The second central design point beside the driver
rule, so a platform crate growing a widget or a colour reads as a
defect to move. docs/RUST.md carries the detail.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:13:54 -04:00
irisandClaude Fable 5.1 ba0f2ea93f docs: the 22:16 report reconciled with what was actually run
RUST.md's "Shell lost" section and IRIS_TODO.md's matching paragraph both
said item 4's fix was written but never built or tested. It was committed
in ba2afba with its test passing, so both were stale the moment that
landed and read as if nothing had been run at all.

Replaced with one section per item, saying what was fixed, what was
measured on this checkout's emulator and what the phone still has to
settle: items 2 and 3 ticked with their numbers, item 4 ticked on the code
with phone confirmation still owed (no Vulkan adapter here), item 1 left
open with the exact logcat line for Iris to look at. The two pre-existing
faults found on the way -- the 16-deep move chain and the API-29 JNI calls
-- are recorded where the next reader will hit them.

IRIS.md gains the public-surface entry: `Widget::tick`,
`UiData::animate`/`tick_animations`, `FlingCalculator`'s density and
coefficient, and `MOVE_CHAIN_LIMIT`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:12:03 -04:00
irisandClaude Fable 5.1 ed04d4c735 iris: the keyboard reopens, the IME's height reaches the layout, and a fling actually moves
Items 1-3 of Iris's 22:16 phone report, plus the two defects that were
hiding behind item 1 and only became visible once the first one was
fixed. Emulator evidence and the numbers are in docs/RUST.md.

**Keyboard reopen.** `attr.rs`'s already-focused branch calls
`focus_gained` on a tap that stays inside `DRAG_SLOP` -- what Android's
own `EditText` does, `showSoftInput` being idempotent. Dismissing the IME
leaves the field focused, so the only branch that requested it never ran
again. Negative control run: without this one call the second tap leaves
`mInputShown=false`. Swipes across and out of the focused field still
summon nothing.

**IME height.** `MainActivity` sends `getInsets(ime()).bottom` and
`isVisible(ime())` as two values; the height used to be sent *as* the
boolean, so nothing had a number to pad by. `Insets`/`WindowInsets` carry
both, `bench_client` reads the boolean for its state machine and the
height for `Composer::set_bottom_inset`, and the list follows because it
is `rest(1)` in the same `Span`.

**Fling.** Three defects, in the order they were found:

1. `on_touch_event` read only each `MotionEvent`'s final position, so a
   batched 120Hz flick fed the tracker one sample and `velocity()`
   answered 0.0. Historical samples are replayed through the sensor pass
   now, `CursorState::time` carries each sample's own time (so a replay
   loop's speed cannot become the measured velocity -- the winit backend
   sets it too), the press is a sample as AOSP's own tracker does, and
   `iris drag release:` logs the decision for the phone's logcat.
2. Nothing advanced a fling between input events: `tick_fling`'s only
   caller was the benchmark's own loop, so the bench flung and a finger
   never did. iris has one animation mechanism now -- `Widget::tick`,
   `UiData::animate`/`tick_animations`, called by both backends before
   the draw and re-requesting a frame while it answers true.
3. With flings finally animating, one lasted 45 seconds: `List::fling`
   hardcoded density 1.0 against physical-pixel velocities, and
   `FlingCalculator`'s coefficient used the scroll friction where AOSP
   uses its 0.84 tuning constant -- 56x, inside an exponential. Emulator:
   1.62s for v=11064, against AOSP's own 1.586s.

**Two pre-existing faults found on the way.** `MOVE_CHAIN_LIMIT` was 16
and the composer's chain is 17, so every debug build aborted on a tap of
the composer and every release build silently drew and hit-tested that
subtree short; it is 64 in both the CPU walk and shader.wgsl, and the
assert prints the chain so a cycle and a deep tree can be told apart. And
`minSdk` is 29, since `getEventTimeNanos` is API 29 and a missing JNI
method is a crash rather than a degraded fling.

Every new invariant carries its guard: sample times non-decreasing in
`on_touch_event`, and tests confirmed to fail without their fix for the
press-seeded velocity, the animation registration and the AOSP
magnitudes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:11:55 -04:00
irisandClaude Fable 5.1 ba2afbaedb iris: a cleared glyph atlas must un-cache every RenderedText, not just empty itself
Iris's phone, 2026-09-06 22:16: after leaving the app and returning,
every glyph drawn *before* the resume came back as fragments of other
letters, while the diagnostics text drawn after it was perfect.

The renderer rebuild does force a full redraw -- `surface_changed` calls
`render.resize(...)`, which sets `UiRenderState::resized`, which makes
the next `update` take `redraw_all`. What survives that is one cache
further in: `TextView::render` returns its cached `RenderedText`
whenever the wrap width, buffer and attrs are unchanged, so
`TextData::place` is never reached, nothing is re-rasterised into the
fresh atlas, and the *previous* atlas's uv_min/uv_max/layer go straight
back to the GPU. Only text whose content changed after the resume
re-shapes -- exactly the split in the screenshot.

One mechanism rather than a per-holder invalidation path: `GlyphAtlas`
carries a `generation`, bumped by `clear`; a `RenderedText` records the
one it was placed against; and `TextView::render`'s cache key includes
it, so clearing the atlas makes every cached render un-reusable at once.
`Painter::glyphs` debug-asserts that a submitted quad's generation is
the live one, catching the fault at the submission instead of on screen.

Test `clearing_the_atlas_re_renders_cached_text_instead_of_reusing_it`
(iris/src/widget/text/mod.rs): draw, clear the atlas, resize, draw
again, and assert the atlas holds the same glyph count. Confirmed to
fail without the cache-key line -- it trips the new debug_assert with
"glyphs placed against atlas generation 0 submitted against 1".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 23:22:40 -04:00
iris 10267dec27 Merge branch 'worktree-agent-a673ba12761c025d9' into rustify 2026-09-06 23:20:30 -04:00
irisandClaude Fable 5.1 7e7cbb5402 Tool-call cards and grouping, with the state a result never arrived in
P1b (docs/RUST.md). `transcript-ui/src/tool.rs` draws a card per tool
call and a group per run: collapsed, a card is its name and the one-line
summary `parse_tool_input` derives; open, it is the description, the
input (highlighted, on the verbatim surface) and the output, capped with
a "Show all N lines". A run is one surface with a heading and a chevron
bar at its foot, so it closes from either end.

Three things worth knowing.

**A collapsed card lays out its summary line and nothing else.** The
fixture's tool outputs are tens of kilobytes and a collapsed card never
builds a widget for one -- `collapsed_cards_shape_only_their_summary_
lines` opens a three-card group over 88 kB of output each and asserts the
text-shape count equals the same group's over three bytes (17 either
way; 17 against 20 when the discipline is deliberately broken, so the
test is real).

**A result arriving replaces one card.** `ToolRow::apply_calls` is the
group's half of `RowBlocks::apply_delta`'s rule, and `build_row` now
hands back one `TailRow` -- blocks for a message, cards for a run --
rather than two mechanisms chosen at each call site.

**Every tap is a tap**: `GestureOutcome::Tapped` out of the `DragArbiter`
`Selection` already owns, so a drag that started on a card scrolls the
transcript instead of opening it.

Three defects found by looking at the render, all recorded with their
repro in docs/IRIS_TODO.md: a `Span` of padded children inside another
`Span` places them a slot out of step (worked around by building the
group as one span, which costs the 4dp inset); `scrollable_on(Axis::X)`
on a non-editable text draws nothing, so a card's command is clipped
rather than pannable; and `NotoSans-Regular` has no U+25B8/25BE/25B4 at
all, so the expander mark is set in the monospace face.

Screenshots: docs/bench/p1b-2026-09-06/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 22:49:51 -04:00
iris a200ddbddd docs/IRIS_TODO.md: Iris's 22:16 phone report on the 20303e0 build, four open items with the reading of each 2026-09-06 22:31:07 -04:00
iris b332873894 Merge remote-tracking branch 'origin/rustify' into worktree-agent-a673ba12761c025d9 2026-09-06 21:33:28 -04:00
irisandClaude Fable 5.1 a4809b3026 WIP: tool-call cards and grouping (P1b)
`transcript-ui::tool` draws a card per call and a group per run, with
the states, the collapsed-lays-out-nothing discipline and the
one-card-per-result update. Screenshots in docs/bench/p1b-2026-09-06/.

Includes a local fix to `List::place`'s reposition-vs-mov clash, which
is about to be dropped for rustify's own.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:33:24 -04:00
iris 1ad2f9ec6e docs/RUST.md: phone delivery is a push to ai-app-bench, not ~/host/bench 2026-09-06 20:04:26 -04:00
iris 33e8ab83a2 docs/RUST.md: the two 2026-09-06 fixes under P1a, with the emulator's first legible screenshot 2026-09-06 19:59:57 -04:00
iris f5b88932b4 iris: a widget's move slot has one owner -- move_applied + repositioned
`mov` accumulates a delta onto the slot and `reposition` overwrote it, and
both legitimately land on one widget in one frame: `List::place`'s
Bottom-known branch offers a row a same-size box that has moved (`mov`),
then corrects the placement inside it when the row's cached height no
longer matches what the row reports (`reposition`). That is what a wrapped
transcript row hit, and what the `move_applied == ZERO` debug assert was
standing in for -- an assert against a case that happens is not a
guarantee, it is a crash.

The slot means `move_applied + repositioned` now, both halves recorded on
`ActiveData`, so `reposition` adds the move rather than dropping it and
stays idempotent. The assert it replaces is a `debug_assert_eq!` that the
slot still holds that sum on entry -- i.e. that nothing but those two ever
wrote it.

Test: `a_widget_moved_by_its_parent_and_then_placed_inside_it_lands_at_the_placement`,
which draws the child at the offered position (-100px) rather than the
placement (100px) without the fix. Verified against the `.wrap(true)`
repro from docs/IRIS_TODO.md (draws correctly, no panic) and an emulator
bench run with assertions live.
2026-09-06 19:59:39 -04:00
irisandClaude Fable 5.1 9079276ec8 A tool call can say it failed, and what it is for, without a renderer
P1b's pure half (docs/RUST.md). Three pieces, all testable with no
widget in sight:

- `event_model::Event::ToolEnd` gains `is_error`, read from the CLI's own
  `tool_result` field by both the live translator and the import replay
  (`import::tool_result_is_error`, one reader so the two cannot disagree
  about the same conversation). Without it a result is all a card has,
  and a broken call draws exactly as confidently as one that worked --
  the missing state, not a wrong one. `#[serde(default)]`, so an older
  transcript reads back as "not reported to have failed".
- `client_core::transcript_fold::ToolState`: Running, Deciding,
  Succeeded, Failed, NoResult. The pair it exists for is the last two
  against Succeeded-with-empty-output -- a call that printed nothing and
  a call whose result never arrived leave the same empty string, and only
  the session's status separates "still going" from "nobody found out".
- `client_core::tool_summary::parse_tool_input` and
  `client_core::durations`: `ToolInput.kt`'s subject/description/timeout
  split and `Durations.kt`'s span formatting, ported with their tests.

The echo driver's three-call run now has a failing middle call, so the
failed appearance is reachable from `ui-sandbox.sh` at all.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 19:46:21 -04:00
iris 3cb18ac5c2 iris: a one-layer glyph atlas is a GL_TEXTURE_2D, so every glyph drew as a box
The emulator was blamed for two days for what is iris's own defect on any
GL adapter. `GpuTextures::new` created the atlas `texture_2d_array` with
one layer; wgpu-hal picks the GL target from the descriptor alone
(`gles::Texture::get_info_from_desc`, `(false, 1) => TEXTURE_2D`), so the
shader's `sampler2DArray` was handed a `GL_TEXTURE_2D`, the unit was
incomplete, every `textureSample` returned (0,0,0,1), and `draw_glyph`'s
`color.a *= texel.a` painted the whole glyph quad.

`MIN_ARRAY_LAYERS = 2`, with the account at `create_array_texture` and a
`debug_assert!` there. Vulkan -- the phone's build and the desktop's
default backend -- was never affected.

`force-gles` now switches the desktop backend too, so the GLES path is
reproducible on a machine with a real GPU in seconds rather than only
through an APK: that is how this was found, with two shader probes
showing the sample was exactly (0,0,0,1).
2026-09-06 19:41:40 -04:00
irisandClaude Fable 5.1 69525bd131 iris: a Rect is not size-independent, and P1a's block appearance verified
The defect P1a's screenshots found, and the one that mattered:
`Rect::is_size_independent()` answered `true`. A `Rect` fills whatever
region it is handed, so its content *is* the region -- and
`draw_inner`'s fast path, which rewrites a widget's primitives with
`r.outside(&from).within(&region)` instead of redrawing it, cannot
reproduce that once a region carries both `rel` and `abs`. What it
looked like: a fenced code block's background kept the height of the
provisional full-region draw `Span` does in its first phase, so one
fence's panel covered every block below it and every row below that,
with the text underneath laid out correctly. Likely the same cause as
RUST.md's older "the composer bar's grey background is not drawn".

Also here: a quote's bar is a `Stack` background behind padded text
rather than a two-child `Span(Dir::RIGHT)` (one widget fewer and no
provisional pass), and `transcript-ui`'s `transcript` example gains a
row holding one of every block kind -- the fixture's own heading,
paragraph, fence and table source, plus a list and a quote, which the
fixture has neither of.

docs/bench/p1a-2026-09-06/ has the pairs and docs/RUST.md's P1a box
names what still differs. The iris half is from the desktop backend
because this emulator cannot draw iris's glyphs at all (solid boxes,
reproduced on the previous commit, with Compose drawing text correctly
on the same AVD); both routes to Vulkan on this AVD were tried and both
fail. Bench stream phase, assertions live, no abort: p50 53.0ms p90
108.6ms p99 132.0ms against 52.8/108.1/137.3 before -- unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 19:30:39 -04:00
irisandClaude Fable 5.1 64f64b54e5 iris: per-block markdown appearance, syntax-highlighted fences, tappable links
P1a (docs/RUST.md). A transcript row's blocks are drawn the way
Markdown.kt draws them rather than as one flat span list:

- transcript-ui/src/markdown.rs is a *block* renderer now.
  `BlockFrame` is the whole widget vocabulary -- Plain, Verbatim (a
  dark rounded panel that pans sideways) and Quote (a bar and an
  indent) -- so a new markdown feature costs spans, not widgets.
  `frame_of` is the one place the BlockKind -> appearance mapping is
  written.
- Fences take `client_core::highlight`'s spans by language, in the
  same Catppuccin palette Theme.kt's `catppuccinSyntax()` uses, with
  the char->byte offset conversion the two index spaces need.
- Lists get the bullet ladder and coloured markers MarkdownPieces.kt
  draws, ordered lists count from the number they were written with,
  headings take Material's own ladder (24/22/16/14/12/11).
- Tables are padded monospace columns measured from the cells, with
  the header bold and a rule under it -- see docs/DECISIONS.md for
  what that trades against a real grid.
- Links carry their URL through to a tap. `GestureOutcome::Tapped`
  is new: a press that never committed to a pan or a selection, so a
  finger that flung the list past a link does not also open it.
  `iris::platform::OpenUrl` is the capability, implemented by each
  backend (xdg-open/open/start on the desktop, an ACTION_VIEW intent
  deferred to `after_input` on Android, the same shape
  `pending_show_keyboard` uses).
- `DragArbiter`/`DragGesture` take an axis, so a code fence pans
  across its own long lines through the same machine a list pans
  down its rows -- and a vertical drag starting on a fence still
  reaches the list.
- `TextEditCtx::byte_at` answers which byte a tap landed on without
  exposing the parley layout; `Rect::radius` takes a `Len`, so a
  corner can be written in dp.

Tests: 31 in transcript-ui (11 new, covering the frame mapping,
highlighting including a multibyte fence and an unknown language,
list markers, table padding and wrapping, link hit-testing), 85 in
iris (4 new on the tap-vs-drag rule and the two axes).
cargo fmt clean, clippy warning-free.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:55:46 -04:00
irisandClaude Fable 5.1 20303e0b4c IRIS.md: take_counters gained a fourth number, text shapes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:40:38 -04:00
irisandClaude Fable 5.1 6973a89815 docs: the verification pass over Tasks A and B, and the composer background withdrawn
RUST.md gains the pass's findings with their commits and the numbers:
the block model held under a per-character prefix property, the
size-independent hit-box defect and its fix, the tail-rebuild selection
gap, why the three new debug_asserts are whole-set, the text-shape
counter that turns "a delta costs one block" into a measurement, and the
verification bench run.

IRIS_TODO.md's "the bar's own grey background is not drawn" is
withdrawn: decoding the screencap puts it at rgb(41,40,49), full width,
y2245..y2365 -- drawn, and dark on black, which is most likely what the
earlier reading was.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:40:25 -04:00
irisandClaude Fable 5.1 c3cfc67bb3 iris: count text layouts, so "a delta shapes one block" is measured rather than argued
take_counters gains a fourth counter, text shapes, bumped in
Painter::render_text -- which TextView::render only reaches on a cache
miss, so it counts shapes and not requests. A draw counter cannot stand
in for it in either direction: a widget can be redrawn without
re-shaping (the layout is memoized by width) and re-shaped without any
extra draw, and re-shaping is the whole thing the per-block transcript
row exists to avoid.

With it, a_delta_into_a_long_reply_redraws_the_same_widgets_as_a_short_one
asserts the number docs/DECISIONS.md's 2026-09-06 entry actually claims:
one delta into a 100-paragraph reply shapes exactly one text layout, the
same as into a one-paragraph one. Before the split that was necessarily
O(message), since the reply was one buffer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:32:31 -04:00
irisandClaude Fable 5.1 155d899e55 transcript-ui: pin the tail rebuild's unregister with the case that broke it
e1030d6 made Selection's key (RowKey, u32) and changed apply's
ReplaceLast arm to unregister unconditionally rather than only when the
key changed -- correctly, but with nothing exercising it. The case is a
tail row rebuilt under the *same* key with fewer blocks than it had: the
blocks that no longer exist keep pointing at widgets replace_back's drop
frees, and Selection::begin resolves every registered handle on an
ordinary press, so the next tap anywhere in the transcript panics. The
old `if new_key != old_key` guard could not see it, because nothing
about the key changed.

Selection::registered_blocks (test-only) is what lets the test assert the
contract unregister states -- every block of the row, not the first --
instead of only that nothing panicked.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:32:05 -04:00
irisandClaude Fable 5.1 e63e923d44 iris: a size-independent widget's hit box lands where it is drawn
draw_inner's third fast path -- offered region changed shape, widget's
output does not depend on it -- rewrites the widget's own primitives in
place and writes no move-slot delta at all. 167862c added a
move_applied increment there, copied from mov, where region and the slot
delta really do move together. Here only region moves, so resolved_region
subtracted a distance the chain never held and every such widget's hit
box sat short of its drawing by exactly the last step it took.

Span reaches this on the first frame of any tree it is in: it measures
each child at the full region and then places it, which for a Rect (the
.background(rect(..)) idiom, list row tints) is a size change through this
branch. So the hit box was wrong from the start, with the drawing correct
-- nothing on screen to say so.

a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at
is the sibling of a_panned_widgets_own_hit_box_moves_exactly_once on the
branch that fix had no reason to touch; it fails on both frames without
this.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:31:58 -04:00
irisandClaude Fable 5.1 a56a928b0c client-core: the transcript's own markdown shapes, and the streaming property as a property
split_blocks was tested on the shapes it was written against. These are
the ones a real reply contains -- a fence with blank lines in it, a `---`
inside a fence, a nested list, a fence directly under a heading, a table,
a quote -- plus the property RowBlocks::apply_delta actually depends on,
checked at every character boundary of a message that has all of them:
growing a message may rewrite its last block and never an earlier one, or
common_prefix must say so. No defect found; the split already held.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:28:57 -04:00
iris 0449a324ef docs/RUST.md: P1 started on Iris's word, sub-order P1a-P1e by what makes the bench fair 2026-09-06 18:28:48 -04:00
irisandClaude Fable 5.1 e1030d69f6 iris: a transcript row is a column of markdown blocks, so a streamed delta costs one block
A row was one TextEdit holding the whole message, so every delta
re-shaped every paragraph of a long reply through parley -- the one phase
where iris trails Compose on the phone (p50 18.2ms vs 13.4ms, bench v2).

- client-core/src/markdown_blocks.rs: split a message into its top-level
  blocks with their source, through the same pulldown-cmark the renderer
  parses with so the two cannot disagree about where a block starts, plus
  common_prefix. Appending markdown can rewrite an earlier block (a
  trailing --- turns the paragraph above into a heading), so the fast
  path compares the prefix it keeps rather than assuming it -- with the
  test that says so.
- transcript-ui: a row is a Span of one TextEdit per block;
  RowBlocks::apply_delta replaces the block a delta lands in;
  TranscriptScreen keeps the tail row's blocks, seeded in build_tree as
  well as push_row (a screen opened onto a streaming reply took the
  rebuild path for its first delta otherwise, with nothing to say so).
- A block is the selection unit: Selection is keyed by (RowKey, u32),
  which is reading order at both levels, and the pointer-captured half of
  a drag resolves the block under the finger from its drawn box
  (Selection::locate) instead of from the row's extent.

Pass condition: a_delta_into_a_long_reply_redraws_the_same_widgets_as_a_short_one
drives a real UiRenderState and asserts the draw count for a delta into a
100-paragraph (3,000+ char) reply equals the count for a one-paragraph
one. 30 either way; it read 630 against 30 twice on the way there.

Emulator stream phase, same AVD before and after: p50 61.5 -> 54.5ms,
p90 211.7 -> 113.1ms, p99 342.6 -> 137.4ms, worst 403.6 -> 143.0ms, 202
-> 293 frames in the same 21 seconds. Selection across blocks verified
with a real long-press drag.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 17:33:37 -04:00
irisandClaude Fable 5.1 167862ca1b iris: the composer scrolls on a finger -- a dp cap worth zero, a stale mask slot, a hit box moved twice
Wrapping the composer's field in .scrollable().masked() needed three
layout defects fixed first, each with a headless regression test that was
confirmed to fail without its fix:

- MaxSize/Sized reported a caller's declared dp length unresolved, and
  Span places a child from the abs/rel of what it reported, so dp(168)
  was worth zero: the bar got a slot of nothing the moment its content
  passed six lines and the Scroll inside measured its container at -63px
  (container=-63 content=415.8 amt=478.8 on the emulator). Len::fold_dp,
  used on the way out, plus a debug_assert in draw_inner that a reported
  Size carries no dp -- the rule is about every widget, not those two.
- Masked allocated a fresh mask slot per draw, and draw_inner's
  unchanged-region fast path does not revisit descendants, so they kept
  clipping against a box the bar had moved away from: four live mask
  entries, none of them current, and the field drew nothing.
  ActiveData::own_mask, allocated once and rewritten in place.
- mov updates active.region and accumulates the same delta on the move
  slot, and resolved_region added both, so a panned widget's own hit box
  sat at twice the pan -- the composer's field was untappable after a
  drag. ActiveData::move_applied.

Scroll itself measured the right number by a misleading route; it is
written against painter.px_size() now and still reports its content's
size, since reporting the container makes the answer a function of
itself.

Verified on this checkout's emulator: swipe 540 1200 -> 540 1460 moved
the field's Message box 31,1041..1048,1509 -> 31,1131..1048,1651 with its
height unchanged at 468px.

run-bench.sh polled logcat for a prefix copy_report also logs at startup,
so it printed a report that had never been run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 17:17:42 -04:00
iris d73db97629 iris/android-app/build-apk.sh: clear jniLibs before building, so only the requested ABI is packaged 2026-09-06 16:47:54 -04:00
irisandClaude Fable 5.1 fb6b459c2c iris: Scroll pans on a finger drag; a vertical drag in a focused field scrolls rather than selects
IRIS_TODO.md's "the composer has no touch-drag scroll". `Scroll::drag`
takes its pan from the same `sense::DragGesture` `List` is driven by --
arbitration, DRAG_SLOP, velocity and pointer capture all stay in sense.rs
and only what a committed pan *means* is decided per caller -- and
`WidgetLike::scrollable()` registers it beside the wheel handler it already
registered, so every scroll area pans on a finger with nothing added at the
call site. No fling: `Scroll` has no per-frame tick to animate one and the
areas it wraps are at most a screenful. `Scroll::amt()` exposes the pan
position.

`attr.rs`'s `on_press` treated an already-focused field as the plain
click_or_drag case, so every Pressing frame extended a selection. It now
applies the same DRAG_SLOP rule its unfocused branch already did: a press
past the slop vertically abandons its pending selection for the rest of the
gesture, so the scroll area around the field wins it. That is Android
EditText's own behaviour and it is what lets a swipe up over the composer
scroll instead of dragging a highlight through what you typed.

Also fixed, found doing it: `ActiveData::mask` stored the mask a widget
*set* rather than the one it was drawn *under*, and `redraw` feeds that
field back in as the inherited mask -- so a targeted redraw of any `Masked`
handed it its own mask and aborted on `set_mask`'s nested-mask assert. A
real abort on the emulator, `assertion failed: self.mask == MaskIdx::NONE`.

And the per-frame orphan guard from 76b1f99 is now a count comparison
(O(active widgets)); the O(primitives) walk only runs to build the failure
message, because running it per frame made a debug build on the emulator too
slow to finish a bench run at all.

Tests: four in scroll.rs (pan past the slop, a tap inside it, a horizontal
drag, the end clamp), `a_finger_drag_over_a_scroll_area_pans_it` in
sense_tests.rs driving the whole registration/dispatch/capture path (fails
with "got 0" without the new registration), and
`redrawing_a_masked_widget_does_not_nest_its_own_mask` in layout_tests.rs
(aborts on the pre-fix code).

The composer itself is deliberately still not `.scrollable()`: `Scroll`
measures against the window rather than its own offered box, so inside the
`MaxSize` capping it at six lines it pans the field out of the bar --
measured, reverted and written down in RUST.md and DECISIONS.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 16:45:56 -04:00
irisandClaude Fable 5.1 76b1f99277 iris: a dirty widget redrawn by its ancestor never freed its old primitives
`draw_inner` read `needs_redraw` without consuming it, and used it to skip
the whole `if let Some(active)` block -- including the `remove(id, false)`
that frees a redrawn widget's previous primitives. So a widget that was
both already active and marked dirty, and was reached by an *ancestor's*
draw rather than by `redraw_updates` picking it first, drew a second full
set of primitives and then had `active.insert` overwrite the only handles
that could ever have freed the first set. Those primitives stay in the
layer's instance buffer for the life of the process, with a leaked move
slot and leaked mask refs, drawn every frame at whatever region they last
had -- and `List` sets no mask, so a row measured at `GENEROUS_PADDING`
leaves its ghost outside the list's own box.

That is the doubled `Compacted:` row in docs/bench/iris-phone-v2-2026-09-06.md:
overlapping copies inside the transcript and one more below the composer.

Fixed by consuming the mark (`needs_redraw.remove`) at the top of
`draw_inner` -- this call *is* the redraw it asked for -- and freeing the
old primitives on the dirty path too.

Guarded so it cannot come back silently: `UiRenderState::orphaned_primitives`
walks every layer's live instances and names any whose owner is no longer
active or no longer holds a handle to them, and `update` `debug_assert!`s it
empty every frame (debug builds only). New regression test
`an_ancestor_redrawing_a_dirty_row_leaves_no_stale_copy` in list.rs fails on
the pre-fix code with "1 primitive(s) survived their own widget's redraw".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 13:59:53 -04:00
iris 3e72a4ef19 docs: the defect pass's findings -- RUST.md boxes, IRIS_TODO ticks, DECISIONS and IRIS entries 2026-09-06 13:47:28 -04:00
iris c02152a4f4 iris: a tap on an empty text field left no caret, so typing was silently dropped
TextEditCtx::select compared the tap against the laid-out text's own box
and cleared the selection for anything outside it. An empty field lays
out to a zero-width box, so tapping the composer granted focus and opened
the keyboard with no caret, and insert_str returns early without one --
every keystroke went nowhere and no glyph was ever emitted. Parley clamps
a point outside the layout by itself, and a press reaching select() has
already been hit-tested to the widget, so there was nothing for the
'outside' branch to mean.

insert_str now debug_asserts rather than dropping input silently, and
UiRenderState::draw_started -- a re-entrancy guard whose test was written
after its own remove(), so it could never fire, and which grew by one
entry per widget ever drawn -- is restored to what it was meant to be:
inserted around Widget::draw, removed when it returns, asserted empty at
the top of every update.
2026-09-06 13:43:56 -04:00
iris d9872989fa iris/android: the composer's launch position was the bench report pane, plus surface/insets lifecycle logging
The empty benchmark-report TextEdit held .height(rest(1)) beside
content.height(rest(2)), so it reserved a third of the window at every
launch and pushed the composer two thirds down -- Iris's 11:39 phone
report. It is sized to its content now, capped and scrollable, and sits
above the transcript rather than under the composer.

New log::info! lines for one insets change, one surface_changed, one
renderer build and one surface_destroyed, each with the glyph/atlas
counts, so a phone's adb logcat can answer the app-switch text loss the
emulator cannot reproduce.
2026-09-06 13:26:34 -04:00
iris 2fed8b34b3 Merge branch 'worktree-agent-a6e37a2335f436d08' into rustify 2026-09-06 13:17:22 -04:00
irisandClaude Fable 5.1 1f379e8384 docs/REVIEW-2026-09-06.md: fix all ten review findings; RUST.md/IRIS_TODO.md: DragGesture merge checks
Finding 1 (the real crash): Selection::clear() drops rows and anchor,
called from TranscriptScreen::apply's Rebuild arm right before
List::clear() -- push_row re-registers survivors as it rebuilds each row.
Fixes a WeakWidget outliving the row group_tool_runs regrouped away,
which panicked the next long-press anywhere. New apply_tests test builds
a real TranscriptScreen, forces the regroup, and confirms no panic.

Findings 2-5: debug_assert!s on List::place's slot, List::fling and
FlingCalculator's velocity finiteness, VelocityTracker::add_sample's
chronological order, and FrameReport::mark_phase's non-decreasing
start_index. Finding 7: bench_client.rs's battery_line guard restructured
so the empty check can't be separated from its unwraps by a future edit.
Findings 9/10: new List tests pinning tick_fling's per-tick deceleration
and replace_back's evicted-key cleanup with a different key than the
existing tests use. IRIS.md's replace_back/clear/apply entry gained the
side-table-clearing note the Docs finding asked for.

Also records this pass's DragGesture-merge verification in RUST.md (tap
stays vs swipe doesn't, a real fling keeps moving after release, keyboard
cycles confirmed via on_insets_changed) and annotates the two IRIS_TODO.md
phone-report items it targets.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 13:16:16 -04:00
95 changed files with 11144 additions and 776 deletions

No files matched your search

+29
View File
@@ -19,6 +19,21 @@ child process, translated into one common event model.** A new session type
is a new driver — never a session-type branch in shared code (routes, is a new driver — never a session-type branch in shared code (routes,
transcript, app screens). transcript, app screens).
The second one, for the Rust port on the `rustify` branch: **the phone app
and a planned desktop app share almost all of their code.** Screens, widgets,
folding, paging, config and the network client live in the shared crates
(`iris`, `client-core`, `transcript-ui`, `tabs-ui`); `android-app` and
`desktop-app` are thin entry points that own only what the platform forces
(JNI and the IME on one side, winit and argv on the other). The two
*layouts* will differ, to suit a phone's screen and a finger against a
desktop's screen and a mouse -- but the widgets a layout is made of (a
button, a text field, a list, a card) and the styling (colours, spacing,
type) are one implementation with no per-platform copy. Anything that could
work on both goes in a shared crate the first time it is written, and a
platform crate growing a widget or a colour is a defect to move, not a
convenience to keep. Iris said this on 2026-09-07; docs/RUST.md carries the
details.
## Layout ## Layout
Mirrors `../dev-updater` deliberately: same stack (axum 0.8 + Mirrors `../dev-updater` deliberately: same stack (axum 0.8 +
@@ -268,6 +283,20 @@ Each exists because something was invisible without it.
checkout's own emulator, taps "Run benchmark" by label, and prints the checkout's own emulator, taps "Run benchmark" by label, and prints the
report -- written so the P0 build/install/tap/read-report cycle stops report -- written so the P0 build/install/tap/read-report cycle stops
being retyped by hand each time (docs/RUST.md's P0 box). being retyped by hand each time (docs/RUST.md's P0 box).
- **iris's three test layers** (docs/RUST.md's "Three test layers" has
the commands and what each cannot answer): test at the cheapest one
that can answer the question. `cargo test -p transcript-fixture` runs
the real transcript screen over the bench fixture with **no window, no
compositor and no GPU** (`iris::harness`), on a clock the test owns and
a gesture replayed from a `t_ms action x y` file under
`iris/transcript-fixture/touch/` -- which is how the batched 120Hz
flick a finger actually makes is testable at all, since a `ui-trace`
swipe is many evenly-spaced events. `iris/run-headless.sh phone --phone
--shot …` opens the same screen in a window at the phone's own size and
density for looking at, and `--replay FILE` drives the same recording
into it. The emulator is for JNI, the IME, insets, the surface
lifecycle and one verification run before a build goes to the phone --
not for iterating on layout.
### Driving the UI ### Driving the UI
+47
View File
@@ -50,6 +50,12 @@ version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "bitflags"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.12.1" version = "1.12.1"
@@ -77,6 +83,7 @@ name = "client-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"event-model", "event-model",
"pulldown-cmark",
"serde", "serde",
"serde_json", "serde_json",
"ureq", "ureq",
@@ -206,6 +213,15 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "getopts"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
dependencies = [
"unicode-width",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.17" version = "0.2.17"
@@ -490,6 +506,25 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "pulldown-cmark"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
dependencies = [
"bitflags",
"getopts",
"memchr",
"pulldown-cmark-escape",
"unicase",
]
[[package]]
name = "pulldown-cmark-escape"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.47" version = "1.0.47"
@@ -783,12 +818,24 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"
+41
View File
@@ -47,6 +47,7 @@ name = "client-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"event-model", "event-model",
"pulldown-cmark",
"serde", "serde",
"serde_json", "serde_json",
"tempfile", "tempfile",
@@ -173,6 +174,15 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "getopts"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
dependencies = [
"unicode-width",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.17" version = "0.2.17"
@@ -425,6 +435,25 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "pulldown-cmark"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
dependencies = [
"bitflags",
"getopts",
"memchr",
"pulldown-cmark-escape",
"unicase",
]
[[package]]
name = "pulldown-cmark-escape"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.47" version = "1.0.47"
@@ -661,12 +690,24 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"
+6
View File
@@ -32,6 +32,12 @@ serde_json = { version = "1", features = ["float_roundtrip", "raw_value"] }
# no need of an async runtime, and RUST.md's brief for this port is # no need of an async runtime, and RUST.md's brief for this port is
# "lightweight" throughout. # "lightweight" throughout.
ureq = { version = "3", features = ["json"] } ureq = { version = "3", features = ["json"] }
# The markdown block split (`markdown_blocks`), which has to agree with the
# renderer in `iris/transcript-ui` about where a block begins -- so it is
# the same parser at the same version, rather than a hand-written splitter
# that would drift from it.
pulldown-cmark = "0.13.4"
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"
+100
View File
@@ -0,0 +1,100 @@
//! A span of milliseconds, written the way somebody reads it -- the port
//! of `Durations.kt`'s `formatMillis`/`formatMillisText`, with its tests.
//!
//! Only the tool-timeout half is here. `formatSpan` (the usage
//! countdown's rounding-up rule) belongs with whatever draws the usage
//! bar, and nothing in this crate needs it yet.
/// A span of milliseconds, written the way somebody reads it.
///
/// A tool's timeout arrives as `480000`, which nobody reads as eight
/// minutes. The rule has two halves, because a short span and a long one
/// are read for different things. Under a minute the question is "roughly
/// how long", so only the largest unit is shown and a fraction carries the
/// rest -- `2.5s`. At a minute or more the question is "how long exactly",
/// so every unit with something in it is written out -- `5d 12h 4m`. Empty
/// units are left out rather than written as zero.
///
/// Sub-second precision is dropped past a minute: nothing that takes days
/// is measured in milliseconds.
pub fn format_millis(ms: i64) -> String {
if ms < 0 {
return format!("-{}", format_millis(-ms));
}
if ms < 1000 {
return format!("{ms}ms");
}
if ms < 60_000 {
let tenths = (ms + 50) / 100;
let (whole, rest) = (tenths / 10, tenths % 10);
return if rest == 0 {
format!("{whole}s")
} else {
format!("{whole}.{rest}s")
};
}
let seconds = ms / 1000;
[
("d", seconds / 86_400),
("h", seconds / 3600 % 24),
("m", seconds / 60 % 60),
("s", seconds % 60),
]
.iter()
.filter(|(_, n)| *n > 0)
.map(|(unit, n)| format!("{n}{unit}"))
.collect::<Vec<_>>()
.join(" ")
}
/// `text` as a span when it is a whole number of milliseconds, and
/// unchanged when it is not.
pub fn format_millis_text(text: &str) -> String {
match text.trim().parse::<i64>() {
Ok(ms) => format_millis(ms),
Err(_) => text.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The two ways a span of time is written here, and the rule each of
/// them follows -- ported from `DurationsTest.kt`, whose doc says why:
/// both are read off a screen to make a decision, so what matters is
/// that the shortest form that answers the question is what appears.
#[test]
fn under_a_minute_is_the_largest_unit_alone() {
assert_eq!(format_millis(30), "30ms");
assert_eq!(format_millis(999), "999ms");
assert_eq!(format_millis(1000), "1s");
assert_eq!(format_millis(2500), "2.5s");
// One decimal, rounded rather than cut: 2.46s is nearer two and a
// half than two and four.
assert_eq!(format_millis(2460), "2.5s");
assert_eq!(format_millis(59_900), "59.9s");
}
#[test]
fn a_minute_or_more_is_every_unit_that_has_something_in_it() {
// The figure this rule was written for: a tool timeout, which
// arrives as milliseconds and is unreadable as 480000.
assert_eq!(format_millis(480_000), "8m");
assert_eq!(format_millis(60_000), "1m");
assert_eq!(format_millis(90_000), "1m 30s");
assert_eq!(format_millis(475_440_000), "5d 12h 4m");
// Empty units are left out rather than written as zero: the labels
// say which is which, and "5d 0h 4m" is only longer.
assert_eq!(format_millis(432_240_000), "5d 4m");
}
#[test]
fn only_a_whole_number_of_milliseconds_is_rewritten() {
assert_eq!(format_millis_text(" 480000 "), "8m");
// A timeout a tool expressed some other way is its own words,
// passed through rather than guessed at.
assert_eq!(format_millis_text("2 minutes"), "2 minutes");
assert_eq!(format_millis_text(""), "");
}
}
+3
View File
@@ -5,10 +5,13 @@
pub mod ansi; pub mod ansi;
pub mod api; pub mod api;
pub mod config; pub mod config;
pub mod durations;
pub mod event_stream; pub mod event_stream;
pub mod highlight; pub mod highlight;
pub mod markdown_blocks;
pub mod notifications; pub mod notifications;
pub mod sse; pub mod sse;
pub mod tool_summary;
pub mod transcript_cache; pub mod transcript_cache;
pub mod transcript_fold; pub mod transcript_fold;
pub mod transcript_source; pub mod transcript_source;
+325
View File
@@ -0,0 +1,325 @@
//! Split a markdown message into its top-level **blocks** -- one
//! paragraph, heading, fenced code block, list, table or quote each, as a
//! byte slice of the original source.
//!
//! This exists for streaming. A transcript row used to be one text widget
//! holding the whole message, so a single streamed delta re-shaped every
//! paragraph of it through the text engine again; the phone's bench v2 put
//! the stream phase at p50 18.2ms against Compose's 13.4ms for exactly
//! that reason (docs/IRIS_TODO.md). A row is a column of one widget per
//! block now, and a delta that lands in the last block leaves every
//! earlier block's layout alone. `docs/DECISIONS.md`'s 2026-09-06 entry has
//! what that rejected and why the split lives here rather than in the UI
//! crate: `docs/CLIENT_CORE.md` already wanted a block model for P1, and
//! keeping it here means iris stays a text renderer that knows nothing
//! about markdown.
//!
//! **Blocks only.** Inline styling (bold, links, inline code) is still the
//! renderer's own job, per block -- this deliberately does not build a
//! full AST, because nothing needs one yet.
//!
//! ## Appending is not guaranteed to leave earlier blocks alone
//!
//! It nearly always does, which is what makes the fast path worth having,
//! but markdown has no such rule: appending a "```" line can turn text
//! that was three paragraphs into one fenced block, and appending "---"
//! under a paragraph turns that paragraph into a heading. So a caller
//! taking the O(last block) path **must compare the prefix it is about to
//! keep** rather than assume it. [`common_prefix`] is that comparison, and
//! it is cheap next to laying the text out again.
use pulldown_cmark::{Event, Options, Parser, Tag};
/// What a block is, for a renderer that wants to style or space blocks
/// differently. `Other` is deliberately present rather than a panic or a
/// silent fallback to `Paragraph`: markdown has more block kinds than this
/// list and more get added, and a renderer treating an unknown one as
/// prose is right, but it should be able to *tell* that is what it is
/// doing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BlockKind {
Paragraph,
Heading,
/// A fenced or indented code block.
Code,
List,
Table,
Quote,
/// A thematic break, raw HTML, a footnote -- anything with no
/// distinguished treatment here.
Other,
}
/// One top-level block: its kind and the exact source that produced it.
/// `source` is a slice of the input with trailing whitespace removed, so
/// two splits of the same prefix compare equal even when one of them had a
/// delta arriving after it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Block {
pub kind: BlockKind,
pub source: String,
}
fn kind_of(tag: &Tag) -> BlockKind {
match tag {
Tag::Paragraph => BlockKind::Paragraph,
Tag::Heading { .. } => BlockKind::Heading,
Tag::CodeBlock(_) => BlockKind::Code,
Tag::List(_) => BlockKind::List,
Tag::Table(_) => BlockKind::Table,
Tag::BlockQuote(_) => BlockKind::Quote,
_ => BlockKind::Other,
}
}
fn options() -> Options {
// The same set `transcript-ui`'s renderer parses with, so a block
// boundary here and the styling there cannot disagree about what the
// source means.
Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS
}
/// Split `src` into its top-level blocks, in source order. An empty or
/// whitespace-only input gives no blocks; text the parser does not put
/// inside any block (a stray fence marker mid-stream) still comes back,
/// as `Other`, rather than being dropped.
pub fn split_blocks(src: &str) -> Vec<Block> {
let mut out: Vec<Block> = Vec::new();
let mut depth = 0usize;
let mut kind = BlockKind::Other;
for (event, range) in Parser::new_ext(src, options()).into_offset_iter() {
match event {
Event::Start(tag) => {
if depth == 0 {
kind = kind_of(&tag);
}
depth += 1;
}
Event::End(_) => {
depth -= 1;
if depth == 0 {
push(&mut out, kind, &src[range]);
}
}
// A top-level event that is not part of any block -- a
// thematic break, a block of raw HTML. Inside one, it is the
// enclosing block's business and this does nothing.
_ => {
if depth == 0 {
push(&mut out, BlockKind::Other, &src[range]);
}
}
}
}
out
}
fn push(out: &mut Vec<Block>, kind: BlockKind, source: &str) {
let source = source.trim_end();
if source.is_empty() {
return;
}
out.push(Block {
kind,
source: source.to_string(),
});
}
/// How many leading blocks of `old` and `new` are identical -- what a
/// caller may keep the laid-out widgets for. See the module doc for why
/// this is a comparison rather than an assumption.
pub fn common_prefix(old: &[Block], new: &[Block]) -> usize {
old.iter().zip(new).take_while(|(a, b)| a == b).count()
}
#[cfg(test)]
mod tests {
use super::*;
fn kinds(src: &str) -> Vec<BlockKind> {
split_blocks(src).into_iter().map(|b| b.kind).collect()
}
#[test]
fn a_message_splits_into_its_top_level_blocks() {
let src = "# Title\n\nFirst para.\n\n```rust\nfn main() {}\n```\n\n- a\n- b\n";
assert_eq!(
kinds(src),
vec![
BlockKind::Heading,
BlockKind::Paragraph,
BlockKind::Code,
BlockKind::List
]
);
let blocks = split_blocks(src);
assert_eq!(blocks[1].source, "First para.");
assert_eq!(blocks[2].source, "```rust\nfn main() {}\n```");
}
#[test]
fn blank_input_has_no_blocks() {
assert!(split_blocks("").is_empty());
assert!(split_blocks(" \n\n ").is_empty());
}
/// The property the streaming fast path rests on, in its ordinary
/// shape: a delta landing in the last paragraph must leave every
/// earlier block byte-identical.
#[test]
fn a_delta_into_the_last_paragraph_leaves_earlier_blocks_untouched() {
let before = split_blocks("# Title\n\nFirst para.\n\nSecond par");
let after = split_blocks("# Title\n\nFirst para.\n\nSecond paragraph now.");
assert_eq!(common_prefix(&before, &after), 2);
assert_eq!(before.len(), 3);
assert_eq!(after.len(), 3);
assert_ne!(before[2], after[2]);
}
/// A delta that starts a *new* block keeps every old block, including
/// the one that was last -- so the fast path appends rather than
/// replacing.
#[test]
fn a_delta_that_starts_a_new_block_keeps_every_old_one() {
let before = split_blocks("First para.\n\nSecond para.");
let after = split_blocks("First para.\n\nSecond para.\n\nThird");
assert_eq!(common_prefix(&before, &after), 2);
assert_eq!(after.len(), 3);
}
/// A code fence arrives one delta at a time and is unterminated for
/// most of its life. It must still be *one* block the whole way, or
/// every delta would re-split the message into a different number of
/// pieces.
#[test]
fn an_unterminated_fence_is_one_block_while_it_streams() {
for src in [
"Here:\n\n```rust\n",
"Here:\n\n```rust\nfn main() {\n",
"Here:\n\n```rust\nfn main() {\n println!(\"hi\");\n",
] {
assert_eq!(
kinds(src),
vec![BlockKind::Paragraph, BlockKind::Code],
"{src:?}"
);
}
}
/// The half the fast path had no reason to touch, and the reason
/// `common_prefix` is a comparison rather than an assumption:
/// appending can rewrite what came before. `---` under a paragraph
/// turns that paragraph into a setext heading, so the block that was
/// already laid out is not the block it is now.
#[test]
fn appending_can_rewrite_an_earlier_block_and_the_prefix_says_so() {
let before = split_blocks("Not a heading\n\nsecond");
let after = split_blocks("Not a heading\n\nsecond\n---");
assert_eq!(before[1].kind, BlockKind::Paragraph);
assert_eq!(after[1].kind, BlockKind::Heading);
assert_eq!(
common_prefix(&before, &after),
1,
"the rewritten block must not be reported as keepable"
);
}
#[test]
fn a_thematic_break_is_its_own_block() {
assert_eq!(
kinds("one\n\n---\n\ntwo"),
vec![BlockKind::Paragraph, BlockKind::Other, BlockKind::Paragraph]
);
}
/// The shapes a real transcript actually contains, each checked for
/// the one property the streaming fast path needs: the *number* of
/// blocks and every earlier block's source stay put while the message
/// grows. A fence's own blank lines, a `---` inside one, a nested
/// list and a table are all places where a naive line-based split
/// would break the message into more pieces than there are blocks.
#[test]
fn the_transcripts_own_block_shapes_survive_a_split() {
let fence_with_blanks = "Intro.\n\n```rust\nfn a() {}\n\nfn b() {}\n```\n\nAfter.";
assert_eq!(
kinds(fence_with_blanks),
vec![BlockKind::Paragraph, BlockKind::Code, BlockKind::Paragraph],
"a blank line inside a fence is not a block boundary"
);
assert_eq!(
kinds("```\n---\n```"),
vec![BlockKind::Code],
"a thematic break inside a fence is code, not a break"
);
assert_eq!(
kinds("- a\n - a1\n - a2\n- b"),
vec![BlockKind::List],
"a nested list is one top-level block"
);
assert_eq!(
kinds("## Heading\n```sh\nls\n```"),
vec![BlockKind::Heading, BlockKind::Code],
"a fence directly under a heading, with no blank line"
);
assert_eq!(
kinds("| a | b |\n|---|---|\n| 1 | 2 |"),
vec![BlockKind::Table]
);
assert_eq!(
kinds("> quoted\n> more\n\nplain"),
vec![BlockKind::Quote, BlockKind::Paragraph]
);
}
/// `apply_delta`'s precondition, stated as the property rather than
/// the arithmetic: for every prefix of a realistic streamed message,
/// the blocks before the last one must be exactly the blocks the
/// previous prefix had. Where markdown breaks that (the `---` case
/// above), `common_prefix` has to *say* so -- which is what the
/// `>= len - 1` assertion below checks: the split may rewrite the
/// last block, never an earlier one, or `RowBlocks::apply_delta`
/// would keep a widget whose text is no longer what it holds.
#[test]
fn every_prefix_of_a_streamed_message_keeps_all_but_its_last_block() {
let full = "# Report\n\nFirst finding, at some length.\n\n```rust\nfn main() {\n\n println!(\"hi\");\n}\n```\n\n- one\n - nested\n- two\n\n| a | b |\n |---|---|\n| 1 | 2 |\n\n> and a closing quote.";
// Every character boundary, so a delta landing mid-word and one
// landing exactly on a fence's closing backtick are both covered.
let mut prev = Vec::new();
for end in full.char_indices().map(|(i, _)| i).chain([full.len()]) {
let now = split_blocks(&full[..end]);
let common = common_prefix(&prev, &now);
assert!(
prev.is_empty() || common + 1 >= prev.len(),
"at {end} bytes the split rewrote block {common} of {}, not just the last one:\n before={prev:#?}\nafter={now:#?}",
prev.len()
);
prev = now;
}
}
/// The half a growing message cannot show: a fence that never closes.
/// The stream ends there and the block must still be the code block
/// it has been all along, not re-split into paragraphs.
#[test]
fn a_stream_that_ends_inside_a_fence_still_ends_with_one_code_block() {
let src = "Here is the patch:\n\n```diff\n- old line\n+ new line";
let blocks = split_blocks(src);
assert_eq!(
blocks.iter().map(|b| b.kind).collect::<Vec<_>>(),
vec![BlockKind::Paragraph, BlockKind::Code]
);
assert_eq!(blocks[1].source, "```diff\n- old line\n+ new line");
}
/// A delta that closes a fence changes the *last* block only, so the
/// fast path takes it -- the case the module doc says is the reason
/// `common_prefix` is a comparison.
#[test]
fn the_delta_that_closes_a_fence_changes_only_the_last_block() {
let before = split_blocks("Text.\n\n```\ncode\n");
let after = split_blocks("Text.\n\n```\ncode\n```");
assert_eq!(before.len(), after.len());
assert_eq!(common_prefix(&before, &after), 1);
assert_ne!(before[1], after[1]);
}
}
+244
View File
@@ -0,0 +1,244 @@
//! A tool call's input, read rather than dumped -- the port of
//! `ToolInput.kt`'s `parseToolInput`, which is what both the collapsed
//! card's one-line summary and the expanded card's key/value list are
//! derived from.
//!
//! Every tool's input arrives as JSON, and showing it raw makes the reader
//! parse `{"command":"…","timeout":120000}` themselves to find the one
//! line they care about. So the fields that carry the meaning are pulled
//! out, and anything left over is still shown, because dropping a field
//! would be claiming the tool has no other input when it might.
//!
//! Pure, and here rather than in the widget crate, for the reason the rest
//! of this crate exists: the derivation is the same on a phone and on a
//! desktop, and it is testable without a renderer.
use crate::durations::format_millis_text;
use crate::highlight::Language;
use serde_json::{Map, Value};
/// A tool call's input, split into the parts a card draws separately.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ToolInput {
/// The thing that will actually be run or read, if this tool has one.
pub subject: Option<String>,
/// The language [`ToolInput::subject`] is written in, for
/// highlighting.
pub language: Option<Language>,
/// The tool's own one-line summary, when it wrote one.
pub description: Option<String>,
/// How long the call may take, in the largest units it fits. Shown
/// apart because it is a limit on the call rather than part of what
/// the call does.
pub timeout: Option<String>,
/// Everything else, as `name: value` lines. Never dropped.
pub rest: Vec<String>,
}
impl ToolInput {
/// The one line to show when there is only room for one: what this
/// call is for.
pub fn title(&self) -> Option<&str> {
self.description
.as_deref()
.or(self.subject.as_deref())
// A subject that is only whitespace would draw as an empty
// summary line, which reads as a tool with nothing to say
// rather than as one whose subject was blank.
.filter(|t| !t.trim().is_empty())
}
}
/// Which field of which tool is the subject.
///
/// A table rather than a chain of `if`s: adding a tool is a row, and the
/// shape stops any of them from being the special case that gets its own
/// code path. Unknown tools fall through to "no subject, everything is
/// rest".
const SUBJECTS: &[(&str, &str, Option<Language>)] = &[
("Bash", "command", Some(Language::Shell)),
("Read", "file_path", None),
("Write", "file_path", None),
("Edit", "file_path", None),
("Glob", "pattern", None),
("Grep", "pattern", None),
("WebFetch", "url", None),
];
/// Fields that are the tool's own prose about itself rather than input to
/// it.
const DESCRIPTIONS: &[&str] = &["description", "prompt"];
/// One JSON value as the Kotlin's `JSONObject.optString`/`get` wrote it: a
/// string is its own characters, anything else is its JSON form.
///
/// One function rather than two, because the same coercion decides both
/// what a subject reads as and what a leftover field's value reads as, and
/// two copies would eventually disagree about a number.
fn as_text(value: &Value) -> String {
match value {
Value::String(s) => s.clone(),
other => other.to_string(),
}
}
fn non_blank(value: Option<&Value>) -> Option<String> {
let text = as_text(value?);
(!text.trim().is_empty()).then_some(text)
}
/// Split `input` (a tool call's JSON) into the parts a card draws.
///
/// Input that is not a JSON object -- older transcripts and some tools
/// send a bare string -- is still the input, so it is still shown, as the
/// whole of `rest`.
pub fn parse_tool_input(tool: &str, input: &str) -> ToolInput {
let Ok(Value::Object(json)) = serde_json::from_str::<Value>(input) else {
return ToolInput {
rest: match input.trim().is_empty() {
true => Vec::new(),
false => vec![input.to_string()],
},
..ToolInput::default()
};
};
parse_object(tool, &json)
}
fn parse_object(tool: &str, json: &Map<String, Value>) -> ToolInput {
let (subject_key, language) = SUBJECTS
.iter()
.find(|(name, ..)| *name == tool)
.map(|(_, key, language)| (Some(*key), *language))
.unwrap_or((None, None));
let subject = subject_key.and_then(|key| non_blank(json.get(key)));
let description = DESCRIPTIONS
.iter()
.find_map(|key| non_blank(json.get(*key)));
let timeout = non_blank(json.get("timeout")).map(|t| format_millis_text(&t));
// Sorted, so the leftovers are in the same order every time this call
// is drawn rather than in whatever order the JSON happened to arrive
// in. A field is left out only when it is already drawn somewhere
// else on the card.
let mut keys: Vec<&String> = json
.keys()
.filter(|k| Some(k.as_str()) != subject_key || subject.is_none())
.filter(|k| !DESCRIPTIONS.contains(&k.as_str()) || description.is_none())
.filter(|k| k.as_str() != "timeout" || timeout.is_none())
.collect();
keys.sort();
let rest = keys
.into_iter()
.map(|key| format!("{key}: {}", as_text(&json[key])))
.collect();
ToolInput {
subject,
language,
description,
timeout,
rest,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn each_tool_in_the_table_has_its_own_subject() {
// One assertion per row of `SUBJECTS`, because the table is the
// whole of the rule and a row lost in an edit would otherwise
// only show up as a card with no summary line.
let cases = [
("Bash", r#"{"command":"ls -la"}"#, "ls -la"),
("Read", r#"{"file_path":"/tmp/x.rs"}"#, "/tmp/x.rs"),
("Write", r#"{"file_path":"/tmp/y.rs"}"#, "/tmp/y.rs"),
("Edit", r#"{"file_path":"/tmp/z.rs"}"#, "/tmp/z.rs"),
("Glob", r#"{"pattern":"**/*.rs"}"#, "**/*.rs"),
("Grep", r#"{"pattern":"fn main"}"#, "fn main"),
("WebFetch", r#"{"url":"https://x/y"}"#, "https://x/y"),
];
for (tool, input, expected) in cases {
let parsed = parse_tool_input(tool, input);
assert_eq!(parsed.subject.as_deref(), Some(expected), "{tool}");
assert_eq!(parsed.title(), Some(expected), "{tool}");
assert!(parsed.rest.is_empty(), "{tool}: {:?}", parsed.rest);
}
assert_eq!(
parse_tool_input("Bash", r#"{"command":"ls"}"#).language,
Some(Language::Shell),
"a Bash command is shell, and is the one row that names a language"
);
}
#[test]
fn a_tools_own_description_is_what_the_one_line_says() {
// The description wins over the subject: it is the tool's own
// prose about what this call is for, which is what a reader
// scanning a collapsed run is looking for.
let parsed = parse_tool_input(
"Bash",
r#"{"command":"cargo test -p iris","description":"Run the iris tests"}"#,
);
assert_eq!(parsed.title(), Some("Run the iris tests"));
assert_eq!(parsed.subject.as_deref(), Some("cargo test -p iris"));
assert!(parsed.rest.is_empty(), "{:?}", parsed.rest);
}
#[test]
fn a_timeout_is_read_as_a_span_and_kept_apart_from_the_rest() {
let parsed = parse_tool_input("Bash", r#"{"command":"sleep 500","timeout":480000}"#);
assert_eq!(parsed.timeout.as_deref(), Some("8m"));
assert!(parsed.rest.is_empty(), "{:?}", parsed.rest);
}
#[test]
fn every_field_not_drawn_elsewhere_is_still_shown() {
// The half the "never dropped" promise is about: a tool this
// build has never heard of has no subject, so *everything* is
// rest -- and a known tool's extra fields are too.
let parsed = parse_tool_input(
"Edit",
r#"{"file_path":"/a.rs","old_string":"x","new_string":"y","replace_all":true}"#,
);
assert_eq!(
parsed.rest,
vec![
"new_string: y".to_string(),
"old_string: x".to_string(),
"replace_all: true".to_string(),
],
"sorted, and a non-string value written as JSON"
);
let unknown = parse_tool_input("SomeNewTool", r#"{"b":2,"a":"one"}"#);
assert_eq!(unknown.subject, None);
assert_eq!(unknown.rest, vec!["a: one".to_string(), "b: 2".to_string()]);
}
#[test]
fn input_that_is_not_an_object_is_still_the_input() {
// Older transcripts and some tools send a bare string; a card
// that dropped it would claim the call had no input at all.
assert_eq!(
parse_tool_input("Bash", "just a string").rest,
vec!["just a string".to_string()]
);
assert_eq!(parse_tool_input("Bash", " ").rest, Vec::<String>::new());
assert_eq!(parse_tool_input("Bash", "").title(), None);
}
#[test]
fn a_blank_subject_is_no_subject_rather_than_an_empty_summary_line() {
let parsed = parse_tool_input("Bash", r#"{"command":" ","other":1}"#);
assert_eq!(parsed.subject, None);
assert_eq!(parsed.title(), None);
// Not dropped just because it was blank -- it is still a field
// the call carried.
assert_eq!(
parsed.rest,
vec!["command: ".to_string(), "other: 1".to_string()]
);
}
}
+241 -2
View File
@@ -78,6 +78,11 @@ pub enum TranscriptItem {
input: String, input: String,
output: String, output: String,
done: bool, done: bool,
/// Whether the result that arrived said the call failed
/// ([`Event::ToolEnd`]'s `is_error`). Meaningless while `done` is
/// false, and [`ToolState::of`] is the only thing that reads the
/// pair, so the two cannot be combined wrongly at a call site.
failed: bool,
asks: Vec<QuestionCard>, asks: Vec<QuestionCard>,
images: Vec<String>, images: Vec<String>,
}, },
@@ -352,6 +357,7 @@ pub fn join_pages(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<T
let &TranscriptItem::ToolRun { let &TranscriptItem::ToolRun {
ref output, ref output,
done, done,
failed,
asks: ref half_asks, asks: ref half_asks,
images: ref half_images, images: ref half_images,
.. ..
@@ -367,6 +373,7 @@ pub fn join_pages(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<T
input, input,
output: output.clone(), output: output.clone(),
done, done,
failed,
// Kept from both halves: a question or an image can be // Kept from both halves: a question or an image can be
// attached to either, depending on which side of the // attached to either, depending on which side of the
// boundary its event fell. // boundary its event fell.
@@ -562,6 +569,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
input: input.to_string(), input: input.to_string(),
output: String::new(), output: String::new(),
done: false, done: false,
failed: false,
asks: Vec::new(), asks: Vec::new(),
images: Vec::new(), images: Vec::new(),
}); });
@@ -572,15 +580,23 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
*out = output.clone(); *out = output.clone();
} }
}), }),
Event::ToolEnd { id, output } => { Event::ToolEnd {
id,
output,
is_error,
} => {
if items.iter().any(|i| i.as_tool_run() == Some(id.as_str())) { if items.iter().any(|i| i.as_tool_run() == Some(id.as_str())) {
update_tool(items, id, |item| { update_tool(items, id, |item| {
if let TranscriptItem::ToolRun { if let TranscriptItem::ToolRun {
output: out, done, .. output: out,
done,
failed,
..
} = item } = item
{ {
*out = output.clone(); *out = output.clone();
*done = true; *done = true;
*failed = *is_error;
} }
}) })
} else { } else {
@@ -594,6 +610,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
input: String::new(), input: String::new(),
output: output.clone(), output: output.clone(),
done: true, done: true,
failed: *is_error,
asks: Vec::new(), asks: Vec::new(),
images: Vec::new(), images: Vec::new(),
}); });
@@ -650,6 +667,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
input, input,
output, output,
done, done,
failed,
images, images,
} if asks.iter().any(|a| &a.id == id) => { } if asks.iter().any(|a| &a.id == id) => {
for ask in asks.iter_mut() { for ask in asks.iter_mut() {
@@ -665,6 +683,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
input, input,
output, output,
done, done,
failed,
asks, asks,
images, images,
} }
@@ -750,6 +769,74 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
} }
} }
/// What became of one tool call -- every state a card has to be able to
/// draw, including the two that are not answers.
///
/// The pair this enum exists for is [`ToolState::Succeeded`] against
/// [`ToolState::NoResult`]. A call that finished having printed nothing
/// and a call whose result never arrived both leave an empty `output`,
/// and drawing them the same way states a verdict nobody reached: "it
/// worked and said nothing" reads as a fact, where the truth is that the
/// turn ended before anything came back.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolState {
/// Started, no result yet, and the session is still working -- the
/// ordinary state of a call in flight.
Running,
/// Stopped on the reader: a permission or question this call carries
/// has not been answered, so nothing is happening until somebody
/// answers it. Distinct from [`Self::Running`] because whose move it
/// is differs, which is the Compose card's "your turn".
Deciding,
/// A result arrived and the tool did not report a failure.
Succeeded,
/// A result arrived and the tool reported that the call failed
/// (`is_error`).
Failed,
/// No result ever arrived and the session is not working any more --
/// the turn was interrupted, or the process went away. Not a verdict
/// on the call: it says only that nobody found out.
NoResult,
}
impl ToolState {
/// The state of one call. `session_working` is
/// [`session_working`]'s answer for the session this call is in --
/// the only thing here that is not a property of the call itself, and
/// what separates "still running" from "never came back".
///
/// Written once, over the fields rather than per call site, because
/// the five states are decided by four conditions and every place
/// that re-derived a subset of them got a different subset.
pub fn of(item: &TranscriptItem, session_working: bool) -> Option<Self> {
let TranscriptItem::ToolRun {
done, failed, asks, ..
} = item
else {
return None;
};
debug_assert!(
!failed || *done,
"a call cannot have failed before its result arrived"
);
Some(if asks.iter().any(|ask| ask.answers.is_empty()) {
// Ahead of `done`: a call waiting on permission has not
// finished either, and which of the two the reader is being
// told about is the one they can act on.
Self::Deciding
} else if !*done {
match session_working {
true => Self::Running,
false => Self::NoResult,
}
} else if *failed {
Self::Failed
} else {
Self::Succeeded
})
}
}
/// One row as the transcript draws it: a run of consecutive tool calls, or /// One row as the transcript draws it: a run of consecutive tool calls, or
/// anything else. Ported from `ToolRows.kt`'s `TranscriptRow` and /// anything else. Ported from `ToolRows.kt`'s `TranscriptRow` and
/// `groupToolRuns` -- the Compose card rendering in that file is not part /// `groupToolRuns` -- the Compose card rendering in that file is not part
@@ -989,6 +1076,7 @@ mod tests {
Event::ToolEnd { Event::ToolEnd {
id: "x".to_string(), id: "x".to_string(),
output: "done".to_string(), output: "done".to_string(),
is_error: false,
}, },
)]); )]);
assert_eq!( assert_eq!(
@@ -1001,6 +1089,7 @@ mod tests {
input: String::new(), input: String::new(),
output: "done".to_string(), output: "done".to_string(),
done: true, done: true,
failed: false,
asks: Vec::new(), asks: Vec::new(),
images: Vec::new(), images: Vec::new(),
}] }]
@@ -1166,6 +1255,7 @@ mod tests {
Event::ToolEnd { Event::ToolEnd {
id: id.to_string(), id: id.to_string(),
output: output.to_string(), output: output.to_string(),
is_error: false,
}, },
) )
} }
@@ -1211,6 +1301,7 @@ mod tests {
input: "{}".to_string(), input: "{}".to_string(),
output: "the result".to_string(), output: "the result".to_string(),
done: true, done: true,
failed: false,
asks: Vec::new(), asks: Vec::new(),
images: Vec::new(), images: Vec::new(),
}], }],
@@ -1269,6 +1360,7 @@ mod tests {
input: "{}".to_string(), input: "{}".to_string(),
output: String::new(), output: String::new(),
done: false, done: false,
failed: false,
asks: Vec::new(), asks: Vec::new(),
images: Vec::new(), images: Vec::new(),
}]; }];
@@ -1279,3 +1371,150 @@ mod tests {
} }
} }
} }
/// [`ToolState`] is what a card colours itself by, so each of its five
/// states is asserted from the events that actually produce it rather than
/// from a hand-built item -- a mapping that agreed with a fixture and
/// disagreed with the fold would be invisible until it was on screen.
#[cfg(test)]
mod tool_state_tests {
use super::*;
fn event(seq: u64, e: Event) -> SeqEvent {
SeqEvent {
seq,
ts: 0.0,
event: e,
}
}
fn fold_all(events: &[SeqEvent]) -> Vec<TranscriptItem> {
events
.iter()
.fold(Vec::new(), |items, e| fold_event(&items, e))
}
fn start(id: &str) -> SeqEvent {
event(
1,
Event::ToolStart {
id: id.to_string(),
tool: "Bash".to_string(),
input: serde_json::json!({"command": "ls"}),
},
)
}
fn end(id: &str, output: &str, is_error: bool) -> SeqEvent {
event(
2,
Event::ToolEnd {
id: id.to_string(),
output: output.to_string(),
is_error,
},
)
}
fn state_of(events: &[SeqEvent], session_working: bool) -> ToolState {
let items = fold_all(events);
ToolState::of(&items[0], session_working).expect("the fixture's first item is a tool call")
}
#[test]
fn a_result_that_arrived_is_read_from_is_error() {
assert_eq!(
state_of(&[start("a"), end("a", "ok", false)], false),
ToolState::Succeeded
);
assert_eq!(
state_of(&[start("a"), end("a", "No such file", true)], false),
ToolState::Failed
);
}
/// The pair this enum exists for. Both calls have an empty `output`
/// and nothing else distinguishes them, so a card that only looked at
/// the text would draw the interrupted one as a call that ran fine and
/// printed nothing.
#[test]
fn a_call_that_printed_nothing_is_not_a_call_that_never_answered() {
assert_eq!(
state_of(&[start("a"), end("a", "", false)], false),
ToolState::Succeeded,
"a result arrived; it was empty"
);
assert_eq!(
state_of(&[start("a")], false),
ToolState::NoResult,
"no result, and the session is not working any more"
);
}
/// The same call, mid-turn: still running rather than abandoned. The
/// only thing separating the two is the session's own status, which is
/// why `of` takes it.
#[test]
fn no_result_while_the_session_works_is_still_running() {
assert_eq!(state_of(&[start("a")], true), ToolState::Running);
}
#[test]
fn an_unanswered_ask_is_the_readers_move_whatever_else_is_true() {
let asking = event(
3,
Event::Question {
id: "q1".to_string(),
prompt: "Allow?".to_string(),
header: None,
options: vec![QuestionOption {
label: "Allow".to_string(),
description: None,
preview: None,
}],
multi_select: false,
about: Some("a".to_string()),
},
);
let answered = event(
4,
Event::Answered {
id: "q1".to_string(),
answers: vec!["Allow".to_string()],
},
);
// Ahead of both "still running" and "no result": the reader can
// act on this one, and cannot act on either of those.
assert_eq!(
state_of(&[start("a"), asking.clone()], true),
ToolState::Deciding
);
assert_eq!(
state_of(&[start("a"), asking.clone()], false),
ToolState::Deciding
);
assert_eq!(
state_of(
&[start("a"), asking, answered, end("a", "ok", false)],
false
),
ToolState::Succeeded,
"once it is answered the call is an ordinary one again"
);
}
#[test]
fn nothing_but_a_tool_call_has_a_tool_state() {
assert_eq!(
ToolState::of(
&TranscriptItem::UserMsg {
seq: 1,
text: "hi".to_string(),
attachments: Vec::new(),
},
true
),
None
);
}
}
+12 -9
View File
@@ -191,14 +191,17 @@ does not repeat it again by hand.
## What is not started at all ## What is not started at all
- **The markdown *block* model beyond syntax spans** -- `highlight/markdown.rs` - **A full markdown AST.** `markdown_blocks` (2026-09-06) splits a message
colours a `.md` file or fence for the highlighter, but does not build the into its *top-level* blocks -- heading, paragraph, fence, list, table,
block tree (headings, lists, tables, fences as distinct nodes) that a quote -- with each block's own source, which is what a renderer needs to
renderer walks to lay out prose versus code versus a table. lay out prose versus code and what lets a streamed delta re-lay out one
`CodeFence.kt`'s use of `org.intellij.markdown` for that full CommonMark block instead of the message (docs/RUST.md's Task B). What it
AST is Compose rendering plumbing, not something to port as-is; a Rust deliberately does **not** build is the tree below that: nested list
UI layer will want its own block parser or a crate for it, decided items, table cells, inline spans. Inline styling is still the renderer's
alongside the framework choice in RUST.md. own job per block (`iris/transcript-ui/src/markdown.rs`), and nothing
has needed the rest yet. `CodeFence.kt`'s use of `org.intellij.markdown`
for a full CommonMark AST is Compose rendering plumbing, not something
to port as-is.
- **`TranscriptUnits.kt`** (see above) -- deliberately out of scope, since - **`TranscriptUnits.kt`** (see above) -- deliberately out of scope, since
it flattens a row into bounded units for a *specific* lazy-list it flattens a row into bounded units for a *specific* lazy-list
framework's composition cost, which is a fact about that framework framework's composition cost, which is a fact about that framework
@@ -209,5 +212,5 @@ does not repeat it again by hand.
`./run-tests.sh` from the repo root now runs `event-model`, `client-core` `./run-tests.sh` from the repo root now runs `event-model`, `client-core`
and `server` in that order (each `cargo test`, forwarding arguments the and `server` in that order (each `cargo test`, forwarding arguments the
same way it always has). From `client-core/` directly: `cargo test` same way it always has). From `client-core/` directly: `cargo test`
(109 tests), `cargo clippy --all-targets`, `cargo fmt` -- all clean as of (119 tests), `cargo clippy --all-targets`, `cargo fmt` -- all clean as of
this writing (2026-09-06). this writing (2026-09-06).
+151
View File
@@ -5,6 +5,157 @@ they can be judged and reversed later. Detail lives in RUST.md (and IRIS.md
for iris API changes); this file is only the summary. Newest first. Items for iris API changes); this file is only the summary. Newest first. Items
marked **DEFERRED** are ones the agent chose not to decide alone. marked **DEFERRED** are ones the agent chose not to decide alone.
## 2026-09-06 (how a tool call looks, P1b)
- **A card that never got a result says "no result", in yellow, and it is
a state Compose cannot say.** A call that finished having printed
nothing and a call whose turn was interrupted before anything came back
both leave an empty output. Compose draws both as an ordinary finished
call, which reads as a fact somebody established. There are five states
now, each with a word and a colour: nothing at all for a call that
worked, "running" (grey), "your turn" (peach, Compose's own wording and
colour), "failed" (red), "no result" (yellow).
- **A failed call is drawn as failed, which needed a field on the wire.**
`is_error` is on the CLI's `tool_result` and was being dropped; the
server now carries it to the phone. Reversible, but the alternative is a
card that says a call succeeded because it cannot tell.
- **A group's cards do not each carry their own surface.** Compose gives
each card a fill and squares the corners where it faces a neighbour, so
a run reads as one object broken into parts. iris has no per-corner
radius, and -- more to the point -- a group built the way Compose builds
it hit a framework layout defect that drew every card's text a card
below its own box. So a group is one surface with its cards on it,
separated by a small gap, and the 4dp inset Compose holds them off the
edge by is gone. Worth revisiting once the layout defect is fixed
(docs/IRIS_TODO.md).
- **A long tool output is capped at 80 lines or 4 kB with a "Show all N
lines".** Compose draws the whole thing, and gets away with it because
its `Text` inside a `LazyColumn` lays out lazily; here the output is one
text widget and shaping a hundred kilobytes of it costs what the file
editor's 32 kB limit was measured against. If iris's text gets cheaper,
this is the number to move.
- **A card's command is clipped, not pannable, and its summary line is
clipped rather than ellipsised.** Both are framework gaps rather than
choices (`scrollable_on` on a non-editable text draws nothing; there is
no overflow ellipsis), and both are worse than Compose today. Named here
because they are visible.
## 2026-09-06 (how a markdown block looks, P1a)
- **A table is drawn as padded monospace columns, not as a grid.** Your
call to reverse. Compose draws a real grid: cells on a tint, each
column with a 136dp floor, scrolling sideways when there are too many.
iris has no grid widget, and building one would be a widget per
markdown feature -- which is the thing the block model exists to avoid.
In a monospace face a character count *is* a pixel width, so padding
each cell to its column's width is alignment, the widths are still
measured from the cells, and a table that is too wide pans sideways
through the same mechanism a code fence already uses. The header is
bold with a rule under it, and a long cell wraps inside its column
(capped at 28 characters, which is what fits three columns across a
phone). **What it trades:** no cell borders, and a table looks like
code rather than like a table. If you want the grid, it is a new widget
and it is a day's work.
- **Three block frames, and only three.** A heading, paragraph and list
are plain text with spans; a fence and a table are a rounded panel that
does not wrap; a quote is a bar with the text padded past it.
Everything else markdown says is expressed in span styles, which cost
no widgets and no layout nodes. So a new markdown feature is a span,
not a widget.
- **A list's marker is part of the text, so a wrapped item's second line
returns to the left margin.** Compose keeps it indented by giving the
marker its own column. Doing the same here needs per-line indent in
iris's text attributes; it is written down rather than done, because
the list items in a real reply are usually one line.
- **A link opens on a tap and not on the end of a drag.** A press that
panned the transcript past a link, or that held long enough to start a
selection, does not follow it -- decided by the same gesture machine
that decides pan-versus-select, so there is one rule rather than two
that can disagree.
## 2026-09-06 (composer scroll and the streaming block model)
- **A streamed message becomes a column of per-block widgets.** Decided by
the design agent; recorded here because it is the shape of every message
on screen. A transcript row is one `TextEdit` today, so a streamed delta
re-shapes the entire message through parley on every event -- the stream
phase is the one place iris is behind Compose on your phone (p50 18.2ms
vs 13.4ms). A row becomes a column of one widget per markdown block
(paragraph, heading, fence, list, table) and a delta replaces only the
last block, keeping every earlier block's layout. **Rejected:** splitting
parley's layout at block boundaries inside one text widget (couples
iris's text widget to markdown structure, and parley has no incremental
API), and caching shaped runs per paragraph inside `TextEdit` (a second
cache with its own invalidation beside the glyph cache). Chosen because
P1's markdown block model is needed anyway, so the split happens once, in
`client-core`, and iris stays a text renderer. **Status: designed, not
built** -- this pass spent its budget on the composer's three layout
defects; docs/RUST.md has the design and the pass conditions.
- **The composer's overflowing text now scrolls on a finger**, capped at
six lines and clipped to the bar. Reverses the "still does not scroll"
item below.
- **A widget may not report a `dp` length** (see IRIS.md). A rule for
widget authors, enforced by a `debug_assert!`; nothing changes for app
code.
## 2026-09-06 (stale-primitives and touch-scroll pass)
- **A vertical drag inside a focused composer now scrolls rather than
selects.** Android's own `EditText` does this -- a vertical drag scrolls
the field, and only a long press starts a selection -- so the platform
decided it. What it costs: you can no longer drag straight down inside
the composer to select several lines of what you typed; use a long press
and then drag, or drag sideways. Say if that trade is wrong for you.
- **`Scroll` gets a finger pan but no fling.** `List` flings; a scroll area
does not, because it has no per-frame tick to animate one and the areas
it wraps are at most a screenful (Android does not fling a six-line text
box either). Easy to add later if a scroll area ever wraps something long.
- **The composer still does not scroll its overflowed text**, though the
mechanism it needs is now in place. Wrapping the field in `.scrollable()`
was tried and reverted the same day: `Scroll` measures its content and
container against the *window*, so inside the `MaxSize` that caps the
composer at six lines the two are in different spaces and the field pans
itself entirely out of the bar (measured on the emulator with 474
characters in it -- the bar collapsed to its padding). Fixing that means
`Scroll` measuring against its own offered box, which is a change to a
widget the transcript and the bench shell both use, so it is its own
piece of work rather than a rider on this one.
## 2026-09-06 (defect pass)
- **The keyboard-open diagnostics overlay is gone; the capture only
logs now.** It was added when `on_insets_changed` was not firing at all
and there was no way to get a report off the phone. It fires reliably
since the activity went edge-to-edge -- and what that looks like in
use is a full-screen report covering the app **every time the keyboard
opens**, with its own Copy/Close buttons sitting underneath the
keyboard, so it cannot be dismissed (reproduced on the emulator this
pass: two `tap 'CLOSE'` runs left it up). An interruption for something
nobody asked for, over the app you are trying to type into. The named
`Diagnostics` button still shows the same text on demand, and the new
`iris surface:`/`iris insets:` log lines carry the lifecycle a `logcat`
pull needs. Reversible: `capture_keyboard_diagnostics` is still the one
place this is decided, and `PlatformHandle::show_diagnostics_overlay`
is still there.
- **The bench shell's report pane is sized to its report, not to a share
of the window.** It held `.height(rest(1))` beside the transcript's
`rest(2)`, so an *empty* `TextEdit` reserved a third of every screen --
which is what Iris's "the app does not start with keyboard spacing
correct" screenshot was showing, with the composer two thirds down and
black below it. It is `.max_height(dp(260))` now and sits above the
transcript rather than under the composer, where it was eating the
navigation-bar clearance. Cost: a filled report is clipped at 260dp
rather than scrolling (a `Scroll` there drew itself off the top of the
screen, since `Scroll` pins to the end of its content and reports its
content's full length to the parent -- worth fixing in `Scroll`, not
worked around here). "Copy report" and `logcat` still have the whole
thing.
## 2026-09-05 ## 2026-09-05
- **iris no longer asks every device for compute-shader limits it never - **iris no longer asks every device for compute-shader limits it never
+389 -2
View File
@@ -8,7 +8,367 @@ capability that moved. Small and trivial changes do not go here.
An entry gives the date, what changed, why, and a short before/after where An entry gives the date, what changed, why, and a short before/after where
it helps judge the change without the session that made it. Newest first. it helps judge the change without the session that made it. Newest first.
## 2026-09-06: `List::anchor_position_display`, `FrameReport::mark_phase`/`phase_stats`/`late_at_hz` (RUST.md's "Benchmark v2") ## 2026-09-07: a headless harness, replayed touch, and physical-pixel desktop layout
Layer 1 and 2 of docs/RUST.md's "Three test layers".
**New: `iris::harness`** -- a screen driven in-process with no window, no
compositor and no GPU, on a clock the caller advances. `Harness::new(size,
density)` gives you an `Rsc`, a `UiRenderState` and a state that
implements `FocusHost`/`OpenUrl` by *recording* what the platform was
asked for (`keyboard_shown`, `opened_urls`) rather than doing it;
`frame(t_ms)`/`frames_until(..)` run frames, `touch(action, pos, t_ms)`
feeds one pointer sample the way Android's `on_touch_event` does, and
`replay(&TouchScript)` runs a whole recorded gesture. `TouchScript` parses
a plain `t_ms action x y` file (`down`/`move`/`up`/`cancel`), so the
batched 120Hz flick shape your phone actually delivers is a file that
`cargo test` can replay -- something the emulator cannot produce at all.
**New: `List::fling_velocity() -> Option<f32>`**, what the release
measured, readable where it landed rather than by re-timing the gesture.
**Changed: `List` starts a fling's curve at its first `tick_fling`, not
at the release.** The only clock it reads is now the one its driver hands
it; in a running app the difference is at most a frame.
**Changed: the desktop backend lays out in physical pixels with a
density, exactly as Android does.** `iris::default::content_scale(window)`
is the desktop's `content_scale` -- winit's scale factor, overridable with
the `IRIS_SCALE` environment variable -- and it now feeds
`UiRenderState::set_density`/`TextData::density` instead of dividing
coordinates into a separate "logical" space. That division had
`UiRenderState::resize` (physical) and the window uniform (logical)
disagreeing on any display whose scale factor is not 1.0, and rasterised
glyphs at one resolution to display them at another. `Input::event` lost
its `scale_factor` parameter as a result, and `DefaultUiState::
window_size()` now answers physical pixels. On a 1.0 display nothing
changes. The override is what lets `run-headless.sh --phone` open a window
at your phone's own 1080x2424 and 2.55.
## 2026-09-07: the fling curve was the identity function
You said the fling "seems to just be linear velocity with an abrupt stop."
It was, exactly: `android_fling_spline`'s lookup returned `t` for every
`t`. Two halves of AOSP's spline build loop had been transposed, which made
its two tables identical, and the lookup interpolated one against the
other -- which reduces algebraically to `t`. So a fling coasted at its
release speed for the whole (correctly computed) duration and stopped dead
at the end of it.
Ported exactly now from `OverScroller.java` and Compose's
`SplineBasedDecay.kt`, which agree line for line. One public addition:
**`FlingCalculator::velocity_at(velocity, elapsed) -> f32`**, beside the
existing `position_at` -- AOSP's `mCurrVelocity` and Compose's
`FlingInfo.velocity`. It is what makes "is this decelerating" answerable
rather than inferred, and it is what `List::tick_fling`'s new
`iris fling tick:` debug line reports each frame.
The lesson worth keeping, since it cost two builds on your phone: every
test the calculator had compared it with itself -- monotonic, correctly
signed, integrates to the closed form, per-tick deltas non-increasing --
and **all of them pass on a straight line**. The numbers now come from
`iris/benches/fling_spline_reference.py`, a separate hand transcription of
the two sources, checked in beside the tests.
## 2026-09-07: the Android insets bridge counts its own dispatches
`AndroidUiState::insets_report() -> String` is new, and the bench app's
Diagnostics pane shows it. It carries the last insets plus **how many times
the platform has delivered any**, because "the keyboard did not push
anything up" has two causes that look identical on screen -- the listener
never fired, or it fired with a zero height -- and you have no logcat on
the phone. `dispatches=0` prints a sentence saying so rather than the
numbers, which would be defaults rather than measurements.
## 2026-09-07: widgets can animate, and a fling finally moves
Iris's phone said "fling still doesn't work" twice. The velocity was only
half of it: **nothing in iris advanced an animation between input
events**, so `List::fling` stored a speed that nothing ever applied. Three
public changes come out of fixing that.
**`Widget::tick(&mut self, now: Instant) -> bool`** is a new trait method,
defaulted to `false`, so no existing widget changes. A widget that
overrides it is animating; answering `false` is how it stops.
**`UiData::animate(id)` and `UiData::tick_animations(now) -> bool`** are
the registry and its driver. A gesture that starts an animation registers
the widget; each backend calls `tick_animations` once per frame before the
draw and asks for another frame while it answers `true`. That answer is
the *only* thing in iris that makes a frame happen without an input event,
and an animation's path out is its own `tick` returning false -- nothing
has to remember to unregister it.
// before: the velocity was stored and never applied
list(ui).fling(-v);
// after
list(ui).fling(-v);
let id = list.id();
ui.ui_mut().animate(id);
The two calls are deliberate rather than folded into `fling`: the velocity
is the list's business and whether anything animates at all is the frame
loop's, and a caller driving its own frames (the benchmark, the headless
tests) still calls `tick_fling` directly.
**`FlingCalculator` needs the real display density, and its coefficient
was wrong.** `new(density)` takes physical pixels per `dp` and the
velocity handed to it must be in those same physical pixels -- the
density does *not* cancel out, contrary to what that type's doc used to
claim. Separately, `physical_coefficient` multiplied by the scroll
friction (0.015) where AOSP multiplies by its own tuning constant 0.84, a
factor of 56 inside an exponential. Together they gave an ordinary flick a
**45-second** coast, which nobody could see while flings never animated.
`List` reads its density from the painter now, and
`a_flick_lasts_what_aosps_own_formula_says_it_does` pins the absolute
numbers (0.59s and 621px for 3000px/s at density 2.75) against AOSP's
formula -- the check every previous test could not make, because they all
compared the calculator with itself.
**`MOVE_CHAIN_LIMIT` is 64, not 16**, in `render_state.rs` and
`shader.wgsl` alike. It bounds a walk so a cyclic `parent` cannot hang
either side; it was never meant as a claim about tree depth, and the
transcript screen's composer field sits 17 slots below the root. Past the
bound both walks silently stop summing, so a widget draws and hit-tests
short with nothing to say so; the CPU assert now prints the chain, so a
cycle and a deep tree can be told apart.
## 2026-09-06: tool cards, `ToolState`, and a screen that knows whether its session is working
`transcript_ui::tool` is new: a card per tool call, a group per run
(P1b). Three things in the public surface follow from it.
**`client_core::transcript_fold::ToolState`** is what a card colours
itself by -- `Running`, `Deciding`, `Succeeded`, `Failed`, `NoResult` --
built by `ToolState::of(&item, session_working)`. The pair it exists for
is `Succeeded` against `NoResult`: a call that finished having printed
nothing and a call whose result never arrived both leave an empty
`output`, and drawing them the same way states a verdict nobody reached.
Only the session's own status separates them, which is why `of` takes it.
**`event_model::Event::ToolEnd` gained `is_error`** (`#[serde(default)]`,
so an older transcript still parses), and
`client_core::transcript_fold::TranscriptItem::ToolRun` gained `failed`.
Without them a result was everything a card knew and a broken call drew
exactly as confidently as one that worked -- the missing state, not a
wrong one. Every construction site of both had to gain a field; the value
comes from the CLI's own `tool_result`, read in one place
(`import::tool_result_is_error`) by both the live translator and the
import replay.
**`TranscriptScreen::set_session_working(rsc, bool)`** is new, and is the
only thing that writes it. Before: a card with no result was drawn the
same whether its turn was still going or had been interrupted. After:
only the *newest* row can say "running", because every row behind it
belongs to a turn that has ended, and changing the flag redraws that one
row rather than the screen. `TranscriptScreen::expand_tail_tools(rsc,
bool)` joins it, answering whether there was a tool run to act on -- a
group's expanded appearance is otherwise unreachable from anything that
cannot press the screen.
**`transcript_ui::row::build_row` now returns a `TailRow`** rather than an
`Option<RowBlocks>`: `Blocks` for a message (a delta costs the last
markdown block) or `Tools` for a run (an arriving result costs one card).
One mechanism for "what can this row change cheaply", asked of the row
rather than decided again at each call site. It also takes the row's own
`working` flag.
Two smaller ones. `client_core::tool_summary::parse_tool_input` is
`ToolInput.kt`'s subject/description/timeout/rest split, and
`client_core::durations::format_millis` is `Durations.kt`'s -- both pure,
both with the Kotlin's own tests ported.
## 2026-09-06: a tap is its own gesture outcome, and opening a URL is a backend capability
Three related additions, all for following a markdown link.
**`iris::platform::OpenUrl`** is a new trait beside `attr::FocusHost`, and
has the same shape: declared in `iris`, implemented once per backend (a
detached `xdg-open`/`open`/`start` on the desktop, an `ACTION_VIEW` intent
on Android, deferred to the next view callback exactly the way
`pending_show_keyboard` is). A widget asks for the capability by bound --
`Rsc::State: FocusHost + OpenUrl` -- instead of a caller threading a
callback down through every builder. One method, not a general "run an
intent": a narrower capability is a narrower thing to get wrong. Nothing
is returned; the platform either shows a browser or does not, and both
are outside the process.
**`GestureOutcome::Tapped`** is new. `Released(None)` used to mean both
"the press ended having selected something" and "the press ended having
done nothing at all", and only the second is a tap. Any caller that acts
on a tap -- following a link -- must not also act when the finger was
panning the list past that link, so the distinction is made once, in the
gesture machine every widget already shares, rather than timed again per
widget. `DragArbiter::is_undecided()` is what answers it.
`Selection::drag` returns the outcome now instead of `()`.
**`DragArbiter`/`DragGesture` take an axis** (`::on(Axis)`; `::new()` is
still vertical). A code fence pans across its own long lines exactly the
way a transcript pans down its rows, and the two were the same state
machine with `dx` and `dy` swapped. `WidgetLike::scrollable_on(axis)`
joins `scrollable()` for the same reason. Before this, a horizontal
`Scroll` existed but could not be dragged by a finger at all -- its
arbiter only ever committed on the vertical axis.
Two smaller ones in the same pass. **`TextEditCtx::byte_at(pos, size)`**
answers which byte of the text a tap landed on, doing the same
region-relative transform `select` does, without handing out the parley
layout a caller could shape against stale text. And **`Rect::radius` now
takes a `Len`**, so a corner can be written in `dp` and come out the same
physical size on every display; a bare number still means physical pixels.
**One behaviour change worth knowing about**: `Rect::is_size_independent()`
answers `false` now. It answered `true`, and a `Rect` fills whatever
region it is given -- so `draw_inner`'s fast path, which rewrites a
widget's primitives in place instead of redrawing it, could not reproduce
what `draw` would have done. A `.background(rect(..))` behind
variable-height content kept the size of the provisional pass its parent
`Span` had drawn it at, which on the transcript screen meant one code
block's panel covering every block below it. Costs one primitive's redraw
when a rect is resized.
## 2026-09-06: a transcript row is a column of blocks, and a block is the selection unit
`transcript-ui`'s row builder used to make **one** `TextEdit` per message.
It makes one per top-level markdown block now -- heading, paragraph,
fenced code, list, table -- in a `Span::down`, because a streamed delta
into a single buffer re-shaped the whole message through parley on every
event. `client_core::markdown_blocks::split_blocks` does the splitting;
`row::RowBlocks::apply_delta` updates the block a delta lands in and
leaves the rest of the message's layout alone.
**The change to judge, since it is what a reader feels**:
`Selection` is keyed by `SelKey = (RowKey, u32)` -- a row and a block --
so **a block, not a row, is the unit a selection steps in**. A drag still
runs from a reply into the tool output beneath it and copies as one
thing; what changed is that the row under the finger is filled in block by
block rather than all at once, which is if anything closer to what the
old shortcut in `Selection`'s module doc was apologising for. `register`
takes a `SelKey`; `unregister` still takes a `RowKey` and now drops every
block of it (dropping only the first is how a freed widget gets left in
the map -- the shape docs/REVIEW-2026-09-06.md's finding 1 called out).
`Selection::locate(ui, render, pos_window)` is new: which block is under a
window position, with that block's own local position and size. The
list-level handler uses it for the pointer-captured half of a drag,
instead of computing a row-local position from `List::extent`.
`row::build_row` returns `(RowKey, StrongWidget, Option<RowBlocks>)` --
the third is the per-block state a caller keeps only for the row a reply
is streaming into, and is `None` for a tool run, which never streams.
## 2026-09-06: a reported `Size` may not carry `dp`; `Len::fold_dp`
**New: `Len::fold_dp(density) -> Len`** -- the same fold `apply_rest` does
(`dp` becomes physical pixels), but staying a `Len` so `rest` survives.
**New rule, and it is a rule about every widget, not about the two that
broke it**: a `Len` a widget *reports* from `draw` must not carry an
unresolved `dp`. `dp` is an input unit -- a number the widget author wrote
-- and the containers that consume a reported length read `abs`, `rel` and
`rest` straight off it (`Span`'s placement arithmetic, `Pad`'s addition),
so a reported `dp` is silently worth **zero**. `MaxSize` and `Sized` both
returned the caller's declared `Len` as written; a `.max_height(dp(168))`
therefore gave its child a slot of nothing the moment the cap actually
applied, which is what made the composer's bar collapse. Both put their
declared lengths through `fold_dp` now, and
`UiRenderState::draw_inner` `debug_assert!`s the invariant after every
`Widget::draw`, so a widget that gets this wrong says so at the mistake
rather than laying out at zero somewhere else.
Nothing changes for a caller: `.max_height(dp(48))` is written the same
way. It is only widget *authors* who now have a rule to follow, and a
debug build that enforces it.
## 2026-09-06: `Painter::set_mask` reuses one slot; `ActiveData` gains two fields
**`Painter::set_mask(region)` allocates its widget's mask slot once and
rewrites it in place** on every later draw, instead of pushing a new one
each time. It has to: `draw_inner`'s unchanged-region fast path does not
revisit a descendant whose own region did not change, so those descendants
go on referencing whichever slot they were first drawn under. Pushing a
fresh slot per draw left the composer's field clipped to a box the bar had
long since moved away from -- four live mask entries, none of them the
`Masked`'s current region -- and it drew nothing at all. Same call, same
signature; only the lifetime changed.
**`ActiveData` gains `own_mask` and `move_applied`** (both public, since
`ActiveData` is). `own_mask` is the slot above, `MaskIdx::NONE` for a
widget that sets no mask. `move_applied` is how much of a widget's own
move-slot delta its `region` already accounts for: `mov` shifts both,
`Painter::reposition` shifts only the slot, and `resolved_region` -- and so
every hit test -- has to subtract it. Without that a widget that had been
panned had its *own* hit box at twice the pan while its descendants were
correct, which made the composer's field untappable after a finger drag.
## 2026-09-06: `Scroll` pans on a finger drag, and a vertical drag in a focused text field no longer selects
Three related public changes, all in aid of IRIS_TODO.md's "the composer
has no touch-drag scroll".
**`Scroll::drag(render, id, sense, pos_window, now)` is new**, and
`WidgetLike::scrollable()` now registers it alongside the wheel handler it
already registered -- so anything built with `.scrollable()` pans on a
finger drag with no extra wiring at the call site. It goes through the same
`sense::DragGesture` that `transcript-ui::Selection::drag` drives `List`
with (arbitration, `DRAG_SLOP`, velocity, pointer capture), rather than a
second copy of that widget's wiring: `DragGesture` owns the mechanics and
each caller decides only what a committed pan *means*. `Scroll::amt()` is
new too, the read-only pan position a test or a scroll indicator needs.
There is deliberately **no fling** on `Scroll`. Unlike `List` it has no
per-frame tick to animate one with (`List::set_redraw_handle`/`tick_fling`),
and the areas it wraps today are at most a screenful, where Android does not
fling either. The released velocity is dropped rather than approximated.
**A vertical drag inside an already-focused `TextEdit` no longer extends a
selection.** `iris::attr`'s `on_press` used to treat a focused field as the
plain `click_or_drag` case -- every `Pressing` frame updated the selection.
It now applies the same `DRAG_SLOP` rule the *unfocused* branch already
applied: a press that moves past the slop vertically abandons its pending
selection for the rest of the gesture, so the scroll area around the field
gets the drag instead. Horizontal drag-to-select is unchanged, and a long
press still starts a selection. This is Android's own `EditText` behaviour
(a vertical drag scrolls; only a long press selects), and it is what makes
"swipe up over the composer to scroll the transcript" work without dragging
a highlight through the message you were typing.
**`UiRenderState::orphaned_primitives()` is new**, and `update` now
`debug_assert!`s (debug builds only) that nothing is orphaned. An orphan is
a primitive still bound for the GPU that no live `ActiveData` names -- a
copy nothing can move, clip or free. That was the doubled `Compacted:` row
on the phone; see the same date's commit `76b1f99` and docs/RUST.md. The
per-frame guard is a count comparison (O(active widgets)); the walk that
names the offenders only runs when the counts disagree, because the walk is
O(primitives) and made a debug build on a phone too slow to finish a
benchmark run.
## 2026-09-06: a tap on a text field always leaves a caret
`TextEditCtx::select` used to compare the tap position against the
*laid-out text's* own box and set `selection = None` for anything outside
it. A press only reaches `select` after being hit-tested to the widget, so
that "outside" meant the field's own padding -- or, for an **empty** field,
everything, since an empty layout is a zero-width box. So tapping an empty
composer focused it and opened the keyboard while leaving no caret, and
`TextEditCtx::insert`/`insert_str` return early with no caret: every
keystroke was dropped in silence, and no glyph ever appeared. Parley's
`from_point`/`extend_to_point` already clamp a point outside the layout to
the nearest cursor position, which is also what a tap in a field's padding
should do.
Behaviour change a caller would notice, in one line: **`select` with a
non-drag position now always produces a selection; it no longer clears
one.** Clearing is `TextEditCtx::deselect`, which is what the backends'
focus handling already calls. A drag is unchanged -- with no previous
selection there is still nothing to extend, so it produces none.
`insert_str` also gained a `debug_assert!` for the no-caret case, so an
insert routed to an unfocused field fails at the mistake in a debug build
instead of silently swallowing input.
## 2026-09-06: `List::anchor_position_display`## 2026-09-06: `List::anchor_position_display`, `FrameReport::mark_phase`/`phase_stats`/`late_at_hz` (RUST.md's "Benchmark v2")
`List` gained `anchor_position_display(&self) -> String`, reporting the `List` gained `anchor_position_display(&self) -> String`, reporting the
anchor's own row index and pixel offset (`idx=N/off=Mpx`, or anchor's own row index and pixel offset (`idx=N/off=Mpx`, or
@@ -552,7 +912,14 @@ streamed event" cost RUST.md's P0 box measured (20 events/second against a
new rows appended after it. A row changing *before* the tail (only new rows appended after it. A row changing *before* the tail (only
`group_tool_runs` retroactively grouping tool calls into a run does `group_tool_runs` retroactively grouping tool calls into a run does
this) falls back to `List::clear` plus a full rebuild, counted in this) falls back to `List::clear` plus a full rebuild, counted in
`TranscriptScreen::take_rebuilds()`. `bench_client.rs`, `transcript_client.rs` `TranscriptScreen::take_rebuilds()`. **A caller that keeps its own
row-keyed side table alongside `List` (`Selection`'s `rows:
BTreeMap<RowKey, WeakWidget<TextEdit>>` is the one this crate has) must
clear it in step with `List::clear()`** — the fallback drops every row
`List` was holding, so any side table not cleared the same way is left
pointing at widgets the clear just freed (docs/REVIEW-2026-09-06.md
finding 1, fixed 2026-09-06 by `Selection::clear()`, called from
`apply`'s `Rebuild` arm right before `List::clear()`). `bench_client.rs`, `transcript_client.rs`
and `desktop-app/app.rs` all call this now instead of rebuilding on every and `desktop-app/app.rs` all call this now instead of rebuilding on every
event; only the opening page (and `apply`'s own fallback) still calls event; only the opening page (and `apply`'s own fallback) still calls
`build_tree`. `build_tree`.
@@ -680,3 +1047,23 @@ still not root-caused).
both CPU-side caches otherwise kept pointing at the old, now-destroyed both CPU-side caches otherwise kept pointing at the old, now-destroyed
device's textures, which is why text used to vanish again after leaving device's textures, which is why text used to vanish again after leaving
and returning to the app. and returning to the app.
## 2026-09-06: `take_counters` counts text layouts too
One public API change, from the verification pass over the composer-scroll
and per-block-row work (RUST.md's "Verification pass over Tasks A and B").
- **`UiRenderState::take_counters` returns four numbers, not three**:
`(draws, region rewrites, move writes, **text shapes**)`. The new one is
bumped in `Painter::render_text`, which `TextView::render` only reaches
on a cache miss, so it counts layouts actually computed rather than
layouts asked for. Callers destructuring the tuple need one more `_`.
It exists because a draw counter cannot answer the question the
per-block transcript row was built for. A widget can be redrawn without
re-shaping (the layout is memoized by width) and re-shaped without any
extra draw, and re-shaping is the expensive half — so "a streamed delta
costs one block" was, until now, argued from the code rather than
measured. With the counter it is a test: one delta into a 100-paragraph
reply shapes exactly **1** text layout, the same as into a
one-paragraph one.
+447 -35
View File
@@ -162,8 +162,33 @@ agent takes them without colliding with that pass's `bench_client.rs`/
genuinely new renderer -- see IRIS.md's 2026-09-06 entry and RUST.md's genuinely new renderer -- see IRIS.md's 2026-09-06 entry and RUST.md's
P0 box, item 4. Verified on the emulator (home, reopen, screenshot); P0 box, item 4. Verified on the emulator (home, reopen, screenshot);
not yet on the phone. not yet on the phone.
- [ ] **Composed/typed text never becomes visible at all -- found - [x] **Composed/typed text never becomes visible at all -- root-caused
2026-09-06, not fixed.** The composer bar stays empty even once the and fixed 2026-09-06.** Not the renderer at all: **the composer's buffer
was empty the whole time.** `TextEditCtx::select` (`iris/src/widget/
text/edit.rs`) compared the tap against the *laid-out text's* box and
set `selection = None` for anything outside it -- and an empty field's
layout is a zero-width box, so tapping an empty composer granted focus
and opened the keyboard while leaving no caret; `insert_str` returns
early with no caret, so every keystroke after that was dropped in
silence. Gboard's suggestion strip is its own composing state, not a
read of our buffer, which is what made the earlier pass conclude the
buffer held the text. Fixed by letting parley clamp a tap outside the
layout to the nearest cursor position (a press that reaches `select`
has already been hit-tested to the widget, so there is no "outside"),
plus a `debug_assert!` in `insert_str` so an insert with no caret fails
at the mistake instead of dropping input -- it immediately caught
`layout_tests::composing_text_after_a_keyboard_resize_...` typing into
an unfocused field. Three new tests in `edit.rs`
(`tapping_an_empty_field_places_a_caret_so_typing_lands`,
`tapping_past_the_end_of_the_text_clamps_to_the_end`,
`dragging_without_a_previous_selection_selects_nothing`); the first
fails on the pre-fix code. Emulator evidence: `adb shell input text`
after `tap 'Message'` now shows the text in the bar
(`/tmp/final-typing.png`) and logs `iris text render: chars=5 ...
glyphs=5`, against `glyphs=0` on every keystroke before.
**The old, superseded diagnosis, kept because it was wrong in an
instructive way:** The composer bar stays empty even once the
buffer genuinely holds the typed text (confirmed indirectly: Gboard's buffer genuinely holds the typed text (confirmed indirectly: Gboard's
own suggestion strip reacts correctly to each keystroke). A new unit own suggestion strip reacts correctly to each keystroke). A new unit
test proves the widget tree's own layout math resolves the field's test proves the widget tree's own layout math resolves the field's
@@ -176,12 +201,38 @@ agent takes them without colliding with that pass's `bench_client.rs`/
a capped/scrollable height, bottom padding tied to the IME/nav-bar a capped/scrollable height, bottom padding tied to the IME/nav-bar
inset) -- structurally in place and unit-tested, but its own visual inset) -- structurally in place and unit-tested, but its own visual
correctness cannot be screenshotted until text actually renders. correctness cannot be screenshotted until text actually renders.
- [ ] **The composer has no touch-drag scroll for overflowing text.** The - [x] **The composer has no touch-drag scroll for overflowing text.**
2026-09-06 rebuild caps the field at ~6 lines and wraps it in **Done 2026-09-06.** `field.scrollable().masked()` in
`.scrollable()` for a wheel/trackpad scroll, but a real finger drag over `transcript-ui/src/composer.rs`: a finger drag inside the bar pans the
text that has overflowed the cap does not scroll it -- `Scroll`'s touch message, the bar stays capped at six lines, and a vertical drag in the
handling is a follow-up, the same shape `List`'s own touch-drag pan focused field no longer extends a selection (Android `EditText`'s own
needed before I3/I5. behaviour). Verified on this checkout's emulator with the
`transcript-screen bench force-gles` debug build -- six repetitions of a
13-word sentence typed in, then
`ui-trace record --do "swipe 540 1200 540 1460 300"`: the field's
`Message` box moved `31,1041..1048,1509` -> `31,1131..1048,1651` (the
content panned down with the finger) with its **height unchanged at
468px** (the bar did not grow), and the two screenshots either side show
different text in the same band.
Three real defects had to be fixed first, each with a headless
regression test in `iris/src/layout_tests.rs` and each confirmed to fail
without its fix (docs/RUST.md's plan box has the measurements):
a `MaxSize` reporting its cap as an unresolved `dp` (`Len::fold_dp`), a
`Masked` allocating a fresh mask slot per draw (`ActiveData::own_mask`),
and a panned widget's own hit box moving twice (`move_applied`).
`Scroll` itself turned out to measure the right number by a misleading
route -- it is written against `painter.px_size()` now, and the claim
below that it "measures against the window" was wrong.
**The grey background was not missing** -- that note (written here on
2026-09-06 and repeated as still open) is withdrawn. Re-measured the
same day on the same AVD by decoding the screencap rather than reading
it: the bar is `rgb(41,40,49)`, the declared `UiColor::new(40, 40, 46)`
after sRGB rounding, **full width and y2245..y2365** on 1080x2424, with
the field at `31,2277..1048,2329` and the 63px nav strip below it. It
is dark by design and sits on black, which is very likely what the
earlier reading was: at a glance the band and the background are hard
to tell apart. If it should read as a bar rather than as a slightly
different black, the colour is the thing to change, not the tree.
## From the phone, 2026-09-06, 11:39 (build delivered 02:07, commit 543f6d9) ## From the phone, 2026-09-06, 11:39 (build delivered 02:07, commit 543f6d9)
@@ -189,8 +240,28 @@ Iris's report on the build with the composing-text, tap-vs-swipe and
atlas-reset fixes, with a screenshot, verbatim. Each is open until an atlas-reset fixes, with a screenshot, verbatim. Each is open until an
agent ticks it here with the evidence. agent ticks it here with the evidence.
- [ ] **"The app definitely does not start with keyboard spacing - [x] **"The app definitely does not start with keyboard spacing
correct. This is how it looks without me doing anything initially."** correct. This is how it looks without me doing anything initially."**
**Not an inset bug at all -- fixed 2026-09-06.** The black third is the
bench shell's own empty *benchmark report* pane: `bench_client.rs`'s
root tree gave it `.height(rest(1))` beside `content.height(rest(2))`,
so an empty `TextEdit` reserved a third of the window at every launch
and pushed the composer up by exactly that. Measured on this checkout's
emulator at the phone's own size (1080x2424, density 420, gesture nav),
which reproduced Iris's screenshot exactly: new `iris insets:` log line
reported `bottom=63 ime_bottom=0` at launch (a nav bar, no keyboard --
so the inset the composer was fed was never large), while `ui-trace
show -m Message --field box` put the field at `31,1488..1048,1540` on a
2282px-tall surface, 789px clear of the bottom -- that pane's third.
**Unit mixing checked explicitly and cleared**: `set_bottom_inset` takes
physical px and stores `Len::abs`, `MainActivity.java`'s `1`/`0`
`ime_bottom` only ever reaches `insets.bottom.max(ime_bottom)` and
`> 0.0`, and every `dp` in the composer resolves at layout time. Fix:
the report pane is sized to its content (`.max_height(dp(260))
.scrollable()`), and moved above the transcript so it cannot eat the
composer's nav-bar clearance. After: field box `31,2277..1048,2329`,
grey bar ending at device y2361 with the 63px nav strip below it
(`/tmp/fix1.png` this pass).
The screenshot shows the composer bar (the grey band) sitting about The screenshot shows the composer bar (the grey band) sitting about
two thirds of the way down a 704x1568 screen, with black below it to two thirds of the way down a 704x1568 screen, with black below it to
the bottom, and the transcript ending at "Claude / Results" just above the bottom, and the transcript ending at "Claude / Results" just above
@@ -202,19 +273,69 @@ agent ticks it here with the evidence.
field the composer may still read as pixels or dp; a stale value from field the composer may still read as pixels or dp; a stale value from
before the first `on_insets_changed`. Reproduce with the phone's before the first `on_insets_changed`. Reproduce with the phone's
screen size and density on the emulator before guessing. screen size and density on the emulator before guessing.
- [ ] **"Swiping still gets caught by the grey bar but keeps working - [~] **"Swiping still gets caught by the grey bar but keeps working
after I go past it."** A pan that starts on the composer is held by after I go past it."** Improved 2026-09-06 by the focused-field rule
the composer until the finger leaves its region, then the list takes below, still needs her phone to close. `attr.rs`'s `on_press` treated an
over. The tap-vs-swipe fix in `attr.rs` stops the *focus*, but the already-focused composer as the plain drag-to-select case, so a swipe
press frames are still being handled by the field rather than passed starting inside it dragged a highlight through the typed text for the
to the list from the first slop-crossing frame. The `DragGesture` whole gesture; it now abandons that the moment the press passes
merge (RUST.md's plan box) should make this one mechanism: once a `DRAG_SLOP` vertically (Android `EditText`'s own rule), which removes one
gesture commits to a pan, the list captures it wherever it began. of the two things that made the bar feel like it caught the swipe. The
- [ ] **"Flinging still does not work."** Expected on this build: finger residual `DRAG_SLOP` measured from the boundary crossing, described
flings are dropped by per-widget hit testing, which `DragGesture`'s below, is unchanged. Original note follows.
pointer capture (commit `e12c708`, not yet merged at 02:07) targets. Not closeable from the emulator, annotated
Stays open until verified on her phone, not the emulator. 2026-09-06 after the `DragGesture` merge. `attr.rs`'s `on_press` never
- [ ] **"Text still disappears if I leave and come back to the app."** calls `capture_pointer` and never consumes a `Pressing` frame past
`DRAG_SLOP` (it just stops watching), so once the finger's *current*
position leaves the composer's box and enters the list's, `List`
starts receiving ordinary hit-tested `Pressing` frames there --
`DragArbiter::is_idle()`'s 2026-09-05 recovery (a missed `PressStart`)
picks it up rather than leaving it stuck. What this does **not** do is
what "wherever it began" implies literally: `DragArbiter::press_start`
restarts from the *boundary-crossing* position, not from the original
touch-down inside the composer, so the pan still needs a fresh
`DRAG_SLOP` of travel measured from the boundary rather than from the
start of the gesture -- composer and list are adjacent, non-overlapping
widgets (`lib.rs`'s `(list, composer_bar).span(Dir::DOWN)`), and only
the composer forwarding its own drag to the list would remove that
residual slop entirely, which is more than this pass's merge changes.
RUST.md's merge-pass box has the reasoning in full and an emulator
swipe confirming the composer's own box never moves/resizes during it;
whether the residual slop is still perceptible as "caught" needs Iris's
phone, since the emulator's per-widget boundary is a few dp wide and
easy to cross without noticing on a real screen too.
- [ ] **"Flinging still does not work."** No longer expected to reproduce
after the `DragGesture` merge (`e12c708`, pointer capture +
`CursorSense::Drop`), 2026-09-06. Emulator evidence (RUST.md's
merge-pass box, check (b)): a real `ui-trace` finger swipe followed by
screenshot-hash sampling caught a post-release frame distinct from the
drag's own last frame in one run, and every run showed 28-32
`render()` frames per gesture against an idle baseline of 0 and ~8
expected from the drag alone -- redraw kept being requested well past
the finger lifting, which only happens while a fling is still
animating. Left unticked in spirit until Iris's phone confirms it,
since only she can say whether it *feels* like a fling now; the
emulator's screenshot timing could not always catch the tail of a
fast-settling one visually (same caveat noted in RUST.md).
- [~] **"Text still disappears if I leave and come back to the app."**
**Instrumented 2026-09-06 so the phone can answer it**, since no
emulator here has a Vulkan adapter. `iris/src/android/view.rs` now logs
one `log::info!` line per surface event with the glyph/atlas counts:
`iris surface: surface_destroyed, tearing the renderer down
(glyphs_cached=387 atlas_pages=1)`, `iris surface: surface_changed
1080x2424 already_live=false glyphs_cached=387 atlas_pages=1`, `iris
surface: new renderer built (Gl), clearing glyph atlas: glyphs=387
pages=1`, plus `iris insets: ... window=(1080, 2424)` on every insets
change. That is the emulator's own healthy app-switch cycle, verified
this pass (home, reopen, screenshot: all text intact,
`/tmp/appswitch.png`). **The one line to look for on the phone is
`already_live=`**: `true` on the return from backgrounding would mean
the surface came back *without* a `surface_destroyed`, so
`surface_changed` reconfigured a renderer whose Vulkan swapchain and
atlas textures belong to a window that is gone -- the reuse branch
never clears the atlas, by design. `false` with no `new renderer built`
line after it would mean the renderer failed to rebuild. Either answer
names the fix; guessing between them from here does not.
The `GlyphAtlas::clear`/`Textures::reset` fix was verified on the The `GlyphAtlas::clear`/`Textures::reset` fix was verified on the
emulator under `force-gles` only; the phone runs Vulkan. So either the emulator under `force-gles` only; the phone runs Vulkan. So either the
reset is not reached on the phone's path (a different surface- reset is not reached on the phone's path (a different surface-
@@ -225,6 +346,146 @@ agent ticks it here with the evidence.
logcat` when Iris next runs it, since no emulator here has a Vulkan logcat` when Iris next runs it, since no emulator here has a Vulkan
adapter under host GPU. adapter under host GPU.
## From the phone, 2026-09-06, 22:16 (build from 20303e0, delivered via ai-app-bench 95e25fe)
Iris's report, verbatim, with a screenshot. Phone: Mali-G715 (Vulkan),
`content_scale: 2.55`, 120Hz. Open until ticked with phone-side evidence.
- [ ] **"Fling still doesn't work."** -> on `ed04d4c`, 2026-09-07:
*"flinging now does technically do something, but it seems to just be
linear velocity with an abrupt stop."* **It was exactly that, and the
arithmetic said so.** `distance_fraction(t)` returned `t` for every `t`
-- a constant-speed slide for the whole duration, then a stop at full
distance -- because two halves of AOSP's spline build loop were
transposed, which made `SPLINE_POSITION` and `SPLINE_TIME` identical, and
the lookup bracketed `t` between `SPLINE_TIME` entries rather than
between even time steps. The two cancelled to the identity. Ported
exactly now from `OverScroller.java` and
`androidx.compose.animation:animation:1.12.0`'s `SplineBasedDecay.kt`
(they agree line for line), with `iris/benches/fling_spline_reference.py`
as an independent transcription supplying the numbers the tests assert
on. Emulator, 2026-09-07: a released `v=3750` decelerates
`3746 -> 2624 -> 1834 -> 1144 -> 752 -> 449 -> 243 -> 83px/s` across 32
frames; a flick into the end of the list stops there in one tick with no
overshoot; a tap 200ms into a fling ends it at 11 ticks instead of 32.
**Open until the phone says so** -- a flick should now visibly slow
before it stops. Its earlier three defects (the velocity, the missing
animation registration, the 56x coefficient) are all still fixed and were
never the linear part.* Second report; the emulator's
`ui-trace` swipe flings (verified 2026-09-06 with `render()` counts),
a finger on the phone does not. What differs: a real flick at 120Hz is
batched by Android into few `MotionEvent`s with *historical* samples
(`getHistoricalX/Y/EventTime`), and can be DOWN, one or two MOVEs, UP
inside `DRAG_SLOP`'s worth of frames; a `ui-trace` swipe is many
evenly-spaced MOVEs. Suspects, in order: `android/sense.rs` reading
only each event's final position (the velocity tracker sees two
samples, or one); the release path starting a fling only from a
gesture already in `Panning`, so a flick that crosses the slop on its
last sample is treated as a tap; `ACTION_CANCEL`/pointer-capture
delivering no `Drop`. Log the release decision (samples, span,
velocity, outcome) at `info` so the next logcat settles it.
- [x] **"I can't reopen keyboard by tapping on message box after it
already happened once."** *(Fixed 2026-09-07: `attr.rs`'s already-
focused branch calls `focus_gained` on a tap inside `DRAG_SLOP`.
Emulator: first tap `mInputShown=true`, back gesture, second tap
`mInputShown=true`. Negative control with that one call removed leaves
the second tap at `false`; a horizontal and a vertical swipe over the
focused field both leave it at `false`, so the earlier "swiping over
the input bar brings up the keyboard" has not returned.)* The field stays focused after the keyboard
is dismissed (back gesture, or the IME's own hide), so `on_press`'s
already-focused branch never requests the IME again. Android's
`EditText` shows the IME on every tap of a focused field; do the same
(`FocusHost`: a tap on a focused field requests the IME, idempotent
when it is already shown).
- [ ] **"Message box does not push up the scroll area."**
**Reopened by the phone on 2026-09-07** -- *"similarly, the keyboard
raising up does not push things upwards"* -- after being ticked on
emulator evidence the day before (`ime_bottom=883`, composer box
`31,2277..1048,2329` -> `31,1457..1048,1509`). The JNI half was right;
what was wrong is one line of `iris/android-app/app/build.gradle`:
**`targetSdk = 34`** against `compileSdk = 37`, while the Compose app in
`app/` targets 37 and *does* push up on her phone. Below target 35 the
window keeps the legacy behaviour, where `adjustResize` shrinks it for
the IME and `getInsets(ime()).bottom` therefore measures zero;
`setDecorFitsSystemWindows(false)` opts out of that and still takes on
the API 36 emulator here, which is why every test run passed. Now
`targetSdk = 37`, plus a `WindowInsetsAnimation.Callback` for the devices
where only the animation path carries the height -- which also makes the
push-up animate (`ime_bottom=509, 663, 833, 881, 883` instead of one
jump). **This is a reading, not a measurement**: no Android 17 device is
reachable from here. So the Diagnostics pane now prints
`insets: dispatches=N left=… ime_bottom=… ime_visible=…` --
**screenshot that line with the keyboard open.** `ime_bottom` in the
hundreds and the composer risen means fixed; `dispatches` climbing with
`ime_bottom=0` means the reading was wrong and the window is still being
resized; `dispatches=0` means the listener is not firing at all, which is
a third thing again.* Since
`MainActivity` went edge-to-edge (`e12c708`), `adjustResize` no
longer resizes the window, so the app owns the IME inset -- but
`ime_bottom` is passed through JNI as the boolean `1`/`0` (the
2026-09-06 "(b)" fix), so nothing has the inset's *height* to pad the
transcript and composer with. Pass both: `isVisible(ime())` and
`getInsets(ime()).bottom` in px; the list's bottom padding and the
composer's position follow the height, the visibility drives the
boolean the `imePadding` rule in AGENTS.md's "Things that have bitten"
describes.
- [x] **"Picture is what happens if I leave the app and come back,
which completely removes text, and then I tap on the debug info. The
textures are definitely getting cooked for some reason after leaving
the app and resuming."** Screenshot: every glyph drawn *before* the
resume is fragments; the diagnostics text drawn *after* is perfect;
the report says `atlas format: Rgba8Unorm, views live: 0`. Reading:
`Textures::reset`/`GlyphAtlas::clear` on the new renderer emptied the
GPU atlas, but the per-widget cached text primitives (`TextView`'s
render cache -- the one `c3cfc67`'s shape counter is keyed on) still
carry the old atlas coordinates and are re-submitted as-is; only
widgets drawn fresh after the resume shape and upload again. Fix: a
renderer rebuild invalidates every cached text render (one
generation counter on the atlas, checked at `TextView::render`, or
a full-tree redraw with caches dropped), with a `debug_assert!` that
no submitted glyph quad references an atlas generation older than the
live one. Reproducible on the emulator by forcing a renderer rebuild
(home + return, or `surface_destroyed`/`surface_created`) on a screen
with text already drawn -- the earlier "verified" home/reopen check
screenshotted the emulator's GLES path, where a resume may not
destroy the surface at all.
**Fixed in `ba2afba` and confirmed on the phone (Iris, 2026-09-07:
"the resume glyph corruption is fixed").** Closed. The emulator could
never have settled it -- no Vulkan adapter here, and the GLES path may
not destroy the surface at all -- so the phone was the only place this
could be answered, and it has been. `clearing_the_atlas_re_renders_
cached_text_instead_of_reusing_it` is what keeps it.
The reading above is right and the mechanism is one step narrower than
"cached text primitives". `IrisViewPeer::surface_changed`
(`iris/src/android/view.rs`) *does* already force a full-tree redraw
after a rebuild: it calls `render.resize(...)` unconditionally, which
sets `UiRenderState::resized`, which makes the next `update` take
`redraw_all` rather than `redraw_updates`. So every widget's `draw`
really does run again after the resume. What survives it is one cache
further in: `TextView::render` (`iris/src/widget/text/mod.rs`) returns
its cached `RenderedText` whenever the wrap width, buffer and attrs are
unchanged -- true of every pre-resume row -- so `TextData::place` is
never reached, nothing is re-rasterised into the fresh atlas, and the
*old* atlas's `uv_min`/`uv_max`/`layer` are re-submitted verbatim. Only
text whose content changed after the resume (the diagnostics pane Iris
tapped) re-shapes, which is exactly the split in her screenshot.
`Painter::glyphs` has one call site in the whole workspace, that one,
so there is no second holder of a `RenderedText` to fix.
The fix, in `ba2afba`: `GlyphAtlas::generation`, bumped by
`GlyphAtlas::clear`; `RenderedText::generation` recording which atlas
its glyphs were placed against; `Painter::atlas_generation()`;
`TextView::render`'s cache key gains it; and a `debug_assert_eq!` in
`Painter::glyphs` that a submitted quad's generation is the live one.
Headless test
`clearing_the_atlas_re_renders_cached_text_instead_of_reusing_it`
(`iris/src/widget/text/mod.rs`): draw, `atlas.clear()`, `resize`, draw
again, and assert the atlas holds the same glyph count again -- it
stays at 0 without the fix, because the cache short-circuits before
`place`.
## Build ## Build
- [x] **Benchmarks**, not unit tests, run on demand (2026-09-05; a - [x] **Benchmarks**, not unit tests, run on demand (2026-09-05; a
@@ -414,22 +675,31 @@ agent ticks it here with the evidence.
`row.rs`'s `build_text_row` is where one would go, keyed to something `row.rs`'s `build_text_row` is where one would go, keyed to something
stable per row (its sender + a short excerpt, matching what a screen stable per row (its sender + a short excerpt, matching what a screen
reader announcing a chat message would say). reader announcing a chat message would say).
- [ ] **A tappable link and a background chip behind inline code.** - [x] **A tappable link** — done 2026-09-06 (P1a). `TextEditCtx::
Both need per-range glyph geometry that `TextEditCtx` does not expose byte_at(pos, size)` answers which byte a tap landed on without
outside `iris::widget::text` (`edit.rs`'s `layout()` helper is handing out the parley layout, `GestureOutcome::Tapped` says the
private) — see `markdown.rs`'s module doc for the exact shape the fix press committed to neither a pan nor a selection, and
would take (the same primitive `TextEdit::draw`'s own selection `iris::platform::OpenUrl` is the capability each backend implements
highlight already uses internally, (`xdg-open`/`open`/`start`; an `ACTION_VIEW` intent on Android,
`iris/src/widget/text/edit.rs:99`). deferred to `after_input` the way `pending_show_keyboard` is).
- [ ] **A background chip behind inline code.** Still needs per-range
glyph *geometry* — a run's boxes, not one offset — which
`TextEditCtx` does not expose outside `iris::widget::text`
(`edit.rs`'s `layout()` helper is private). The same primitive
`TextEdit::draw`'s own selection highlight uses internally,
`iris/src/widget/text/edit.rs:99`. `byte_at` above deliberately did
not open that up: a tap needs one offset and a chip needs the run.
- [ ] **`Selection`'s anchor-row shortcut.** The row a drag started in - [ ] **`Selection`'s anchor-row shortcut.** The row a drag started in
is selected in full (`select_all`) the moment the drag leaves it, is selected in full (`select_all`) the moment the drag leaves it,
rather than "from the click point to whichever edge points away from rather than "from the click point to whichever edge points away from
the drag" — needs the same private `layout()` access as the item the drag" — needs the same private `layout()` access as the item
above. `selection.rs`'s module doc has the exact reasoning. above. `selection.rs`'s module doc has the exact reasoning.
- [ ] **No syntax highlighting inside a fenced code block.** - [x] **Syntax highlighting inside a fenced code block** — done
`client_core::highlight` exists (built for the file explorer) and 2026-09-06 (P1a). `client_core::highlight::spans_of` by language,
could feed per-token `SpanStyle`s into a code block's span; wiring it converted from its char indices to `SpanStyle`'s byte offsets, in
in was not attempted this pass. the same Catppuccin palette `Theme.kt` uses. A language the scanner
has no rules for stays plain rather than being coloured by the
nearest one's.
- [ ] **Masks defined relative to each other.** Wanted: mask A multiplies - [ ] **Masks defined relative to each other.** Wanted: mask A multiplies
by something *and also* applies mask B — a mask can reference a parent by something *and also* applies mask B — a mask can reference a parent
@@ -449,6 +719,109 @@ agent ticks it here with the evidence.
everything, the same way input is**. Whatever the mechanism, a widget everything, the same way input is**. Whatever the mechanism, a widget
that does not animate must pay nothing and import nothing for it. that does not animate must pay nothing and import nothing for it.
## Found by P1a (2026-09-06)
- [x] **`Rect` claimed to be size-independent, and it is not.** A `Rect`
fills whatever region it is handed, so `draw_inner`'s size-independent
fast path -- which rewrites primitives with
`r.outside(&from).within(&region)` rather than redrawing -- could not
reproduce its `draw`, and a `.background(rect(..))` kept the size of
the *provisional* full-region pass `Span` does in phase 1. One fenced
code block's panel covered every block below it and every row below
that. Fixed in `iris/src/widget/rect.rs`; the reason is written at the
definition. Suspect the same cause for anything else tinted with a
background rect.
- [x] **A wrapped transcript row tripped `reposition`'s debug assert.**
Settled 2026-09-06 by giving the move slot one owner instead of two.
`mov` accumulates a delta on it, `reposition` overwrote it, and both
legitimately land on one widget in one frame: `List::place`'s
Bottom-known branch offers a row a same-size box that has *moved*
(`mov`), then corrects the placement inside it when the row's cached
height no longer matches what the row reports (`reposition`). The
slot now always means `move_applied + repositioned`
(`ActiveData::repositioned`, `iris/core/src/ui/render_state.rs`), so
`reposition` adds the move rather than dropping it -- the assert is
gone and the arithmetic is right. Test:
`a_widget_moved_by_its_parent_and_then_placed_inside_it_lands_at_the_placement`
in `layout_tests.rs`, which lands the child at the *offered* position
(-100px) instead of the placement (100px) without the fix, and a
`debug_assert_eq!` in `reposition` that nothing but those two ever
writes the slot. Verified with the `.wrap(true)` repro (draws, no
panic) and an emulator bench run with assertions live.
- [ ] **Desktop colours are washed out: the winit surface is sRGB and
the shader writes the palette's bytes as linear.** Mocha Crust
(17,17,27) is drawn as (73,73,91), measured off
`run-headless.sh --shot`. Android is correct, so this is the surface
format rather than the palette -- but it makes the desktop build
useless as a colour reference, which is exactly what P1a needed it for
when the emulator could not draw glyphs.
- [x] **Every glyph was a solid box on the GLES backend -- iris's bug,
not the emulator's.** Fixed 2026-09-06. The atlas is one
`texture_2d_array` and `GpuTextures::new` created it with **one
layer**; wgpu-hal picks the GL target from the descriptor
(`(false, 1) => TEXTURE_2D`), so under GLES that array was a
`GL_TEXTURE_2D` bound to the shader's `sampler2DArray`, the unit was
incomplete, every `textureSample` returned (0,0,0,1), and
`draw_glyph`'s `color.a *= texel.a` filled the quad. `MIN_ARRAY_LAYERS
= 2` in `iris/core/src/render/texture.rs`, with a `debug_assert!` at
`create_array_texture`. Vulkan (the phone, the desktop's default
backend) was never affected. Reproduce the class in seconds without an
emulator: `iris`'s `force-gles` feature now switches the **desktop**
backend too -- `./run-headless.sh transcript --shot /tmp/x.png -- -p
transcript-ui --features iris/force-gles`.
- [ ] **The bench report pane draws over the transcript rows instead of
replacing them.** Visible on the emulator for the first time now that
glyphs render there (`/tmp/emu-final.png`, 2026-09-06): after a bench
run the report's lines and the transcript's occupy the same rows in the
top third of the screen, both legible, neither on top. Pre-existing --
the same overlap is in a screenshot taken before the move-slot fix -- so
it is its own item, most likely the report pane not masking or not
claiming its region.
## Found by P1b (2026-09-06), all with a headless repro
Each was found by looking at `iris/run-headless.sh transcript -- -p
transcript-ui` rather than at a diff, and each is worked around in
`transcript-ui/src/tool.rs` rather than fixed here. docs/RUST.md's P1b box
has the fuller account.
- [ ] **A `Span` of `Pad`ded children inside another `Span` places those
children a slot out of step.** Each child drew its content one sibling's
height below its own box. Repro: `IRIS_TOOLS_EXPANDED=1
iris/run-headless.sh transcript --shot /tmp/x.png -- -p transcript-ui`
with `tool.rs`'s group built as `Span(DOWN)[header, Pad(Span(DOWN)
[cards]), bar]` instead of the single `Span` it uses now. Bisected:
removing the inner `Span` fixes it, and so does removing the children's
own `Pad`; the background `Stack`, the `Sized` wrappers and the
`WidgetPtr` per child make no difference. **Not** the `mov`-vs-
`reposition` fault f5b8893 fixed -- it survives that commit. The
workaround costs the group the 4dp inset its Compose counterpart holds
its cards off the edge by, so this is worth fixing.
- [ ] **`scrollable_on(Axis::X)` on a non-editable `Text` draws nothing.**
The panel is drawn and the text inside it is not. A markdown fence does
the same to a `TextEdit` and is fine, so it is the widget kind rather
than the chain. `tool.rs`'s `raw_block` is `masked()` only until this is
fixed, which means a long command is clipped rather than pannable.
- [ ] **No overflow ellipsis.** `TextAttrs` can wrap or not wrap; there is
no "one line, ellipsised" the way `maxLines = 1` + `TextOverflow.
Ellipsis` gives Compose. A tool card's summary is clipped instead, so
nothing on screen says it was cut. Whichever end is cut has to be a
choice when this lands: a path is identified by its tail, a command by
its head.
- [ ] **A drawn chevron.** `Chevron.kt` draws its own strokes precisely
because a chevron from a font is a glyph a system font may not have --
and the bundled `NotoSans-Regular.ttf` indeed has no U+25B8/25BE/25B4,
while `NotoSansMono-Regular.ttf` does. `tool.rs` sets the mark in the
monospace face as a result. A real fix needs a line/path primitive;
iris has rects, text and textures only.
- [ ] **A tool card's text is not selectable.** `Selection` is keyed
`(RowKey, block index)` and a card has no markdown blocks, so nothing in
a card registers. Compose's `SelectionContainer` covers tool output,
which is the text people most want to copy. Needs a key for "the nth
text of this row" that a card can mint without colliding with a
message's blocks.
## Build (for the port) ## Build (for the port)
Widgets `RUST.md`'s "The port, in order (decided 2026-09-05)" needs and Widgets `RUST.md`'s "The port, in order (decided 2026-09-05)" needs and
@@ -528,8 +901,23 @@ do not duplicate it there.
## From the phone, bench v2 (2026-09-06): streaming re-lays out the whole message ## From the phone, bench v2 (2026-09-06): streaming re-lays out the whole message
- [ ] **Streaming a delta into a long message costs a full text layout of - [x] **Streaming a delta into a long message costs a full text layout of
that message.** Iris's phone report (`docs/bench/iris-phone-v2-2026-09-06.md`): that message.** **Done 2026-09-06** -- a row is a column of one
`TextEdit` per markdown block (`client_core::markdown_blocks`,
`row::RowBlocks::apply_delta`), so a delta re-shapes the last block and
keeps every earlier block's layout. A block is the selection unit now
(`Selection`'s `SelKey`); selection across blocks and rows still works,
checked on the emulator with a real long-press drag. Pass condition met
in `a_delta_into_a_long_reply_redraws_the_same_widgets_as_a_short_one`:
a delta into a 100-paragraph reply redraws the same widget count as one
into a one-paragraph reply (30 either way). Emulator stream phase, same
AVD before and after: **p50 61.5 -> 54.5ms, p90 211.7 -> 113.1ms, p99
342.6 -> 137.4ms, worst 403.6 -> 143.0ms**, 202 -> 293 frames in the same
21 seconds. docs/RUST.md's Task B box has the detail and the two dead
ends. **The phone is the measurement that decides it** -- these are
emulator numbers and only the ratio transfers.
The original entry, for the record: Iris's phone report (`docs/bench/iris-phone-v2-2026-09-06.md`):
the stream phase is the one place iris is behind Compose (p50 18.2 ms vs the stream phase is the one place iris is behind Compose (p50 18.2 ms vs
13.4 ms; p99 level at ~43 ms). `TranscriptScreen::apply` replaces only 13.4 ms; p99 level at ~43 ms). `TranscriptScreen::apply` replaces only
the last row, but that row is the growing message, and replacing it the last row, but that row is the growing message, and replacing it
@@ -543,3 +931,27 @@ do not duplicate it there.
p50 dropping below Compose's on the phone. Do this after the four bench p50 dropping below Compose's on the phone. Do this after the four bench
v2 defects (stale primitives, finger fling, decay curve, IME show) are v2 defects (stale primitives, finger fling, decay curve, IME show) are
closed, since they are what make the run unrepresentative today. closed, since they are what make the run unrepresentative today.
## From the phone, 2026-09-07 (build from ed04d4c)
- [ ] **"Some transcript blocks will be hidden until I uncover enough of
them."** Two screenshots of the bench app's transcript at the top
edge, both wrong in opposite directions: in one, rows scrolled above
the viewport are still drawn and bleed *through* the header bar
(`version = "0.1.0"` and a paragraph visible behind "Run benchmark /
Copy report / Diagnostics"), so the list's mask is not clipping at
the header's bottom edge; in the other, scrolled a little further,
the row that straddles the top edge is not drawn at all -- black from
the header down to "You", where the previous shot showed a paragraph
-- so a row is culled as soon as its *top* leaves the viewport rather
than when its *bottom* does. Suspects: the list's visible-range test
(`iris/src/widget/list.rs`) comparing a row's top against the
viewport top; the mask region for the transcript set from the
window rather than from the area under the header; and the two-phase
provisional/real draw noted in `03c6be8`'s header-duplicate
investigation, which was never root-caused and has the same shape.
Reproduce at layer 1 of the test rig: a headless screen with a row
straddling the top edge must place that row, and a primitive above
the header's bottom must be masked. Fix both with one rule: a row is
drawn if any part of it intersects the viewport, and the viewport is
the list's own region.
+92
View File
@@ -947,3 +947,95 @@ When this lands, copy this entry into `IRIS.md` (newest first):
> `SizeCtx` and `Cache` are gone with it — see `LAYOUT.md` for the full > `SizeCtx` and `Cache` are gone with it — see `LAYOUT.md` for the full
> design, the move-offset mechanism this shipped alongside, and the file > design, the move-offset mechanism this shipped alongside, and the file
> list. > list.
## Masks with a shape (decided 2026-09-07, not yet built)
Iris, on the code block's scrolling: "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, and/or
another widget you can select, 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."
**What exists.** `Mask` in `shader.wgsl`/`data.rs` is two `UiSpan`s and
a `move_idx`; `fs_main` resolves it and does `color *= 0.0` outside the
rectangle -- a hard cut on a pixel boundary. `Masked` (`widget/mask.rs`)
sets the painter's mask to its own region. Separately, `draw_rounded_rect`
already produces an anti-aliased rounded edge from
`distance_from_rect(pos, center, corner, radius)` with a half-pixel
`smoothstep`, and the border variant multiplies a second coverage in.
**Design** (revised the same day on Iris's two corrections: hit-testing
applies the shape too, and a mask should reference a primitive rather
than carry a copy of its shape).
1. **A mask is a reference to a primitive already drawn, plus how to
use it.** `Mask { kind, idx, flags, parent }`: the primitive's
binding (`RECT`, `TEXTURE`, `GLYPH`) and slot, flags (today one:
*alpha only* -- take the primitive's coverage and ignore its colour,
which is the default and the only mode until a need for another
appears), and the enclosing mask's slot for nesting. The fragment
stage evaluates the referenced primitive *at the masked pixel* --
for a `Rect`, the same `draw_rounded_rect` coverage from the same
SDF; for a texture or glyph, the sampled alpha -- and does
`color.a *= coverage`. Nothing about the shape is copied: a rounded
container's corner and its children's clipped corner are the same
primitive's arithmetic, and a texture mask (an alpha image as the
clip) works with no new shader path.
What this needs from the data layout: evaluating a primitive at an
arbitrary pixel means its placement (its spans and `move_idx`, today
vertex attributes) has to be readable from a storage buffer in the
fragment stage. If it is not already there, put it there once, for
every primitive, rather than keeping a second copy for masks -- the
vertex stage can read the same buffer. Textures: the shader binds one
image at a time (see `masks_layout`'s comment on why an image's own
bind group must not name the masks buffer), so a texture mask is
limited to what the fragment can sample without a bind-group switch:
the atlas, and the primitive's own bound image when the masked
primitive is drawn in the same image's batch. Say so at the flag.
2. **Nested masks chain and multiply, like moves.** `parent` walks up
the chain, bounded like `resolve_move` (`MOVE_CHAIN_LIMIT`'s sibling;
debug-assert on overflow and print the chain); coverages multiply,
so a pixel inside two feathered corners is dimmed by both, which is
what a compositor does and what "alpha should be multiplied" asks.
3. **`.masked()` points the mask at the current widget's own
primitives.** `Masked` stops describing a region: it records which
primitive(s) the wrapping widget drew this frame (the painter knows
-- it just allocated the slots) and sets the mask to reference them.
So a rounded `Rect` widget's `.masked()` clips its children to
itself by pointing at the rect it already draws; an image widget's
`.masked()` clips to its alpha. No radius or shape argument exists to
fall out of sync. When a widget draws more than one primitive (a
bordered rect is one primitive; a card with a stripe is two), the
mask references the *first* and the doc says so; a widget that wants
another names it.
4. **Hit-testing applies the shape.** A press is inside a masked
subtree only if the mask's coverage at that point is above one half.
For a `Rect` that is the same rounded-rect SDF evaluated on the CPU
-- one function in the shared crate, with the WGSL a transliteration
of it and a test that compares the two at a grid of points
(`headless` renders to a buffer and reads back, or the Rust version
is checked against the values the shader produced once and recorded).
For a texture, the CPU needs the alpha: keep the alpha channel of an
image used as a mask readable on the CPU (it was uploaded from CPU
memory; keeping the alpha plane is a quarter of the image), and read
it at the point. A masked corner that cannot be tapped and a masked
corner that is not drawn are then the same corner.
**Rejected.** A stencil buffer (a second pass per mask level and no
anti-aliasing); the scissor rectangle (rectangles only, no alpha);
rendering a masked subtree to an offscreen texture and compositing
(a texture allocation per mask, every frame it scrolls, on the phone).
**Pass conditions.** A headless test draws a rounded container with a
masked child that overhangs all four sides and asserts the child's
coverage at a corner pixel equals the container's own coverage there
(same primitive evaluated, so exactly equal, not approximately); a
nested-mask test asserts the product at a pixel inside both feathers; a
texture-mask test clips a rect to an alpha image and asserts a
transparent texel masks fully; a hit-test asserts a press in a
container's clipped corner misses and one just inside the curve hits,
and that the CPU SDF and the shader agree at a grid of points; a
`run-headless.sh --phone` screenshot of a scrolled code block shows
rounded corners with no square pixels poking out at the top and bottom
of the scrolled content. Record the commands in RUST.md when it lands.
+214
View File
@@ -0,0 +1,214 @@
# Review: iris changes since 0e46293
Scope: `git diff 0e46293..HEAD -- iris/ client-core/` (58 files, +5224/-226).
Read-only review; no source changed. Ordered likely-bug, then invariant
guards, then rules, then tests/docs.
## Likely bugs
1. **`iris/transcript-ui/src/lib.rs:152-160` (`RowDiff::Rebuild` arm of
`TranscriptScreen::apply`) never unregisters the rows it drops from
`Selection`, so a stale `WeakWidget<TextEdit>` outlives the widget it
points to and the next touch on *any* row panics.**
`Selection::rows: BTreeMap<RowKey, WeakWidget<TextEdit>>` documents its
own contract at `selection.rs:69-71`: "every addition here needs its
removal ... called when `List` evicts the row." The `ReplaceLast` arm
above it honours this (`lib.rs:143-145`, `self.selection.borrow_mut()
.unregister(old_key)` when the key changes). The `Rebuild` arm calls
`(self.list)(rsc).clear()` and rebuilds every row from `new_rows`, but
never touches `self.selection` — any key present in `old_rows` and
*absent* from `new_rows` (exactly what `group_tool_runs` regrouping two
separate tool-call rows into one produces — see `diff_tests::
a_tool_run_closing_and_joining_an_earlier_call_is_a_regroup_fallback`,
which tests the diff decision but not `apply` itself) is left in
`self.rows` pointing at a widget `List::clear()` just freed.
`TextEditable::edit` (`iris/src/widget/text/edit.rs:582-587`) resolves
that handle with `ui.widgets.get_mut(self).unwrap()` — an unconditional
panic on the freed slot. `Selection::begin` (`selection.rs:88-101`)
iterates *every* registered row (`w.edit(ui).deselect()`) on an
ordinary fresh press, so the crash fires on the next tap anywhere in
the transcript after a regroup, not only on a tap targeting the
orphaned row.
Fix: give `Selection` a way to reconcile against the row set that
survived a rebuild (e.g. `Selection::retain(&self, keys: &BTreeSet<RowKey>)`
removing everything else, called from the `Rebuild` arm before
rebuilding), or simplest — call `self.selection.borrow_mut()` cleared
the same way `List::clear()` clears the list, then let the rebuild's
`push_row` calls re-`register` everything as they already do.
## Guarded invariants missing
2. **`iris/src/widget/list.rs:751` (`List::place`) indexes/expects on
`slot` with no assertion that it exists.** `slot_widget` (`:563-575`)
panics via `.expect(...)` for a sentinel with no widget set, and does
an unchecked `&self.items[s as usize]` for a real index — a bare
"index out of bounds" with no context if `place` is ever reached with a
stale slot. Every current caller happens to derive `slot` from
`repair_anchor`/`prev_slot`/`next_slot`, which already check existence,
but that invariant is enforced by convention across three call sites,
not by the function that depends on it. Add
`debug_assert!(self.slot_exists(slot), "place() called with a slot that doesn't exist: {slot:?}");`
at the top of `place`.
3. **`iris/src/widget/list.rs:426` (`List::fling`) and `sense.rs`'s
`FlingCalculator::distance`/`duration`/`position_at` never check that
the incoming velocity is finite.** A `NaN`/`inf` velocity (a
`VelocityTracker::velocity()` divide-by-near-zero span, or a caller
passing a raw device value straight through) propagates through
`deceleration_for`'s `.ln()` silently — the fling either never settles
(`settled_on_schedule` compares against a `NaN` `duration()`, which is
always `false`) or jumps to `NaN` positions with nothing on screen
saying why. Add `debug_assert!(velocity_px_per_s.is_finite())` in
`List::fling` and `FlingCalculator::new`/`distance`.
4. **`iris/src/sense.rs:592-604` (`VelocityTracker::velocity`) has no
assertion that samples are chronological.** `add_sample` trusts its
caller's `Instant` ordering; a caller that samples out of order (a
restored/replayed gesture, a test) would silently produce a negative
`span` handled only by the `span <= 0.0 => 0.0` catch-all, masking the
bug that produced it rather than surfacing it. Add
`debug_assert!(self.samples.back().is_none_or(|&(last, _)| at >= last))`
in `add_sample`.
5. **`iris/core/src/render/frame_report.rs:247-252` (`mark_phase`) has no
assertion that phases are pushed in non-decreasing `start_index`
order.** `phase_stats`'s slicing (`:274`, `idx >= phase.start_index &&
idx < end_index`) silently produces an empty or nonsensical slice for
an out-of-order phase rather than surfacing the misuse — cheap to add
given `self.phases.last()` is already in scope:
`debug_assert!(self.phases.last().is_none_or(|p| self.total_frames >= p.start_index));`
## Rules
6. **Two mechanisms answer "what row selection points at, still valid?"**
`Selection` relies on callers remembering to `unregister` (finding 1);
`List` relies on callers deriving slots only from already-checked
sources (finding 2). Both are the same class of problem — a derived
handle that silently outlives what it points to — solved ad hoc twice
rather than once. Not asking for a shared abstraction here, but the two
should at minimum cross-reference each other's doc comment so the next
caller who adds a third handle-into-`List`-rows type (the code rules'
"a rule that governs a set belongs to the set") finds both existing
examples.
7. **`iris/android-app/src/bench_client.rs:224-225` (`battery_line`)
calls `.min().unwrap()`/`.max().unwrap()` on `samples` guarded three
lines above by `if samples.is_empty()`, which is fine — but the guard
and the two unwraps are two statements apart with a `let mean = ...`
in between reading the same slice; a future edit reordering those
lines loses the guard's protection silently.** Low severity (this is
the bench tool, not the app), but worth a one-line comment tying the
unwraps back to the guard, or restructuring as
`let (Some(min), Some(max)) = (samples.iter().min(), samples.iter().max())`
pattern so the empty case can't be separated from the check by a future
edit.
## Tests
8. **No test exercises `TranscriptScreen::apply`'s `Rebuild` arm through
`Selection`.** `lib.rs`'s `diff_tests` module (`:284-379`) tests only
the pure `diff_rows` decision function, never `apply` itself wired to a
real `Selection`; `selection.rs`'s own tests (`a_missed_press_start_
recovers_on_the_next_pressing_frame`, `unregister_forgets_the_row_and_
clears_a_matching_anchor`) never go through `apply`/`List::clear`
either. This is exactly the gap that let finding 1 through: the two
pieces (`apply`'s fallback, `Selection`'s registration contract) are
each tested in isolation and never together. Add: build a
`TranscriptScreen`, force a `RowDiff::Rebuild` (two adjacent tool-call
rows regrouping, per the existing `diff_tests` case), then call
`selected_text`/simulate a fresh press on a surviving row and assert no
panic.
9. **`iris/src/widget/list.rs`'s fling tests check total distance and the
start/end clamp but not the speed profile in between.**
`fling_moves_the_list_and_then_settles`/`fling_distance_is_positive_
toward_the_end` only assert the fling started, moved in the right
direction, and eventually stopped — none checks that
`tick_fling`'s per-tick delta is *monotonically decreasing* once past
the fling's peak (the property `fling_calculator_tests::position_at_
is_monotonic_and_clamped_past_the_end` already checks one level down,
for `FlingCalculator` alone, but never through `List::tick_fling`'s own
`scroll`/`anchor.offset` accumulation). A regression that made
`tick_fling` apply the *total* distance every tick instead of the
incremental one, for instance, would still pass both existing tests
(final position and direction are unaffected by how the interior ticks
split it up) while being wildly wrong every intermediate frame.
10. **`iris/src/widget/list.rs::replacing_the_last_row_stays_pinned_to_
the_bottom` and its sibling test `replace_back`'s effect on the
displayed row, never that the row it evicted is actually gone from
`heights`/`extents`.** Both tests assert the *new* row's position;
neither asserts `old.key` is absent from `list_ref.heights`/`extents`
after the replace (the "stale primitive" class finding 1 is a
production instance of). A cheap addition: assert
`!list_ref.heights.contains_key(&old.key)` after `replace_back` in the
existing test, since `old.key` is already returned to the test as
`evicted`... (`lib.rs` calls it that way; the `list.rs` test would need
to capture the key from `old` similarly.)
## Docs
No missing `IRIS.md` entry found for a *public* API change in this diff —
`List::fling`/`VelocityTracker`/`FlingCalculator`, `List::
anchor_position_display`, `FrameReport::mark_phase`/`phase_stats`/
`late_at_hz`, `UiRenderNode::new`'s `Result` change, `Len::dp`, and
`List::replace_back`/`clear`/`TranscriptScreen::apply` all have entries.
The `List::replace_back`/`clear`/`TranscriptScreen::apply` entry
(`docs/IRIS.md:526`) predates this review's finding 1 and does not mention
`Selection`'s registration contract at all — once finding 1 is fixed,
that entry should gain a line noting what the fix requires of a caller
that keeps its own row-keyed side table (the same shape `Selection` is),
so the next such table doesn't reproduce the same gap.
## Fixed, 2026-09-06
All ten findings addressed after the `DragGesture` merge (`selection.rs`
was rewritten by that merge, but finding 1's shape and location were
unchanged — `TranscriptScreen::apply`'s `Rebuild` arm, `iris/transcript-ui/
src/lib.rs`).
1. **Fixed.** `Selection::clear()` (`selection.rs`) drops `rows` and
`anchor`, called from `apply`'s `Rebuild` arm right before
`List::clear()``push_row` re-`register`s whatever survives as it
rebuilds each row, the "simplest" fix option the finding named.
2. **Fixed.** `debug_assert!(self.slot_exists(slot), ...)` at the top of
`List::place` (`iris/src/widget/list.rs`).
3. **Fixed.** `debug_assert!(velocity_px_per_s.is_finite())` in
`List::fling`, and `debug_assert!(velocity.is_finite())` in
`FlingCalculator::distance`/`duration` (`iris/src/sense.rs`).
`position_at` calls both, so it inherits the guard rather than needing
its own.
4. **Fixed.** `debug_assert!` on chronological sample order in
`VelocityTracker::add_sample` (`iris/src/sense.rs`).
5. **Fixed.** `debug_assert!` on non-decreasing `start_index` in
`FrameReport::mark_phase` (`iris/core/src/render/frame_report.rs`).
6. **Fixed (doc cross-reference only, as asked).** `Selection::register`'s
doc now points at `List::place`'s `slot_exists` assertion and vice
versa isn't needed since finding 2's fix already cites this file in
its own comment; both are grep-able on "docs/REVIEW-2026-09-06.md" and
on each other's type names.
7. **Fixed.** `bench_client.rs::battery_line` restructured to
`let (Some(min), Some(max)) = (samples.iter().min(), samples.iter().max())`,
so the empty-guard and the two lookups can no longer be separated by a
future edit.
8. **Fixed.** `transcript-ui`'s new `apply_tests::
a_row_dropped_by_a_regroup_does_not_outlive_itself_in_selection`
(`lib.rs`) builds a real `TranscriptScreen`, forces the same regroup
shape `diff_tests` already covers at the pure-diff level, calls `apply`,
and then `Selection::begin` on a surviving row — which panicked before
fix 1, resolving a `WeakWidget` `List::clear()` had just freed.
9. **Fixed.** `list.rs`'s new `tick_fling_applies_shrinking_incremental_
deltas` flings toward the end from `jump_to_start` and asserts each
tick's `extents[&0]` delta is no larger than the previous one — would
fail against a `tick_fling` that applied the total spline distance
every tick instead of the incremental slice, which the two pre-existing
fling tests cannot catch.
10. **Fixed.** `list.rs`'s new `replace_back_forgets_the_evicted_keys_own_
height` replaces row 4 with a row keyed `100` (the two existing
`replace_back` tests always reuse the same key, so neither actually
exercises the removal) and asserts `heights` no longer contains the
evicted key.
Docs: `docs/IRIS.md`'s 2026-09-05 `List::replace_back`/`clear`/
`TranscriptScreen::apply` entry now has a line on what the fix requires of
a caller with its own row-keyed side table, naming `Selection` as the
example and dating the fix.
Verification run alongside the rest of this pass's checks: `cargo fmt
--all`, `cargo clippy --workspace --all-targets`, `cargo test --workspace`
from `iris/` — see docs/RUST.md's plan box for the pass/fail and any
caveats from this same session.
+1251 -37
View File
File diff suppressed because it is too large. Load diff
+11
View File
@@ -33,3 +33,14 @@ one in place when it turns out to need a decision.
that would work today, for Claude sessions, and it is the option that was that would work today, for Claude sessions, and it is the option that was
not chosen. not chosen.
## From Iris's phone log export, 2026-09-07 (Compose app)
- [ ] **Crash on 2026-09-03 11:40, `IllegalArgumentException: Reversed
range is not supported`** at `ToolInput.kt:200` (`highlighted`, inside
`ToolInputView` -> `RawBlock` -> `ToolCard`). An `AnnotatedString`
range was built with end before start while highlighting a tool
input. Found in the per-package system log she exported; the tool
input that triggered it is not in the log. Reproduce by fuzzing
`highlighted` with inputs whose token boundaries collapse, and guard
the range construction.
+8
View File
@@ -20,6 +20,14 @@ overlapping, and once more below the composer bar: primitives of a
replaced/removed row surviving in the GPU buffers, the same shape as the replaced/removed row surviving in the GPU buffers, the same shape as the
header drawn twice after a keyboard resize. header drawn twice after a keyboard resize.
**Root-caused and fixed 2026-09-06** (commit `76b1f99`): the diagnosis in
that sentence was right and the location was not -- `UiRenderState::
draw_inner` read the `needs_redraw` mark without consuming it and skipped
the branch that frees a redrawn widget's old primitives. docs/RUST.md's
"Stale primitives, the phone's half" box has the full account, the guard
(`orphaned_primitives`, `debug_assert`ed every frame) and the emulator run
that exercises it.
``` ```
iris bench report iris bench report
per phase: per phase:
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

+13
View File
@@ -150,6 +150,19 @@ pub enum Event {
ToolEnd { ToolEnd {
id: String, id: String,
output: String, output: String,
/// Whether the tool reported that the call *failed*, from the
/// CLI's own `is_error` on the `tool_result`.
///
/// Added 2026-09-06 with the tool-call cards (RUST.md's P1b),
/// because without it a result is the only thing a card has and a
/// failed call is drawn as confidently as a successful one -- the
/// missing state, not a wrong one. `#[serde(default)]` so a
/// transcript written before this field, or a peer on an older
/// build, reads back as "not reported to have failed" rather than
/// failing to parse; that is the same claim the field's absence
/// used to make implicitly.
#[serde(default)]
is_error: bool,
}, },
/// An image the session produced or was sent, saved under the session /// An image the session produced or was sent, saved under the session
/// dir and referenced by id; the phone fetches it by URL. /// dir and referenced by id; the phone fetches it by URL.
+38 -16
View File
@@ -721,6 +721,7 @@ name = "client-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"event-model", "event-model",
"pulldown-cmark",
"serde", "serde",
"serde_json", "serde_json",
"ureq", "ureq",
@@ -964,9 +965,9 @@ dependencies = [
[[package]] [[package]]
name = "dlib" name = "dlib"
version = "0.5.2" version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
dependencies = [ dependencies = [
"libloading", "libloading",
] ]
@@ -2874,9 +2875,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.38.4" version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [ dependencies = [
"memchr", "memchr",
] ]
@@ -3057,6 +3058,15 @@ version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce"
[[package]]
name = "rig-input"
version = "0.1.0"
dependencies = [
"iris",
"wayland-client",
"wayland-protocols-wlr",
]
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@@ -3645,6 +3655,18 @@ dependencies = [
"once_cell", "once_cell",
] ]
[[package]]
name = "transcript-fixture"
version = "0.1.0"
dependencies = [
"client-core",
"event-model",
"iris",
"serde_json",
"transcript-ui",
"winit",
]
[[package]] [[package]]
name = "transcript-ui" name = "transcript-ui"
version = "0.1.0" version = "0.1.0"
@@ -3893,9 +3915,9 @@ dependencies = [
[[package]] [[package]]
name = "wayland-backend" name = "wayland-backend"
version = "0.3.12" version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9" checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078"
dependencies = [ dependencies = [
"cc", "cc",
"downcast-rs", "downcast-rs",
@@ -3907,9 +3929,9 @@ dependencies = [
[[package]] [[package]]
name = "wayland-client" name = "wayland-client"
version = "0.31.12" version = "0.31.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags 2.10.0",
"rustix 1.1.3", "rustix 1.1.3",
@@ -3941,9 +3963,9 @@ dependencies = [
[[package]] [[package]]
name = "wayland-protocols" name = "wayland-protocols"
version = "0.32.10" version = "0.32.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3" checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags 2.10.0",
"wayland-backend", "wayland-backend",
@@ -3966,9 +3988,9 @@ dependencies = [
[[package]] [[package]]
name = "wayland-protocols-wlr" name = "wayland-protocols-wlr"
version = "0.3.10" version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3" checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags 2.10.0",
"wayland-backend", "wayland-backend",
@@ -3979,9 +4001,9 @@ dependencies = [
[[package]] [[package]]
name = "wayland-scanner" name = "wayland-scanner"
version = "0.31.8" version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quick-xml", "quick-xml",
@@ -3990,9 +4012,9 @@ dependencies = [
[[package]] [[package]]
name = "wayland-sys" name = "wayland-sys"
version = "0.31.8" version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd" checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
dependencies = [ dependencies = [
"dlib", "dlib",
"log", "log",
+20 -6
View File
@@ -15,6 +15,11 @@ wgpu = { workspace = true }
image = { workspace = true } image = { workspace = true }
accesskit = { workspace = true } accesskit = { workspace = true }
tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread"] } tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread"] }
# For diagnostics visible through android_logger (or whatever logger the
# app crate installs) -- this crate never installs one itself. Not in the
# android-only block below any more: the lines that matter most are in
# shared widget code, which the host backend compiles too.
log = "0.4.28"
# winit everywhere except Android; android-view (below) is what stands in # winit everywhere except Android; android-view (below) is what stands in
# for it there. Both backends live in this crate (see `src/android/mod.rs`'s # for it there. Both backends live in this crate (see `src/android/mod.rs`'s
@@ -53,9 +58,6 @@ accesskit_android = "0.8.0"
# for `android/insets.rs`'s own id -> state map -- the same reason # for `android/insets.rs`'s own id -> state map -- the same reason
# android-view's own `PEER_MAP` carries one. # android-view's own `PEER_MAP` carries one.
send_wrapper = "0.6.0" send_wrapper = "0.6.0"
# For diagnostics visible through android_logger, wherever the app crate
# installs it -- this crate never installs a logger itself.
log = "0.4.28"
[features] [features]
# RUST.md's I5 "Where iris's frame time goes" diagnosis: forces the Android # RUST.md's I5 "Where iris's frame time goes" diagnosis: forces the Android
@@ -64,7 +66,11 @@ log = "0.4.28"
# default) or virgl's GLES path, without a second env-var plumbing path that # default) or virgl's GLES path, without a second env-var plumbing path that
# nothing on this machine can hand to an already-launched Android process # nothing on this machine can hand to an already-launched Android process
# (there is no `am start` environment and no system-property reader here to # (there is no `am start` environment and no system-property reader here to
# add one). Android-only; `android/render.rs` is the only reader. # add one). Read by `android/render.rs` and, so the GLES path can be
# reproduced on a machine with a real GPU rather than only in the emulator,
# by `default/render.rs`:
# ./run-headless.sh transcript --shot /tmp/x.png -- -p transcript-ui \
# --features iris/force-gles
force-gles = [] force-gles = []
[dev-dependencies] [dev-dependencies]
@@ -83,13 +89,21 @@ name = "message_list"
harness = false harness = false
[workspace] [workspace]
members = ["core", "macro", "tabs-ui", "transcript-ui", "desktop-app"] members = [
"core",
"macro",
"tabs-ui",
"transcript-ui",
"transcript-fixture",
"rig-input",
"desktop-app",
]
# android-app pulls in android-view, which needs the NDK sysroot to link # android-app pulls in android-view, which needs the NDK sysroot to link
# -- excluded so `cargo build --workspace --all-targets` on the host stays # -- excluded so `cargo build --workspace --all-targets` on the host stays
# buildable. Cross-compile it from its own directory (its own single-crate # buildable. Cross-compile it from its own directory (its own single-crate
# workspace, since it has no `[workspace]` table of its own and this # workspace, since it has no `[workspace]` table of its own and this
# exclusion stops it inheriting this one): `cd android-app && cargo ndk # exclusion stops it inheriting this one): `cd android-app && cargo ndk
# -t x86_64 -P 26 build`. # -t x86_64 -P 29 build`.
exclude = ["android-app"] exclude = ["android-app"]
[workspace.package] [workspace.package]
+13
View File
@@ -745,6 +745,7 @@ name = "client-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"event-model", "event-model",
"pulldown-cmark",
"serde", "serde",
"serde_json", "serde_json",
"ureq", "ureq",
@@ -1773,6 +1774,7 @@ dependencies = [
"serde_json", "serde_json",
"tabs-ui", "tabs-ui",
"tokio", "tokio",
"transcript-fixture",
"transcript-ui", "transcript-ui",
] ]
@@ -3863,6 +3865,17 @@ dependencies = [
"once_cell", "once_cell",
] ]
[[package]]
name = "transcript-fixture"
version = "0.1.0"
dependencies = [
"client-core",
"event-model",
"iris",
"serde_json",
"transcript-ui",
]
[[package]] [[package]]
name = "transcript-ui" name = "transcript-ui"
version = "0.1.0" version = "0.1.0"
+5 -1
View File
@@ -29,6 +29,10 @@ log = "0.4.28"
# which Cargo's `unused_dependencies` lint (on by default) correctly flags. # which Cargo's `unused_dependencies` lint (on by default) correctly flags.
tabs-ui = { path = "../tabs-ui", optional = true } tabs-ui = { path = "../tabs-ui", optional = true }
transcript-ui = { path = "../transcript-ui", optional = true } transcript-ui = { path = "../transcript-ui", optional = true }
# P0's bench build only: the fixture and the folded screen both bench
# clients open, shared with the headless harness and the desktop window
# (docs/RUST.md's "Three test layers").
transcript-fixture = { path = "../transcript-fixture", optional = true }
client-core = { path = "../../client-core", optional = true } client-core = { path = "../../client-core", optional = true }
event-model = { path = "../../event-model", optional = true } event-model = { path = "../../event-model", optional = true }
serde_json = { version = "1", features = ["float_roundtrip"], optional = true } serde_json = { version = "1", features = ["float_roundtrip"], optional = true }
@@ -65,7 +69,7 @@ force-gles = ["iris/force-gles"]
# `event-model` -- `lib.rs`'s `ActiveClient` selection gives this feature # `event-model` -- `lib.rs`'s `ActiveClient` selection gives this feature
# priority over `transcript-screen`'s own `TranscriptClient` when both are # priority over `transcript-screen`'s own `TranscriptClient` when both are
# listed, which is how this crate's build command names both explicitly. # listed, which is how this crate's build command names both explicitly.
bench = ["transcript-screen", "dep:libc", "dep:tokio"] bench = ["transcript-screen", "dep:transcript-fixture", "dep:libc", "dep:tokio"]
[profile.release] [profile.release]
panic = "abort" panic = "abort"
+31 -2
View File
@@ -13,8 +13,37 @@ android {
defaultConfig { defaultConfig {
applicationId = "dev.iris.android.demo" applicationId = "dev.iris.android.demo"
minSdk = 26 // 29, not 26: `iris::android::view`'s touch handler dates each
targetSdk = 34 // sample with `MotionEvent.getEventTimeNanos` and
// `getHistoricalEventTimeNanos`, both API 29, and a missing JNI
// method there is a hard crash on the first touch rather than a
// degraded fling. Raised deliberately rather than guarded at
// runtime: nothing this app is built for runs below 29, and an
// untested fallback path is its own defect. `build-apk.sh`'s
// `cargo ndk -P` is kept at the same number.
minSdk = 29
// 37, matching `compileSdk` and the Compose app in `app/` -- which
// is the one part of this that is measured rather than reasoned:
// that app targets 37 and its keyboard does push the transcript up
// on Iris's phone, and this one targeted 34 and does not
// (2026-09-07). The emulator here is API 36 and the push-up works
// there at either target, so the target is the only difference the
// two devices do not share.
//
// The mechanism, stated as the reading it is: below targetSdk 35
// a window keeps the legacy behaviour, where `adjustResize` shrinks
// the window for the IME and `getInsets(ime()).bottom` therefore
// measures the overlap with an already-shrunk window -- zero, with
// nothing left to push up. `MainActivity`'s
// `setDecorFitsSystemWindows(false)` opts out of that, and on API
// 36 it still takes; Android 16 deprecated it and Android 17 is
// where it appears not to. At 35+ edge-to-edge is not opt-in, so
// the app is handed the real overlap without relying on a
// deprecated call. If the phone still reports `ime_bottom=0` with
// a nonzero `dispatches` in the Diagnostics pane, this reading was
// wrong and the `WindowInsetsAnimation.Callback` in
// `MainActivity` is the other half to look at.
targetSdk = 37
versionCode = 1 versionCode = 1
versionName = "1.0" versionName = "1.0"
} }
@@ -27,7 +27,7 @@ public final class IrisView extends RustView {
protected native long newViewPeer(Context context); protected native long newViewPeer(Context context);
native void applyWindowInsetsNative( native void applyWindowInsetsNative(
long peer, int left, int top, int right, int bottom, int imeBottom); long peer, int left, int top, int right, int bottom, int imeBottom, int imeVisible);
native void unregisterInsetsNative(long peer); native void unregisterInsetsNative(long peer);
@@ -35,8 +35,9 @@ public final class IrisView extends RustView {
super(context); super(context);
} }
void applyWindowInsets(int left, int top, int right, int bottom, int imeBottom) { void applyWindowInsets(
applyWindowInsetsNative(mViewPeer, left, top, right, bottom, imeBottom); int left, int top, int right, int bottom, int imeBottom, int imeVisible) {
applyWindowInsetsNative(mViewPeer, left, top, right, bottom, imeBottom, imeVisible);
} }
@Override @Override
@@ -4,7 +4,9 @@ import android.app.Activity;
import android.os.Build; import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.view.WindowInsets; import android.view.WindowInsets;
import android.view.WindowInsetsAnimation;
import android.widget.FrameLayout; import android.widget.FrameLayout;
import java.util.List;
/** /**
* The android-view backend's demo activity (RUST.md's I2): one IrisView * The android-view backend's demo activity (RUST.md's I2): one IrisView
@@ -50,36 +52,88 @@ public final class MainActivity extends Activity {
getWindow().setDecorFitsSystemWindows(false); getWindow().setDecorFitsSystemWindows(false);
} }
// **The keyboard's height arrives twice, over two different
// paths, and the phone needs the second one** (Iris, 2026-09-07:
// the emulator pushed the composer up and her Pixel did not).
// `setOnApplyWindowInsetsListener` is the platform's *settled*
// answer; `WindowInsetsAnimation.Callback` is the running one, and
// an IME that animates in delivers every intermediate height
// through the callback with the static dispatch arriving only at
// the ends -- on some devices only at `onEnd`. Registering both
// means neither device depends on the other's timing, and it is
// also what makes the push-up *animate* with the keyboard rather
// than jump when it lands.
//
// The two do not disagree, because they are the same call with the
// same numbers read out of whichever `WindowInsets` is current.
// `DISPATCH_MODE_CONTINUE_ON_SUBTREE` so this view consuming
// nothing keeps the ordinary dispatch running underneath.
// `onEnd` re-reads the root's insets rather than trusting the last
// `onProgress`: an animation interrupted mid-flight never delivers
// its final frame, which is exactly the fault the Compose app hit
// (AGENTS.md, "the composer can get stuck floating above the
// bottom of the screen").
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
view.setWindowInsetsAnimationCallback(new WindowInsetsAnimation.Callback(
WindowInsetsAnimation.Callback.DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
@Override
public WindowInsets onProgress(
WindowInsets insets, List<WindowInsetsAnimation> running) {
sendInsets(view, insets);
return insets;
}
@Override
public void onEnd(WindowInsetsAnimation animation) {
WindowInsets settled = view.getRootWindowInsets();
if (settled != null) {
sendInsets(view, settled);
}
}
});
}
view.setOnApplyWindowInsetsListener((v, insets) -> { view.setOnApplyWindowInsetsListener((v, insets) -> {
sendInsets((IrisView) v, insets);
return insets;
});
}
/** Read one `WindowInsets` and hand it to the Rust side. The only
* place that reads these fields, so the static dispatch and the
* animation callback above cannot come to report different things. */
private static void sendInsets(IrisView view, WindowInsets insets) {
int left = insets.getSystemWindowInsetLeft(); int left = insets.getSystemWindowInsetLeft();
int top = insets.getSystemWindowInsetTop(); int top = insets.getSystemWindowInsetTop();
int right = insets.getSystemWindowInsetRight(); int right = insets.getSystemWindowInsetRight();
int bottom = insets.getSystemWindowInsetBottom(); int bottom = insets.getSystemWindowInsetBottom();
// The manifest declares adjustResize (AGENTS.md: without it the // **Two separate answers, because they are separate questions**
// keyboard pans the whole window instead of resizing it), and // (Iris's phone, 2026-09-06: "message box does not push up the
// under adjustResize the window itself shrinks to make room for // scroll area"). `isVisible(ime())` says whether the keyboard is
// the keyboard -- which is exactly the condition under which // up; `getInsets(ime()).bottom` says how tall it is. An earlier
// WindowInsets.Type.ime()'s own *inset amount* reports zero: it // pass sent the boolean *as* the height (0 or 1) because under
// measures how much of the window the keyboard overlaps, and // plain `adjustResize` the window shrinks to make room and the ime
// resize already made that overlap zero by construction. That // inset therefore measures a zero overlap by construction -- true
// numeric inset is not a usable "is the keyboard open" signal // then, and no longer true now that this is an edge-to-edge window
// here (found while root-causing why bench_client.rs's keyboard // (`targetSdk` 35+, plus the `setDecorFitsSystemWindows` call
// phase and auto-diagnostics never fired on the emulator despite // above for the devices below that), which is exactly the case
// the keyboard visibly opening -- RUST.md's P0 box). What does // where the system stops resizing and hands the app the real
// survive adjustResize is the boolean isVisible() answer, set // overlap instead. Sending 1 for it left the Rust side padding the
// from the platform's own start/end of the transition over a // composer by one physical pixel, so the keyboard covered the bar
// different path than the inset amount -- the same fact // and the transcript alike.
// AGENTS.md's "Things that have bitten" already names for the //
// Compose side's identical trap. Passed through as a 0/1 stand- // The visibility is still sent in its own right rather than
// in for the ime_bottom pixel amount, since nothing on the Rust // inferred from `height > 0`: the two disagree during the
// side reads it as a real pixel value -- only `> 0.0`. // keyboard's slide-in and -out (visible, height still climbing),
// and "is the IME up" drives the bench's own state machine
// (`bench_client.rs`'s `ime_state`) where a half-open frame
// reading as "closed" is a miscount.
int imeBottom = 0; int imeBottom = 0;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R int imeVisible = 0;
&& insets.isVisible(WindowInsets.Type.ime())) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
imeBottom = 1; imeBottom = insets.getInsets(WindowInsets.Type.ime()).bottom;
imeVisible = insets.isVisible(WindowInsets.Type.ime()) ? 1 : 0;
} }
((IrisView) v).applyWindowInsets(left, top, right, bottom, imeBottom); view.applyWindowInsets(left, top, right, bottom, imeBottom, imeVisible);
return insets;
});
} }
} }
+7 -2
View File
@@ -52,11 +52,16 @@ if [ -z "$NDK_DIR" ]; then
fi fi
export ANDROID_NDK_HOME="$NDK_DIR" export ANDROID_NDK_HOME="$NDK_DIR"
# Only the ABI asked for goes into the APK. cargo ndk adds its output beside
# whatever earlier builds left here, and Gradle packages every directory it
# finds -- a debug x86_64 emulator build left behind made an arm64 "release"
# 339 MB on 2026-09-06.
rm -rf app/src/main/jniLibs
echo "build-apk.sh: cargo ndk -t $ABI build ${BUILD_TYPE:+(${BUILD_TYPE})} --features \"$FEATURES\"" echo "build-apk.sh: cargo ndk -t $ABI build ${BUILD_TYPE:+(${BUILD_TYPE})} --features \"$FEATURES\""
if [ "$BUILD_TYPE" = "release" ]; then if [ "$BUILD_TYPE" = "release" ]; then
cargo ndk -t "$ABI" -P 26 -o app/src/main/jniLibs/ build --release --features "$FEATURES" cargo ndk -t "$ABI" -P 29 -o app/src/main/jniLibs/ build --release --features "$FEATURES"
else else
cargo ndk -t "$ABI" -P 26 -o app/src/main/jniLibs/ build --features "$FEATURES" cargo ndk -t "$ABI" -P 29 -o app/src/main/jniLibs/ build --features "$FEATURES"
fi fi
GRADLE_TASK="assembleDebug" GRADLE_TASK="assembleDebug"
+7 -2
View File
@@ -51,9 +51,14 @@ ui-trace record -s "$SERIAL" -d 3000 --do "tap 'Run benchmark'" -o /tmp/run-benc
# phase, ~61s of typing, 10s of keyboard toggles, roughly 2.5 minutes end # phase, ~61s of typing, 10s of keyboard toggles, roughly 2.5 minutes end
# to end) but device speed varies. 260s cap rather than v1's 90s -- v2 is # to end) but device speed varies. 260s cap rather than v1's 90s -- v2 is
# a longer script than v1's swipe-loop-only run. # a longer script than v1's swipe-loop-only run.
# The report's own first line, not the bare "iris bench report:" prefix:
# `copy_report` logs that prefix too ("nothing to copy -- run the benchmark
# first", which the app emits at startup), so polling for the prefix
# returned instantly and the script printed a report that was never run.
REPORT_LINE="iris bench report: iris bench report"
i=0 i=0
while [ "$i" -lt 260 ]; do while [ "$i" -lt 260 ]; do
LINE=$(adb -s "$SERIAL" logcat -d -s iris-android-app:I 2>/dev/null | grep "iris bench report:" || true) LINE=$(adb -s "$SERIAL" logcat -d -s iris-android-app:I 2>/dev/null | grep "$REPORT_LINE" || true)
if [ -n "$LINE" ]; then if [ -n "$LINE" ]; then
break break
fi fi
@@ -66,4 +71,4 @@ if [ -z "$LINE" ]; then
fi fi
# -A 60 rather than v1's -A 6 -- v2's report has a per-phase block (four # -A 60 rather than v1's -A 6 -- v2's report has a per-phase block (four
# phases, four lines each) on top of the frames/bench sections v1 had. # phases, four lines each) on top of the frames/bench sections v1 had.
adb -s "$SERIAL" logcat -d -s iris-android-app:I | grep -A 60 "iris bench report:" adb -s "$SERIAL" logcat -d -s iris-android-app:I | grep -A 60 "$REPORT_LINE"
+82 -83
View File
@@ -6,14 +6,11 @@
//! //!
//! **Reuses `transcript_client.rs`'s shape** (folded items, the same //! **Reuses `transcript_client.rs`'s shape** (folded items, the same
//! `TranscriptScreen::apply` incremental update on every event) with the //! `TranscriptScreen::apply` incremental update on every event) with the
//! network half replaced by the checked-in fixture, embedded with //! network half replaced by the checked-in fixture. Reading that fixture
//! `include_str!` -- `app/bench-fixture/assets/transcript.jsonl`, //! and folding it into a screen is **`transcript-fixture`'s** job, not
//! 1,915,760 bytes, generated by `app/bench-fixture/generate.py` and never //! this file's -- the same crate the headless harness and the
//! a real transcript (that file's own README). The first 3,200 lines are //! phone-shaped desktop window open, so all three measure one screen
//! the opening backlog, folded once through //! (AGENTS.md's sharing rule; moved out of here 2026-09-07). The tail is
//! `client_core::transcript_fold::fold_page` exactly as a real
//! `/transcript` page would be (then a full `transcript_ui::build_tree`,
//! same as any first load); the remaining ~400 are the streaming tail,
//! replayed one at a time through `fold_event` -- the same fold path a //! replayed one at a time through `fold_event` -- the same fold path a
//! live SSE reply arrives on -- by the "Run benchmark" control below. //! live SSE reply arrives on -- by the "Run benchmark" control below.
//! Streaming through `apply` rather than a full rebuild per event is what //! Streaming through `apply` rather than a full rebuild per event is what
@@ -22,7 +19,7 @@
use crate::bench_jni::PlatformHandle; use crate::bench_jni::PlatformHandle;
use android_view::jni::{JavaVM, objects::GlobalRef}; use android_view::jni::{JavaVM, objects::GlobalRef};
use client_core::transcript_fold::{TranscriptItem, fold_event, fold_page, group_tool_runs}; use client_core::transcript_fold::{TranscriptItem, fold_event};
use event_model::SeqEvent; use event_model::SeqEvent;
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState}; use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
use iris::prelude::*; use iris::prelude::*;
@@ -30,13 +27,6 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
/// bench-fixture/README.md: the first `BACKLOG_COUNT` non-blank lines are
/// the opening window; the rest are the streaming tail. Kept in sync with
/// `BenchFixture.kt`'s identical constant by hand -- both read the same
/// checked-in file, so a mismatch would only mean the two apps' bench
/// builds open a different split of it, not a wrong-vs-right answer.
const BACKLOG_COUNT: usize = 3200;
/// RUST.md's "Benchmark v2" spec, written once so both apps' bench clients /// RUST.md's "Benchmark v2" spec, written once so both apps' bench clients
/// implement the identical four phases -- see that box before changing any /// implement the identical four phases -- see that box before changing any
/// constant here, since a mismatch would make the two reports stop /// constant here, since a mismatch would make the two reports stop
@@ -90,7 +80,11 @@ const KEYBOARD_WAIT_MS: u64 = 1_000;
/// when a later step in the same phase needs to read state back. /// when a later step in the same phase needs to read state back.
const ANIM_STEP_MS: u64 = 16; const ANIM_STEP_MS: u64 = 16;
const FIXTURE_JSONL: &str = include_str!("../../../app/bench-fixture/assets/transcript.jsonl"); /// How much of the screen a *filled* benchmark report may take before it
/// scrolls instead of growing -- roughly a third of a phone screen, the
/// share the pane used to reserve unconditionally. An empty report takes
/// nothing at all; see `new`'s comment at the tree it is used in.
const REPORT_MAX_HEIGHT_DP: f32 = 260.0;
pub struct BenchClient { pub struct BenchClient {
ui_state: AndroidUiState, ui_state: AndroidUiState,
@@ -152,31 +146,6 @@ impl HasAndroidUiState for BenchClient {
} }
} }
/// Parses the fixture once: `serde_json::Value`s for the backlog
/// (`fold_page` takes a page of raw wire JSON, same as a real
/// `/transcript` response) and folded `SeqEvent`s for the tail (`fold_event`
/// takes one live wire event at a time, same as a real SSE frame).
fn parse_fixture() -> (Vec<serde_json::Value>, Vec<SeqEvent>) {
let lines: Vec<&str> = FIXTURE_JSONL
.lines()
.filter(|line| !line.trim().is_empty())
.collect();
let mut backlog = Vec::with_capacity(BACKLOG_COUNT.min(lines.len()));
let mut stream_tail = Vec::new();
for (i, line) in lines.iter().enumerate() {
let value: serde_json::Value =
serde_json::from_str(line).expect("bench fixture is generated JSON, always valid");
if i < BACKLOG_COUNT {
backlog.push(value);
} else {
let event: SeqEvent = serde_json::from_value(value)
.expect("bench fixture event matches event-model's SeqEvent");
stream_tail.push(event);
}
}
(backlog, stream_tail)
}
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget { fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
wtext(message.to_string()) wtext(message.to_string())
.color(Color::WHITE) .color(Color::WHITE)
@@ -221,8 +190,15 @@ fn battery_line(samples: &[i32]) -> String {
return " battery current: unavailable on this device".to_string(); return " battery current: unavailable on this device".to_string();
} }
let mean = samples.iter().map(|&v| v as i64).sum::<i64>() / samples.len() as i64; let mean = samples.iter().map(|&v| v as i64).sum::<i64>() / samples.len() as i64;
let min = samples.iter().min().unwrap(); // `min`/`max` are guarded by the `is_empty` check above, three lines
let max = samples.iter().max().unwrap(); // up -- pairing the `Option` unwraps with the emptiness check right
// here (rather than two statements apart, with `mean` in between
// reading the same slice) is what keeps a future reorder from
// separating the guard from what it protects (docs/
// REVIEW-2026-09-06.md finding 7).
let (Some(min), Some(max)) = (samples.iter().min(), samples.iter().max()) else {
unreachable!("samples is non-empty, checked above");
};
format!( format!(
" battery current: mean {mean}\u{b5}A over {} samples (min {min}, max {max})", " battery current: mean {mean}\u{b5}A over {} samples (min {min}, max {max})",
samples.len() samples.len()
@@ -248,10 +224,28 @@ impl AndroidAppState for BenchClient {
let top_bar = WidgetPtr::new().add(rsc); let top_bar = WidgetPtr::new().add(rsc);
let controls = bench_controls(rsc, 0.0); let controls = bench_controls(rsc, 0.0);
top_bar(rsc).set(controls); top_bar(rsc).set(controls);
// The report pane is sized to whatever report it is holding, not
// to a share of the window: `rest(1)` here reserved a third of
// the screen for an *empty* `TextEdit` at every launch, which is
// what Iris's 2026-09-06 11:39 phone report described as "the app
// does not start with keyboard spacing correct" -- the composer
// two thirds down with black below it, nothing to do with the IME
// inset (measured: `iris insets:` reports bottom=63 ime_bottom=0
// at launch, while the `Message` field's own box sat 789px above
// the bottom of a 2282px surface -- exactly this pane's third).
// Capped and scrollable so a long report cannot take the screen
// back over, the same idiom `composer.rs` uses for the field.
// Above the transcript, not below it: the report is what the
// header's own "Run benchmark" button produces (UI_RULES.md --
// results appear where the action was started), and a pane under
// the composer would eat the navigation-bar clearance
// `set_bottom_inset` gives it.
let tree = ( let tree = (
top_bar, top_bar,
content.height(rest(2)), report_display
report_display.height(rest(1)).pad(dp(8)), .pad(dp(8))
.max_height(dp(REPORT_MAX_HEIGHT_DP)),
content.height(rest(1)),
) )
.span(Dir::DOWN) .span(Dir::DOWN)
.add_strong(rsc) .add_strong(rsc)
@@ -291,12 +285,12 @@ impl AndroidAppState for BenchClient {
last_top_pad: 0.0, last_top_pad: 0.0,
}; };
let (backlog, stream_tail) = parse_fixture(); match transcript_fixture::build_screen(rsc) {
client.stream_tail = stream_tail; Ok((opened, tree)) => {
match fold_page(&backlog) { client.items = opened.items;
Ok(items) => { client.stream_tail = opened.stream_tail;
client.items = items; (client.content)(rsc).set(tree);
client.rebuild_transcript(rsc); client.screen = Some(opened.screen);
} }
Err(message) => { Err(message) => {
client.show_message(rsc, &format!("Couldn't fold the bench fixture: {message}")) client.show_message(rsc, &format!("Couldn't fold the bench fixture: {message}"))
@@ -377,7 +371,12 @@ impl AndroidAppState for BenchClient {
.set_bottom_inset(rsc, insets.bottom.max(insets.ime_bottom)); .set_bottom_inset(rsc, insets.bottom.max(insets.ime_bottom));
} }
let ime_visible = insets.ime_bottom > 0.0; // The platform's own answer, not `ime_bottom > 0.0` -- see
// `iris::android::WindowInsets::ime_bottom`. The height is still
// climbing while the keyboard slides in, so a frame or two of a
// real opening reads as "closed" when the boolean is inferred from
// it, and `shown_events`/`hidden_events` below count transitions.
let ime_visible = insets.ime_visible;
let mut ime = self.ime_state.lock().unwrap(); let mut ime = self.ime_state.lock().unwrap();
if ime_visible && !ime.visible { if ime_visible && !ime.visible {
@@ -510,8 +509,7 @@ impl BenchClient {
} }
fn rebuild_transcript(&mut self, rsc: &mut Rsc) { fn rebuild_transcript(&mut self, rsc: &mut Rsc) {
let rows = group_tool_runs(&self.items); let (screen, tree) = transcript_ui::build_tree(rsc, transcript_fixture::rows(&self.items));
let (screen, tree) = transcript_ui::build_tree(rsc, rows);
(self.content)(rsc).set(tree); (self.content)(rsc).set(tree);
self.screen = Some(screen); self.screen = Some(screen);
} }
@@ -523,47 +521,48 @@ impl BenchClient {
/// text is currently shown -- `last_report` is what `copy_report` reads, /// text is currently shown -- `last_report` is what `copy_report` reads,
/// so it's set here too rather than adding a second copy path. /// so it's set here too rather than adding a second copy path.
fn show_diagnostics(&mut self, rsc: &mut Rsc) { fn show_diagnostics(&mut self, rsc: &mut Rsc) {
let report = self.diagnostics_text(rsc);
self.report_display.edit(rsc).set(&report);
self.last_report = Some(report);
}
/// The diagnostics report as text, with no side effect on what is on
/// screen -- shared by the `Diagnostics` button (which shows it) and
/// the keyboard-open capture (which only logs it), so the two can
/// never drift into reporting different things.
fn diagnostics_text(&self, rsc: &mut Rsc) -> String {
let font = rsc.ui.text.font_diagnostics(); let font = rsc.ui.text.font_diagnostics();
let frame_report = match self.android_state().frame_report.report() { let frame_report = match self.android_state().frame_report.report() {
Some(stats) => format!("{stats}"), Some(stats) => format!("{stats}"),
None => "no frames recorded yet".to_string(), None => "no frames recorded yet".to_string(),
}; };
let report = match &self.android_state().renderer { let renderer = match &self.android_state().renderer {
Some(renderer) => renderer.diagnostics_report(&font, &frame_report), Some(renderer) => renderer.diagnostics_report(&font, &frame_report),
None => "iris diagnostics: no renderer yet (no surface)".to_string(), None => "iris diagnostics: no renderer yet (no surface)".to_string(),
}; };
self.report_display.edit(rsc).set(&report); // The insets line goes in the pane, not just the log: Iris has no
self.last_report = Some(report); // logcat on her phone, and "the keyboard does not push the
// composer up" cannot be told from "the listener never fired"
// without it (`AndroidUiState::insets_report`).
format!("{renderer}\n{}", self.android_state().insets_report())
} }
/// The keyboard's own diagnostics capture -- see `on_insets_changed`'s /// The keyboard's own diagnostics capture -- see `on_insets_changed`'s
/// doc comment. Reuses `show_diagnostics`'s exact report (so it is the /// doc comment. **Logged only.** It used to also copy the report to
/// same text the on-screen `Diagnostics` button produces, plus the /// the clipboard unprompted and put it in the shell's overlay view,
/// per-frame log `FrameReport` already keeps around the resize -- /// from when the keyboard-inset callback was not firing at all and a
/// `frame_report.report()` above covers "the frames around the /// report could not be got off the phone any other way. Both are gone
/// resize" without a second accounting mechanism), then does three /// as of 2026-09-06: the callback fires reliably now (edge-to-edge,
/// things the button does not: logs it (so a `logcat` pull gets it /// `MainActivity.java`), and the overlay covered the whole screen on
/// even if nothing on screen does), copies it to the clipboard /// *every* keyboard open with its own Copy/Close buttons underneath
/// unprompted, and shows it in the shell's plain overlay view, which /// the keyboard, so it could not be dismissed -- an interruption for
/// draws independently of iris's own renderer -- the whole point, /// something nobody asked for, over an app you are trying to type
/// since the renderer is exactly what might be in the wiped state /// into (UI_RULES.md). The named `Diagnostics` button still shows the
/// this exists to report on. /// same text on demand, and `iris surface:`/`iris insets:` (view.rs)
/// carry the lifecycle a `logcat` pull actually needs.
fn capture_keyboard_diagnostics(&mut self, rsc: &mut Rsc) { fn capture_keyboard_diagnostics(&mut self, rsc: &mut Rsc) {
self.show_diagnostics(rsc); let report = self.diagnostics_text(rsc);
let Some(report) = self.last_report.clone() else {
return;
};
log::info!("iris keyboard diagnostics:\n{report}"); log::info!("iris keyboard diagnostics:\n{report}");
let Some(platform) = &self.platform else {
log::info!("iris keyboard diagnostics: no platform handle, can't reach the shell");
return;
};
if platform.copy_to_clipboard("iris keyboard diagnostics", &report) {
log::info!("iris keyboard diagnostics: copied to clipboard");
} else {
log::info!("iris keyboard diagnostics: clipboard copy failed");
}
platform.show_diagnostics_overlay(&report);
} }
fn copy_report(&mut self) { fn copy_report(&mut self) {
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env python3
"""AOSP's fling spline, transcribed independently of the Rust port.
This exists so the numbers in `sense.rs`'s `the_spline_matches_aosps_own_table`
and `a_flick_decelerates_the_way_aosp_says_it_does` are not the Rust code
grading its own homework. Every test iris's fling had before 2026-09-07
compared the curve with itself -- monotonic, signed, integrates to the closed
form -- and all of them passed while `distance_fraction(t)` was returning
exactly `t` (see `android_fling_spline`'s doc comment). Numbers checked into a
test have to come from somewhere else, and this is the somewhere else.
Transcribed by hand from, and only from:
* frameworks/base `core/java/android/widget/OverScroller.java`,
`SplineOverScroller`'s static initialiser, `getSplineDeceleration`,
`getSplineFlingDistance`, `getSplineFlingDuration` and `update`.
* androidx.compose.animation:animation:1.12.0 `SplineBasedDecay.kt`
(`computeSplineInfo`, `AndroidFlingSpline.flingPosition`) and
`FlingCalculator.kt` (`computeDeceleration`, `flingDistance`,
`flingDuration`, `FlingInfo.position`/`velocity`). The two agree line for
line, which is why iris ports one curve rather than two.
Run it with no arguments; it prints the table entries and the (velocity,
density, t) points the Rust tests assert on.
"""
NB_SAMPLES = 100
INFLEXION = 0.35
START_TENSION = 0.5
END_TENSION = 1.0
P1 = START_TENSION * INFLEXION
P2 = 1.0 - END_TENSION * (1.0 - INFLEXION)
# ViewConfiguration.getScrollFriction(), and SplineOverScroller's own
# "look and feel tuning" constant -- a different number in a different place
# of the same formula, which is the pair iris got the wrong way round once.
SCROLL_FRICTION = 0.015
TUNING = 0.84
GRAVITY_EARTH = 9.80665
INCHES_PER_METER = 39.37
import math
DECELERATION_RATE = math.log(0.78) / math.log(0.9)
def spline_positions():
"""SPLINE_POSITION: distance fraction at each of 101 even time steps."""
position = [0.0] * (NB_SAMPLES + 1)
x_min = 0.0
for i in range(NB_SAMPLES):
alpha = i / NB_SAMPLES
x_max = 1.0
while True:
x = x_min + (x_max - x_min) / 2.0
coef = 3.0 * x * (1.0 - x)
# Solved on the P1/P2 curve...
tx = coef * ((1.0 - x) * P1 + x * P2) + x * x * x
if abs(tx - alpha) < 1e-5:
break
if tx > alpha:
x_max = x
else:
x_min = x
# ...and sampled on the tension curve.
position[i] = coef * ((1.0 - x) * START_TENSION + x * END_TENSION) + x * x * x
position[NB_SAMPLES] = 1.0
return position
POSITION = spline_positions()
def fling_sample(t):
"""(distance fraction, velocity fraction) at time fraction `t`."""
t = min(max(t, 0.0), 1.0)
index = int(t * NB_SAMPLES)
if index >= NB_SAMPLES:
return 1.0, 0.0
t_inf = index / NB_SAMPLES
t_sup = (index + 1) / NB_SAMPLES
velocity_coef = (POSITION[index + 1] - POSITION[index]) / (t_sup - t_inf)
return POSITION[index] + (t - t_inf) * velocity_coef, velocity_coef
def physical_coefficient(density):
return GRAVITY_EARTH * INCHES_PER_METER * density * 160.0 * TUNING
def deceleration(velocity, density):
return math.log(
INFLEXION * abs(velocity) / (SCROLL_FRICTION * physical_coefficient(density))
)
def fling_distance(velocity, density):
l = deceleration(velocity, density)
return (
SCROLL_FRICTION
* physical_coefficient(density)
* math.exp(DECELERATION_RATE / (DECELERATION_RATE - 1.0) * l)
)
def fling_duration_s(velocity, density):
l = deceleration(velocity, density)
return math.exp(l / (DECELERATION_RATE - 1.0))
def position_at(velocity, density, t_seconds):
d = fling_duration_s(velocity, density)
return fling_distance(velocity, density) * fling_sample(t_seconds / d)[0]
def velocity_at(velocity, density, t_seconds):
d = fling_duration_s(velocity, density)
return fling_sample(t_seconds / d)[1] * fling_distance(velocity, density) / d
if __name__ == "__main__":
print("SPLINE_POSITION at a few indices (index: value)")
for i in (0, 1, 10, 25, 50, 75, 99, 100):
print(f" {i:3}: {POSITION[i]:.6f}")
print()
print("distance/velocity fraction at time fractions")
for t in (0.0, 0.1, 0.25, 0.5, 0.75, 0.9, 1.0):
d, v = fling_sample(t)
print(f" t={t:<5} distance={d:.6f} velocity={v:.6f}")
print()
# 2.55 is Iris's Pixel 9 Pro XL (docs/bench/iris-phone-v2-2026-09-06.md);
# 2.75 is this checkout's emulator.
for density in (2.55, 2.75):
for velocity in (5000.0, 11064.0):
dur = fling_duration_s(velocity, density)
print(
f"density={density} v={velocity}: "
f"distance={fling_distance(velocity, density):.3f}px "
f"duration={dur:.4f}s"
)
# Deliberately not round fractions. The velocity coefficient is
# piecewise *constant* across each of the 100 samples, so it
# steps at t = k/100 and a test asserting on 0.75 is asserting
# on which side of a discontinuity the last float landed --
# which is genuinely different between Python and Rust and says
# nothing about the curve.
for frac in (0.125, 0.335, 0.505, 0.755):
t = frac * dur
print(
f" t={frac:>4} of duration ({t:.4f}s): "
f"pos={position_at(velocity, density, t):.3f}px "
f"vel={velocity_at(velocity, density, t):.3f}px/s"
)
+5 -5
View File
@@ -142,7 +142,7 @@ fn bench_first_frame(n: usize) {
let start = Instant::now(); let start = Instant::now();
render.update(&root, &mut rsc); render.update(&root, &mut rsc);
let elapsed = start.elapsed(); let elapsed = start.elapsed();
let (draws, rewrites, moves) = render.take_counters(); let (draws, rewrites, moves, _shapes) = render.take_counters();
report( report(
&format!("(a) first frame, N={n}"), &format!("(a) first frame, N={n}"),
elapsed, elapsed,
@@ -177,7 +177,7 @@ fn bench_scroll(n: usize, ticks: usize) {
let start = Instant::now(); let start = Instant::now();
render.update(&root, &mut rsc); render.update(&root, &mut rsc);
total += start.elapsed(); total += start.elapsed();
let (draws, rewrites, moves) = render.take_counters(); let (draws, rewrites, moves, _shapes) = render.take_counters();
total_draws += draws; total_draws += draws;
total_rewrites += rewrites; total_rewrites += rewrites;
total_moves += moves; total_moves += moves;
@@ -245,7 +245,7 @@ fn bench_input_grows(n: usize, lines: usize) {
let start = Instant::now(); let start = Instant::now();
render.update(&root, &mut rsc); render.update(&root, &mut rsc);
total += start.elapsed(); total += start.elapsed();
let (draws, rewrites, moves) = render.take_counters(); let (draws, rewrites, moves, _shapes) = render.take_counters();
total_draws += draws; total_draws += draws;
total_rewrites += rewrites; total_rewrites += rewrites;
total_moves += moves; total_moves += moves;
@@ -302,7 +302,7 @@ fn bench_insert_above_anchor(n: usize, inserts: usize) {
let start = Instant::now(); let start = Instant::now();
render.update(&root, &mut rsc); render.update(&root, &mut rsc);
total += start.elapsed(); total += start.elapsed();
let (draws, rewrites, moves) = render.take_counters(); let (draws, rewrites, moves, _shapes) = render.take_counters();
total_draws += draws; total_draws += draws;
total_rewrites += rewrites; total_rewrites += rewrites;
total_moves += moves; total_moves += moves;
@@ -384,7 +384,7 @@ fn bench_expand_holds_edge(n: usize, growths: usize) {
let start = Instant::now(); let start = Instant::now();
render.update(&root, &mut rsc); render.update(&root, &mut rsc);
total += start.elapsed(); total += start.elapsed();
let (draws, rewrites, moves) = render.take_counters(); let (draws, rewrites, moves, _shapes) = render.take_counters();
total_draws += draws; total_draws += draws;
total_rewrites += rewrites; total_rewrites += rewrites;
total_moves += moves; total_moves += moves;
+23
View File
@@ -147,6 +147,29 @@ impl Len {
} }
} }
/// The same fold as [`Self::apply_rest`] but staying a `Len`, so
/// `rest` survives: `dp` becomes physical pixels and every other
/// component is left alone.
///
/// **A `Len` a widget *reports* must have been through this.** `dp` is
/// an input unit -- a number the widget author wrote -- and the
/// containers that consume a reported length read `abs`/`rel`/`rest`
/// directly (`Span::draw`'s placement arithmetic, `Pad`'s addition),
/// so a reported `dp` is silently worth zero. That is what made the
/// composer's bar collapse to nothing the moment its content grew past
/// `MaxSize`'s cap: the cap was `dp(168)` and was returned unresolved,
/// so the bar was given a slot of 0 and the field inside it was panned
/// out of a container measured at -63px. `UiRenderState::draw_inner`
/// debug-asserts the invariant after every `Widget::draw`.
pub fn fold_dp(&self, density: f32) -> Self {
Self {
abs: self.abs + self.dp * density,
dp: 0.0,
rel: self.rel,
rest: self.rest,
}
}
pub fn abs(abs: impl UiNum) -> Self { pub fn abs(abs: impl UiNum) -> Self {
Self { Self {
abs: abs.to_f32(), abs: abs.to_f32(),
+6
View File
@@ -601,6 +601,11 @@ pub struct RenderedText {
pub glyphs: std::sync::Arc<Vec<PlacedGlyph>>, pub glyphs: std::sync::Arc<Vec<PlacedGlyph>>,
pub size: Vec2, pub size: Vec2,
pub color: UiColor, pub color: UiColor,
/// The [`GlyphAtlas::generation`] the glyphs above were placed against.
/// A holder must re-render rather than re-emit these quads once the
/// atlas has moved on (`GlyphAtlas::clear`'s doc says what happens
/// otherwise); `Painter::glyphs` debug-asserts it.
pub generation: u64,
} }
impl TextData { impl TextData {
@@ -619,6 +624,7 @@ impl TextData {
glyphs: std::sync::Arc::new(glyphs), glyphs: std::sync::Arc::new(glyphs),
size: buffer.size(), size: buffer.size(),
color: attrs.color, color: attrs.color,
generation: self.atlas.generation(),
} }
} }
} }
+22
View File
@@ -71,6 +71,10 @@ struct Page {
#[derive(Default)] #[derive(Default)]
pub struct GlyphAtlas { pub struct GlyphAtlas {
pages: Vec<Page>, pages: Vec<Page>,
/// Bumped by [`GlyphAtlas::clear`], so anything holding placed glyphs
/// from an earlier atlas can tell that its coordinates are stale --
/// see that method's doc for what goes wrong without it.
generation: u64,
/// `None` for a glyph that rasterised to nothing -- a space, say. Cached /// `None` for a glyph that rasterised to nothing -- a space, say. Cached
/// too, so it is not re-rasterised on every layout. /// too, so it is not re-rasterised on every layout.
entries: HashMap<GlyphKey, Option<GlyphEntry>>, entries: HashMap<GlyphKey, Option<GlyphEntry>>,
@@ -166,6 +170,13 @@ impl GlyphAtlas {
self.entries.insert(key, None); self.entries.insert(key, None);
} }
/// Which atlas the entries handed out right now belong to. A
/// [`crate::RenderedText`] records this when it is built and is only
/// reusable while it still matches.
pub fn generation(&self) -> u64 {
self.generation
}
pub fn page_count(&self) -> usize { pub fn page_count(&self) -> usize {
self.pages.len() self.pages.len()
} }
@@ -188,9 +199,20 @@ impl GlyphAtlas {
/// new. Dropping `pages` also drops its `TextureHandle`s, which send a /// new. Dropping `pages` also drops its `TextureHandle`s, which send a
/// free message back through their `Textures`; see `Textures::reset`'s /// free message back through their `Textures`; see `Textures::reset`'s
/// doc for why that is harmless here. /// doc for why that is harmless here.
/// Bumping `generation` here is the other half of the same
/// invalidation: emptying this atlas does nothing about the
/// `RenderedText`s widgets are *already holding*
/// (`iris::widget::TextView`'s `tex` cache), whose `PlacedGlyph`s carry
/// `uv_min`/`uv_max`/`layer` into the atlas that has just been thrown
/// away. Those redraw perfectly happily and sample whatever now sits at
/// those coordinates -- the fragments-of-other-glyphs Iris photographed
/// after resuming the app on 2026-09-06. One counter, checked where the
/// cache is read, is what makes a cached render un-reusable across a
/// renderer rebuild.
pub fn clear(&mut self) { pub fn clear(&mut self) {
self.pages.clear(); self.pages.clear();
self.entries.clear(); self.entries.clear();
self.generation += 1;
} }
} }
+9
View File
@@ -245,6 +245,15 @@ impl FrameReport {
/// this once per phase (fling/stream/type/keyboard) so `phase_stats` /// this once per phase (fling/stream/type/keyboard) so `phase_stats`
/// can slice one whole run's frames by what was happening during each. /// can slice one whole run's frames by what was happening during each.
pub fn mark_phase(&mut self, name: &str) { pub fn mark_phase(&mut self, name: &str) {
// `phase_stats`'s slicing (`idx >= phase.start_index && idx <
// end_index`) silently produces an empty or nonsensical slice for
// a phase pushed out of order rather than surfacing the misuse
// (docs/REVIEW-2026-09-06.md finding 5).
debug_assert!(
self.phases
.last()
.is_none_or(|p| self.total_frames >= p.start_index)
);
self.phases.push(PhaseMark { self.phases.push(PhaseMark {
name: name.to_string(), name: name.to_string(),
start_index: self.total_frames, start_index: self.total_frames,
+26
View File
@@ -6,6 +6,7 @@ use crate::{
ArrBuf, ArrBuf,
data::{MaskIdx, MoveIdx, PrimitiveInstance}, data::{MaskIdx, MoveIdx, PrimitiveInstance},
}, },
util::HashSet,
}; };
use bytemuck::Pod; use bytemuck::Pod;
use wgpu::*; use wgpu::*;
@@ -277,6 +278,31 @@ impl Primitives {
} }
} }
/// How many instances are still bound for the GPU -- the O(1) half of
/// the orphan check, so the O(primitives) walk below only runs on a
/// frame that already looks wrong. See
/// [`crate::UiRenderState::orphaned_primitives`].
pub fn live_count(&self) -> usize {
(self.instances.len() - self.free.len()) + (self.images.len() - self.image_free.len())
}
/// Every instance that is still bound for the GPU, as `(inst_idx,
/// owner, is_image)` -- everything except the slots already handed to
/// [`Self::free`] and waiting for [`Self::apply_free`] to compact them
/// away. Only [`crate::UiRenderState::orphaned_primitives`] uses this,
/// to check that every drawn primitive still belongs to a live widget.
pub fn live_instances(&self) -> impl Iterator<Item = (usize, WidgetId, bool)> + '_ {
let free: HashSet<usize> = self.free.iter().copied().collect();
let image_free: HashSet<usize> = self.image_free.iter().copied().collect();
let rects = (0..self.instances.len())
.filter(move |i| !free.contains(i))
.map(|i| (i, self.assoc[i], false));
let images = (0..self.images.len())
.filter(move |i| !image_free.contains(i))
.map(|i| (i, self.image_assoc[i], true));
rects.chain(images)
}
pub fn data(&self) -> &PrimitiveData { pub fn data(&self) -> &PrimitiveData {
&self.data &self.data
} }
+11 -5
View File
@@ -80,11 +80,17 @@ var<storage> masks: array<Mask>;
@group(3) @binding(1) @group(3) @binding(1)
var<storage> move_offsets: array<MoveOffset>; var<storage> move_offsets: array<MoveOffset>;
// A move chain more than this deep means something else is wrong (an // The bound on the parent walk, kept in step with `MOVE_CHAIN_LIMIT` in
// accidental cycle) -- kept in step with `MOVE_CHAIN_LIMIT` in // render_state.rs, which walks the identical chain on the CPU side for
// render_state.rs, which walks the identical bound on the CPU side for // hit-testing. Bounded so a malformed chain (a cyclic `parent`) cannot
// hit-testing. Bounded so a malformed chain cannot hang the GPU. // hang the GPU -- not a claim about how deep a real tree gets. It was 16
const MOVE_CHAIN_LIMIT: u32 = 16u; // and that was too small: the transcript screen's composer field sits 17
// slots below the root, measured 2026-09-07 on this checkout's emulator
// by tapping it (the CPU walk's own debug assert names the chain now).
// Past the bound both walks simply stop summing, so the widget draws and
// hit-tests short by whatever the outer slots held, with nothing on
// screen to say so.
const MOVE_CHAIN_LIMIT: u32 = 64u;
/// Sums the pixel delta along the parent chain starting at `idx`, shared by /// Sums the pixel delta along the parent chain starting at `idx`, shared by
/// the vertex stage (a primitive's own corners) and the fragment stage (its /// the vertex stage (a primitive's own corners) and the fragment stage (its
+24 -1
View File
@@ -5,6 +5,10 @@ use crate::{PatchRect, TextureKind, TextureUpdate, Textures};
use super::atlas::PAGE; use super::atlas::PAGE;
/// The fewest layers the glyph atlas array is ever created with. Two, not
/// one, for the GLES reason written on `create_array_texture`.
const MIN_ARRAY_LAYERS: u32 = 2;
/// What one texture slot is, GPU-side. Parallel to `Textures`' own slot /// What one texture slot is, GPU-side. Parallel to `Textures`' own slot
/// numbering (`TextureKind`'s `Image`/`Page`), so a slot's index means the /// numbering (`TextureKind`'s `Image`/`Page`), so a slot's index means the
/// same thing on both sides without a second map to keep in sync. /// same thing on both sides without a second map to keep in sync.
@@ -360,7 +364,26 @@ impl GpuTextures {
}) })
} }
/// The atlas is sampled as a `texture_2d_array`, and **a one-layer
/// array is not one on the GLES backend**: wgpu-hal picks the GL
/// texture target from the descriptor alone
/// (`gles::Texture::get_info_from_desc`, `(false, 1) => TEXTURE_2D`),
/// so a capacity of 1 creates a `GL_TEXTURE_2D` and binds it to the
/// shader's `sampler2DArray`. GL then treats that unit as incomplete
/// and every `textureSample` returns (0, 0, 0, 1) -- which, through
/// `draw_glyph`'s `color.a *= texel.a`, draws every glyph as a solid
/// filled box. That was iris's appearance on the emulator's GLES for
/// two days (RUST.md, "the emulator cannot draw iris's glyphs"), and
/// it is a real defect on any device whose adapter is GL rather than
/// Vulkan, not an emulator artifact. So the array never has fewer than
/// `MIN_ARRAY_LAYERS` layers; the second layer costs one page of
/// texture memory and is used by the next atlas page anyway.
fn create_array_texture(device: &Device, capacity: u32) -> Texture { fn create_array_texture(device: &Device, capacity: u32) -> Texture {
debug_assert!(
capacity >= MIN_ARRAY_LAYERS,
"glyph atlas array asked for {capacity} layers; fewer than {MIN_ARRAY_LAYERS} is a \
GL_TEXTURE_2D on the GLES backend and draws every glyph as a box"
);
device.create_texture(&TextureDescriptor { device.create_texture(&TextureDescriptor {
label: Some("glyph atlas array"), label: Some("glyph atlas array"),
size: Extent3d { size: Extent3d {
@@ -382,7 +405,7 @@ impl GpuTextures {
pub fn new(device: &Device, queue: &Queue) -> Self { pub fn new(device: &Device, queue: &Queue) -> Self {
let sampler = default_sampler(device); let sampler = default_sampler(device);
let null_view = null_texture_view(device); let null_view = null_texture_view(device);
let array_capacity = 1; let array_capacity = MIN_ARRAY_LAYERS;
let array_texture = Self::create_array_texture(device, array_capacity); let array_texture = Self::create_array_texture(device, array_capacity);
let array_view = array_texture.create_view(&TextureViewDescriptor { let array_view = array_texture.create_view(&TextureViewDescriptor {
dimension: Some(TextureViewDimension::D2Array), dimension: Some(TextureViewDimension::D2Array),
+46 -1
View File
@@ -1,4 +1,6 @@
use crate::{LayerId, MaskIdx, MoveIdx, PrimitiveHandle, Size, TextureHandle, UiRegion, WidgetId}; use crate::{
LayerId, MaskIdx, MoveIdx, PrimitiveHandle, Size, TextureHandle, UiRegion, WidgetId, util::Vec2,
};
/// important non rendering data for retained drawing /// important non rendering data for retained drawing
#[derive(Debug)] #[derive(Debug)]
@@ -9,7 +11,22 @@ pub struct ActiveData {
pub textures: Vec<TextureHandle>, pub textures: Vec<TextureHandle>,
pub primitives: Vec<PrimitiveHandle>, pub primitives: Vec<PrimitiveHandle>,
pub children: Vec<WidgetId>, pub children: Vec<WidgetId>,
/// The mask this widget was drawn **under** (its parent's), not the
/// one it set for itself -- see `own_mask` for that.
pub mask: MaskIdx, pub mask: MaskIdx,
/// The mask slot this widget allocated for *itself* with
/// `Painter::set_mask`, or `MaskIdx::NONE`. Kept across redraws and
/// rewritten in place, the way `move_slot` is: a `Masked` that pushed
/// a fresh slot each draw left every already-drawn descendant --
/// which `draw_inner`'s unchanged-region fast path does not revisit --
/// clipping to the *old* slot's region, so a composer whose bar had
/// since been placed at the bottom of the screen was still being
/// clipped to a box at the top of it and drew nothing (measured
/// 2026-09-06: four mask entries live, none of them the widget's
/// current region). Its path out is the `undraw` branch of
/// `UiRenderState::remove`, which drops the self-ownership ref taken
/// when the slot was allocated.
pub own_mask: MaskIdx,
pub layer: LayerId, pub layer: LayerId,
/// What `Widget::draw` returned the last time this widget was actually /// What `Widget::draw` returned the last time this widget was actually
/// drawn -- read by a parent placing this widget again without /// drawn -- read by a parent placing this widget again without
@@ -21,4 +38,32 @@ pub struct ActiveData {
/// so a retained child's `parent` link never goes stale). See /// so a retained child's `parent` link never goes stale). See
/// LAYOUT.md section 2. /// LAYOUT.md section 2.
pub move_slot: MoveIdx, pub move_slot: MoveIdx,
/// How much of this widget's own `move_slot` delta is already folded
/// into `region` above, in window pixels. The two mechanisms that
/// write that slot disagree about this and cannot be told apart from
/// the slot alone: `UiRenderState::mov` shifts `region` and the delta
/// together (the *offered* region genuinely moved), while
/// `Painter::reposition` writes only the delta (`region` stays the
/// offered box and the delta says where inside it the content was
/// placed). So anything that wants the widget's real position --
/// `resolved_region`, and through it every hit test -- must subtract
/// this from the chain sum. Without it a panned widget's own hit box
/// sits at twice the pan while its descendants' are correct, which is
/// how it went unnoticed: the composer's field became untappable
/// after a finger pan (2026-09-06). Reset to zero whenever the widget
/// is really redrawn, since `draw_inner` zeroes the slot then too.
pub move_applied: Vec2,
/// The offset the last `Painter::reposition` placed this widget's
/// content at *within* `region`, in window pixels. The move slot has
/// exactly one owner and one meaning:
/// `move_offsets[move_slot] == move_applied + repositioned`. `mov`
/// adds to the first, `reposition` overwrites the second (it
/// recomputes `from` afresh every call, so repeating it must land on
/// the same answer rather than drifting), and both then rewrite the
/// slot from the sum -- which is what lets a parent both move a child
/// with its own layout and place it inside that moved region in one
/// frame. `List::place`'s Bottom-known branch does exactly that once a
/// row's blocks wrap. Reset to zero on a real redraw, with
/// `move_applied` and the slot itself.
pub repositioned: Vec2,
} }
+40
View File
@@ -24,6 +24,46 @@ pub struct UiData {
/// id (never reallocated), so a retained descendant's `parent` index /// id (never reallocated), so a retained descendant's `parent` index
/// never goes stale -- see LAYOUT.md section 2. /// never goes stale -- see LAYOUT.md section 2.
pub move_offsets: TrackedArena<MoveOffset, u32>, pub move_offsets: TrackedArena<MoveOffset, u32>,
/// Every widget whose [`crate::Widget::tick`] should run before the
/// next frame -- today, a `List` coasting through a fling. Added by
/// [`Self::animate`] when the animation starts and removed by
/// [`Self::tick_animations`] the frame its `tick` answers `false`, so
/// a stopped animation costs nothing and a dropped widget cannot be
/// ticked (`get_dyn_mut` answers `None` and it is dropped the same
/// way).
animating: Vec<WidgetId>,
}
impl UiData {
/// Ask for `id`'s [`crate::Widget::tick`] to run every frame until it
/// says it is done. Idempotent -- registering an already-animating
/// widget is the ordinary case (a second fling before the first
/// settled) and must not tick it twice per frame.
pub fn animate(&mut self, id: WidgetId) {
if !self.animating.contains(&id) {
self.animating.push(id);
}
}
/// Tick every registered widget to `now`, drop the ones that finished,
/// and say whether any is still going -- which is a backend's cue to
/// ask for another frame. Called once per frame *before* the draw, so
/// what the frame draws is this instant's position rather than the
/// previous one's.
pub fn tick_animations(&mut self, now: std::time::Instant) -> bool {
// Taken out and put back rather than iterated in place: `tick`
// needs `&mut` on the widget arena this list lives beside, and a
// widget is free to register another one while ticking.
let mut registered = std::mem::take(&mut self.animating);
registered.retain(|&id| match self.widgets.get_dyn_mut(id) {
Some(widget) => widget.tick(now),
None => false,
});
for id in registered {
self.animate(id);
}
!self.animating.is_empty()
}
} }
pub trait UiRsc { pub trait UiRsc {
+50 -2
View File
@@ -13,6 +13,10 @@ pub struct Painter<'a> {
pub(super) region: UiRegion, pub(super) region: UiRegion,
pub(super) mask: MaskIdx, pub(super) mask: MaskIdx,
pub(super) move_slot: MoveIdx, pub(super) move_slot: MoveIdx,
/// This widget's own mask slot, reused across redraws -- see
/// `ActiveData::own_mask`. `MaskIdx::NONE` until `set_mask` is called
/// for the first time in this widget's life.
pub(super) own_mask: MaskIdx,
pub(super) textures: Vec<TextureHandle>, pub(super) textures: Vec<TextureHandle>,
pub(super) primitives: Vec<PrimitiveHandle>, pub(super) primitives: Vec<PrimitiveHandle>,
pub(super) children: Vec<WidgetId>, pub(super) children: Vec<WidgetId>,
@@ -48,12 +52,32 @@ impl<'a> Painter<'a> {
self.primitive_at(primitive, region.within(&self.region)); self.primitive_at(primitive, region.within(&self.region));
} }
/// Clip everything this widget draws, itself and its descendants, to
/// `region`. One per widget: a second call would need the two to be
/// intersected, which nothing here does.
///
/// The slot is allocated once and **rewritten in place** on every
/// later draw rather than pushed again, because a descendant whose own
/// region did not change is not redrawn (`draw_inner`'s fast path) and
/// so keeps pointing at whichever slot it was drawn under. See
/// `ActiveData::own_mask` for what pushing a fresh one cost.
pub fn set_mask(&mut self, region: UiRegion) { pub fn set_mask(&mut self, region: UiRegion) {
assert!(self.mask == MaskIdx::NONE); assert!(self.mask == MaskIdx::NONE);
self.mask = self.rsc.ui_mut().masks.push(Mask { let mask = Mask {
region, region,
move_idx: self.move_slot, move_idx: self.move_slot,
}); };
if self.own_mask == MaskIdx::NONE {
let slot = self.rsc.ui_mut().masks.push(mask);
// The one ref this widget holds on its own slot, so the slot
// outlives any single frame's primitives; released in
// `UiRenderState::remove`'s `undraw` branch.
self.rsc.ui_mut().masks.push_ref(slot);
self.own_mask = slot;
} else {
*self.rsc.ui_mut().masks.get_mut(self.own_mask) = mask;
}
self.mask = self.own_mask;
} }
/// Draws a widget within this widget's region, returning the size it /// Draws a widget within this widget's region, returning the size it
@@ -86,6 +110,7 @@ impl<'a> Painter<'a> {
self.mask, self.mask,
None, None,
None, None,
crate::render::MaskIdx::NONE,
self.rsc, self.rsc,
); );
self.state self.state
@@ -166,17 +191,40 @@ impl<'a> Painter<'a> {
width: Option<f32>, width: Option<f32>,
) -> RenderedText { ) -> RenderedText {
let density = self.state.density; let density = self.state.density;
// Counted here rather than in `TextView::render`, which returns
// its memoized layout without reaching this -- so this counts
// shapes, not requests. `UiRenderState::take_counters`.
self.state.shape_count += 1;
let ui = self.rsc.ui_mut(); let ui = self.rsc.ui_mut();
ui.text ui.text
.render(buffer, attrs, width, &mut ui.textures, density) .render(buffer, attrs, width, &mut ui.textures, density)
} }
/// Which glyph atlas the glyphs handed out right now belong to --
/// what a widget caching a [`RenderedText`] across frames has to
/// compare against before re-emitting it (`GlyphAtlas::clear`).
pub fn atlas_generation(&mut self) -> u64 {
self.rsc.ui_mut().text.atlas.generation()
}
/// Draw a laid-out string: one quad per glyph, all sampling the atlas. /// Draw a laid-out string: one quad per glyph, all sampling the atlas.
/// ///
/// `origin` is where the text's top-left goes; every glyph is placed at an /// `origin` is where the text's top-left goes; every glyph is placed at an
/// absolute pixel offset from it, so re-drawing after a resize is this loop /// absolute pixel offset from it, so re-drawing after a resize is this loop
/// and nothing else. /// and nothing else.
pub fn glyphs(&mut self, text: &RenderedText, origin: UiRegion) { pub fn glyphs(&mut self, text: &RenderedText, origin: UiRegion) {
// A caller re-emitting quads placed against an atlas that has since
// been cleared draws every glyph from coordinates now holding
// something else. Caught at the submission rather than on screen,
// where it reads as fragments of unrelated letters.
debug_assert_eq!(
text.generation,
self.atlas_generation(),
"glyphs placed against atlas generation {} submitted against {}: the holder did not \
re-render after the atlas was cleared",
text.generation,
self.atlas_generation(),
);
let flags_for = |is_color| { let flags_for = |is_color| {
if is_color { if is_color {
GlyphPrimitive::IS_COLOR GlyphPrimitive::IS_COLOR
+262 -21
View File
@@ -1,7 +1,7 @@
use crate::{ use crate::{
ActiveData, IdLike, MaskIdx, MoveIdx, Painter, PixelRegion, PrimitiveLayers, RegionAlign, ActiveData, IdLike, MaskIdx, MoveIdx, Painter, PixelRegion, PrimitiveLayers, RegionAlign,
StrongWidget, UiRegion, UiRsc, UiVec2, WidgetId, Widgets, StrongWidget, UiRegion, UiRsc, UiVec2, WidgetId, Widgets,
render::MoveOffset, render::{IMAGE_BINDING, MoveOffset},
util::{HashMap, HashSet, Id, Vec2}, util::{HashMap, HashSet, Id, Vec2},
}; };
@@ -18,6 +18,18 @@ pub struct UiRenderState {
old_root: Option<WidgetId>, old_root: Option<WidgetId>,
resized: bool, resized: bool,
/// The widgets whose `Widget::draw` is on the stack right now -- so
/// [`Self::redraw`] can tell "this widget needs drawing again" from
/// "an ancestor is drawing it at this very moment", where a second
/// draw would leave the first one's primitives behind with nothing
/// owning them. An id is inserted immediately before `draw` is called
/// and removed the moment it returns (both in `draw_inner`), so this
/// is empty between frames -- asserted at the end of `update`.
///
/// It used to only ever be inserted into, and `redraw` removed the id
/// *before* testing for it, which made the test constant `false`: the
/// guard could never fire and the set grew by one entry per widget
/// ever drawn and was never emptied.
draw_started: HashSet<WidgetId>, draw_started: HashSet<WidgetId>,
/// The widget currently holding exclusive pointer input, if any -- /// The widget currently holding exclusive pointer input, if any --
@@ -43,12 +55,22 @@ pub struct UiRenderState {
draw_count: u64, draw_count: u64,
region_mut_count: u64, region_mut_count: u64,
mov_count: u64, mov_count: u64,
/// Text layouts actually computed -- bumped by `Painter::render_text`,
/// which `TextView::render` only reaches on a cache miss.
pub(super) shape_count: u64,
} }
/// A move chain more than this deep would mean something else is wrong /// The bound on the parent walk -- see `resolve_move` in shader.wgsl,
/// (an accidental cycle) -- see `resolve_move` in shader.wgsl, which walks /// which walks the identical chain and must be kept in step with this
/// the identical bound and must be kept in step with this constant. /// constant. It exists so a cyclic `parent` link cannot hang either walk,
pub const MOVE_CHAIN_LIMIT: usize = 16; /// not as a statement about how deep a real tree gets: it was 16, and the
/// transcript screen's composer field turned out to sit **17** slots below
/// the root (measured 2026-09-07 on this checkout's emulator, by tapping
/// the composer in a debug build -- the assert in `resolve_move_chain`
/// prints the chain). A chain past the bound is not reported anywhere at
/// run time; both walks just stop summing, so the widget is drawn and hit
/// tested short by whatever the outer slots held.
pub const MOVE_CHAIN_LIMIT: usize = 64;
impl UiRenderState { impl UiRenderState {
pub fn new() -> Self { pub fn new() -> Self {
@@ -64,17 +86,25 @@ impl UiRenderState {
draw_count: 0, draw_count: 0,
region_mut_count: 0, region_mut_count: 0,
mov_count: 0, mov_count: 0,
shape_count: 0,
} }
} }
/// Reads and zeroes the (draws, region_mut rewrites, move_offsets /// Reads and zeroes the (draws, region_mut rewrites, move_offsets
/// writes) counters -- call once per frame before `update()` to /// writes, text shapes) counters -- call once per frame before
/// measure exactly that frame, per LAYOUT.md section 8. /// `update()` to measure exactly that frame, per LAYOUT.md section 8.
pub fn take_counters(&mut self) -> (u64, u64, u64) { ///
/// The fourth is the one a draw count cannot stand in for: a widget
/// can be redrawn without re-shaping (`TextView::render` memoizes by
/// width) and re-shaped without any extra draw, and it is re-shaping
/// that the per-block transcript row exists to avoid -- see
/// `transcript_ui`'s `a_delta_into_a_long_reply_shapes_one_block`.
pub fn take_counters(&mut self) -> (u64, u64, u64, u64) {
( (
std::mem::take(&mut self.draw_count), std::mem::take(&mut self.draw_count),
std::mem::take(&mut self.region_mut_count), std::mem::take(&mut self.region_mut_count),
std::mem::take(&mut self.mov_count), std::mem::take(&mut self.mov_count),
std::mem::take(&mut self.shape_count),
) )
} }
@@ -115,6 +145,11 @@ impl UiRenderState {
); );
} }
let root = root.into(); let root = root.into();
debug_assert!(
self.draw_started.is_empty(),
"a previous frame left {} widget(s) marked as mid-draw",
self.draw_started.len(),
);
if self.needs_redraw_all(root) { if self.needs_redraw_all(root) {
self.redraw_all(root, rsc); self.redraw_all(root, rsc);
self.old_root = root.map(|r| r.id()); self.old_root = root.map(|r| r.id());
@@ -122,6 +157,8 @@ impl UiRenderState {
} else if rsc.widgets().has_updates() { } else if rsc.widgets().has_updates() {
self.redraw_updates(rsc); self.redraw_updates(rsc);
} }
#[cfg(debug_assertions)]
debug_assert!(self.primitive_counts_agree(), "{}", self.orphan_report(rsc),);
} }
fn redraw_all(&mut self, root: Option<&StrongWidget>, rsc: &mut dyn UiRsc) { fn redraw_all(&mut self, root: Option<&StrongWidget>, rsc: &mut dyn UiRsc) {
@@ -137,6 +174,7 @@ impl UiRenderState {
MaskIdx::NONE, MaskIdx::NONE,
None, None,
None, None,
MaskIdx::NONE,
rsc, rsc,
); );
} }
@@ -171,12 +209,27 @@ impl UiRenderState {
mask: MaskIdx, mask: MaskIdx,
old_children: Option<Vec<WidgetId>>, old_children: Option<Vec<WidgetId>>,
old_move_slot: Option<MoveIdx>, old_move_slot: Option<MoveIdx>,
old_own_mask: MaskIdx,
rsc: &mut dyn UiRsc, rsc: &mut dyn UiRsc,
) { ) {
let mut old_children = old_children.unwrap_or_default(); let mut old_children = old_children.unwrap_or_default();
let mut old_move_slot = old_move_slot; let mut old_move_slot = old_move_slot;
let mut own_mask = old_own_mask;
// Consumed here, not merely read: this call *is* the redraw the mark
// asked for, and leaving the mark set is what stranded a widget's
// primitives. `Painter::draw_twice` calls this twice for the same id
// in one frame (`List::place`'s measurement pass), and on the second
// call the still-set mark took the whole `if let` below -- including
// the `remove` that frees the first draw's primitives -- out of play,
// so `active.insert` at the end overwrote the only handles that could
// ever have freed them. The result is a full second copy of the row,
// drawn every frame from then on at the oversized measurement region
// and, with `List` setting no mask, outside the list's own bounds:
// the doubled `Compacted:` row in docs/bench/iris-phone-v2-2026-09-06.md.
// The same shape reaches any dirty widget an ancestor redraws first.
let dirty = rsc.widgets_mut().needs_redraw.remove(&id);
if let Some(active) = self.active.get_mut(&id) if let Some(active) = self.active.get_mut(&id)
&& !rsc.widgets().needs_redraw.contains(&id) && !dirty
{ {
// check to see if we can skip drawing first // check to see if we can skip drawing first
if active.region == region { if active.region == region {
@@ -203,6 +256,15 @@ impl UiRenderState {
*r = r.outside(&from).within(&region); *r = r.outside(&from).within(&region);
self.region_mut_count += 1; self.region_mut_count += 1;
} }
// `move_applied` is deliberately **not** touched here,
// unlike in `mov`: it counts the part of this widget's own
// move-slot delta that `region` has already absorbed, and
// this branch writes no delta at all -- the primitives were
// moved directly. Counting one would make
// `resolved_region` subtract a distance the chain never
// held, putting the hit box short of the drawing by
// exactly this step. See `ActiveData::move_applied`, and
// `a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at`.
active.region = region; active.region = region;
return; return;
} }
@@ -210,10 +272,25 @@ impl UiRenderState {
let active = self.remove(id, false, rsc).unwrap(); let active = self.remove(id, false, rsc).unwrap();
old_children = active.children; old_children = active.children;
old_move_slot = Some(active.move_slot); old_move_slot = Some(active.move_slot);
own_mask = active.own_mask;
} else if dirty && self.active.contains_key(&id) {
// Dirty and already drawn: none of the fast paths above may be
// taken (the widget's own content changed, so its old primitives
// say nothing about its new ones), but they are also the only
// thing that frees them. Same two lines, reached the other way.
let active = self.remove(id, false, rsc).unwrap();
old_children = active.children;
old_move_slot = Some(active.move_slot);
own_mask = active.own_mask;
} }
// draw widget // draw widget
self.draw_started.insert(id); let reentrant = !self.draw_started.insert(id);
debug_assert!(
!reentrant,
"widget {id:?} is being drawn while its own draw is already on the stack; \
the second draw's primitives would orphan the first's"
);
let move_slot = match old_move_slot { let move_slot = match old_move_slot {
// Reused across a real redraw of the same id: the fresh // Reused across a real redraw of the same id: the fresh
@@ -242,11 +319,22 @@ impl UiRenderState {
} }
}; };
// The mask this widget was drawn *under*, kept aside because
// `Painter::set_mask` overwrites `painter.mask` with the widget's
// own new one -- and `ActiveData::mask`'s only consumer is
// `redraw`, which feeds it back in as the *inherited* mask. Storing
// the set one instead handed a `Masked` its own mask on every
// targeted redraw, tripping `set_mask`'s nested-mask assert:
// `assertion failed: self.mask == MaskIdx::NONE`, an abort the
// first time the composer's scroll area was redrawn on the
// emulator.
let inherited_mask = mask;
let mut painter = Painter { let mut painter = Painter {
state: self, state: self,
region, region,
mask, mask,
move_slot, move_slot,
own_mask,
layer, layer,
id, id,
textures: Vec::new(), textures: Vec::new(),
@@ -258,14 +346,26 @@ impl UiRenderState {
let mut widget = painter.rsc.widgets().get_dyn_dynamic(id); let mut widget = painter.rsc.widgets().get_dyn_dynamic(id);
painter.state.draw_count += 1; painter.state.draw_count += 1;
let size = widget.draw(&mut painter); let size = widget.draw(&mut painter);
// A reported length is consumed by containers that read `abs`,
// `rel` and `rest` straight off it (`Span`'s placement, `Pad`'s
// addition), so an unresolved `dp` in one is silently worth zero
// -- see `Len::fold_dp`, which is what a widget reporting a
// caller-declared size has to put it through.
debug_assert!(
size.x.dp == 0.0 && size.y.dp == 0.0,
"widget {id:?} reported an unresolved `dp` size ({size:?}); \
report `Len::fold_dp(painter.density())` instead"
);
drop(widget); drop(widget);
painter.state.draw_started.remove(&id);
let Painter { let Painter {
state: _, state: _,
rsc: _, rsc: _,
region, region,
mask, mask: _,
move_slot, move_slot,
own_mask,
textures, textures,
primitives, primitives,
children, children,
@@ -281,10 +381,13 @@ impl UiRenderState {
textures, textures,
primitives, primitives,
children, children,
mask, mask: inherited_mask,
layer, layer,
size, size,
move_slot, move_slot,
own_mask,
move_applied: Vec2::ZERO,
repositioned: Vec2::ZERO,
}; };
// remove old children that weren't kept // remove old children that weren't kept
@@ -312,6 +415,7 @@ impl UiRenderState {
let from_px = from.top_left().to_abs(self.output_size); let from_px = from.top_left().to_abs(self.output_size);
let to_px = to.top_left().to_abs(self.output_size); let to_px = to.top_left().to_abs(self.output_size);
let delta = to_px - from_px; let delta = to_px - from_px;
active.move_applied += delta;
let entry = rsc.ui_mut().move_offsets.get_mut(slot); let entry = rsc.ui_mut().move_offsets.get_mut(slot);
entry.delta[0] += delta.x; entry.delta[0] += delta.x;
entry.delta[1] += delta.y; entry.delta[1] += delta.y;
@@ -346,6 +450,8 @@ impl UiRenderState {
let Some(active) = self.active.get(&id) else { let Some(active) = self.active.get(&id) else {
return; return;
}; };
let move_applied = active.move_applied;
let repositioned = active.repositioned;
let from = active let from = active
.size .size
.to_uivec2(self.density) .to_uivec2(self.density)
@@ -355,8 +461,27 @@ impl UiRenderState {
let from_px = from.top_left().to_abs(self.output_size); let from_px = from.top_left().to_abs(self.output_size);
let to_px = to.top_left().to_abs(self.output_size); let to_px = to.top_left().to_abs(self.output_size);
let delta = to_px - from_px; let delta = to_px - from_px;
// Not `delta` alone: a parent may have `mov`ed this widget to a
// region that itself moved earlier in the same frame, and that
// part of the slot is `move_applied`'s, not this call's. Writing
// `delta` on its own dropped it and put the content back at the
// pre-move position. `from` is computed against `active.region`,
// which `mov` already updated, so `delta` is purely the placement
// inside the region and the two summands never overlap.
let entry = rsc.ui_mut().move_offsets.get_mut(slot); let entry = rsc.ui_mut().move_offsets.get_mut(slot);
entry.delta = [delta.x, delta.y]; debug_assert_eq!(
entry.delta,
[
move_applied.x + repositioned.x,
move_applied.y + repositioned.y
],
"widget {id:?}'s move slot was written by something other than `mov`/`reposition`; \
the slot is theirs and means `move_applied + repositioned` -- see `ActiveData`"
);
entry.delta = [move_applied.x + delta.x, move_applied.y + delta.y];
if let Some(active) = self.active.get_mut(&id) {
active.repositioned = delta;
}
self.mov_count += 1; self.mov_count += 1;
} }
@@ -387,6 +512,11 @@ impl UiRenderState {
// the parent's own `ActiveData` may already be gone by the // the parent's own `ActiveData` may already be gone by the
// time a deep descendant is retired (see LAYOUT.md // time a deep descendant is retired (see LAYOUT.md
// section 2's lifecycle note). // section 2's lifecycle note).
if active.own_mask != MaskIdx::NONE {
// The self-ownership ref `Painter::set_mask` took when
// it allocated this widget's own mask slot.
rsc.ui_mut().masks.remove(active.own_mask);
}
let parent_slot = rsc.ui_mut().move_offsets[active.move_slot.idx()].parent; let parent_slot = rsc.ui_mut().move_offsets[active.move_slot.idx()].parent;
rsc.ui_mut().move_offsets.remove(active.move_slot); rsc.ui_mut().move_offsets.remove(active.move_slot);
if parent_slot != MoveOffset::NONE_PARENT { if parent_slot != MoveOffset::NONE_PARENT {
@@ -452,6 +582,79 @@ impl UiRenderState {
self.active.len() self.active.len()
} }
/// Primitive instances still bound for the GPU whose owner is no
/// longer in `active`, or whose owner's `ActiveData` no longer names
/// them: a copy nothing can move, clip, resize or free, redrawn every
/// frame at whatever position it last had. `(layer, inst_idx, owner)`
/// each.
///
/// Asserted empty at the end of every [`Self::update`], because this
/// is exactly the shape of the duplicated transcript row on Iris's
/// phone (`docs/bench/iris-phone-v2-2026-09-06.md`): counting
/// `active` alone cannot see it, since the orphan's owner is very
/// much alive -- it is the *earlier* set of primitives that got
/// stranded when the widget was drawn a second time without the first
/// draw being freed. O(primitives), debug builds only.
pub fn orphaned_primitives(&self) -> Vec<(usize, usize, WidgetId)> {
let mut orphans = Vec::new();
for (layer, primitives) in self.layers.iter() {
for (inst_idx, owner, is_image) in primitives.live_instances() {
let owned = self.active.get(&owner).is_some_and(|a| {
a.primitives.iter().any(|h| {
h.layer == layer
&& h.inst_idx == inst_idx
&& (h.binding == IMAGE_BINDING) == is_image
})
});
if !owned {
orphans.push((layer, inst_idx, owner));
}
}
}
orphans
}
/// Whether every primitive still bound for the GPU is owned by a live
/// widget, decided by counting rather than by walking: an orphan is a
/// live instance no `ActiveData` names, so it can only ever make the
/// live count exceed the owned one. O(active widgets) -- a few dozen --
/// against [`Self::orphaned_primitives`]'s O(primitives), which on a
/// transcript is tens of thousands and made a debug build on a phone
/// too slow to finish a benchmark run.
fn primitive_counts_agree(&self) -> bool {
let live: usize = self.layers.iter().map(|(_, p)| p.live_count()).sum();
let owned: usize = self.active.values().map(|a| a.primitives.len()).sum();
live == owned
}
/// The message [`Self::update`]'s orphan assert prints -- built here
/// rather than inline so the (allocating, O(primitives)) work only
/// happens on the failing path.
#[cfg(debug_assertions)]
fn orphan_report(&self, rsc: &dyn UiRsc) -> String {
let orphans = self.orphaned_primitives();
let mut lines: Vec<String> = orphans
.iter()
.take(8)
.map(|(layer, idx, owner)| {
let alive = self.active.contains_key(owner);
format!(
" layer {layer} instance {idx}: owner '{}' ({owner:?}), owner still active: {alive}",
rsc.widgets().label(*owner),
)
})
.collect();
if orphans.len() > lines.len() {
lines.push(format!(" ... and {} more", orphans.len() - lines.len()));
}
format!(
"{} primitive(s) are drawn but owned by nobody -- a stale copy \
nothing will ever move or free:\n{}",
orphans.len(),
lines.join("\n"),
)
}
/// Give `id` exclusive pointer input from the next `run_sensors` call /// Give `id` exclusive pointer input from the next `run_sensors` call
/// on -- see `captured`'s field doc. Overwrites any previous capture /// on -- see `captured`'s field doc. Overwrites any previous capture
/// (a gesture that starts a new one has already decided the old one /// (a gesture that starts a new one has already decided the old one
@@ -499,7 +702,12 @@ impl UiRenderState {
/// section 2b. /// section 2b.
pub fn resolved_region(&self, id: &impl IdLike, rsc: &dyn UiRsc) -> Option<UiRegion> { pub fn resolved_region(&self, id: &impl IdLike, rsc: &dyn UiRsc) -> Option<UiRegion> {
let active = self.active.get(&id.id())?; let active = self.active.get(&id.id())?;
let delta = self.resolve_move_chain(active.move_slot, rsc); // The chain sum is what the shader adds to this widget's
// *primitives*, which were written before any of those moves.
// `region`, unlike them, has already been shifted by whatever
// part of this widget's own slot `mov` put there -- see
// `ActiveData::move_applied`, which is exactly that part.
let delta = self.resolve_move_chain(active.move_slot, rsc) - active.move_applied;
Some(active.region.offset(UiVec2::abs(delta))) Some(active.region.offset(UiVec2::abs(delta)))
} }
@@ -507,26 +715,56 @@ impl UiRenderState {
/// pixel delta along the parent chain starting at `slot`. Both walks /// pixel delta along the parent chain starting at `slot`. Both walks
/// share `MOVE_CHAIN_LIMIT` as their bound so the two cannot disagree /// share `MOVE_CHAIN_LIMIT` as their bound so the two cannot disagree
/// about where the chain ends. /// about where the chain ends.
fn resolve_move_chain(&self, mut slot: MoveIdx, rsc: &dyn UiRsc) -> Vec2 { fn resolve_move_chain(&self, slot: MoveIdx, rsc: &dyn UiRsc) -> Vec2 {
let offsets = &rsc.ui().move_offsets; let offsets = &rsc.ui().move_offsets;
let mut delta = Vec2::ZERO; let mut delta = Vec2::ZERO;
let mut at = slot;
for i in 0..MOVE_CHAIN_LIMIT { for i in 0..MOVE_CHAIN_LIMIT {
let entry = &offsets[slot.idx()]; let entry = &offsets[at.idx()];
delta.x += entry.delta[0]; delta.x += entry.delta[0];
delta.y += entry.delta[1]; delta.y += entry.delta[1];
if entry.parent == MoveOffset::NONE_PARENT { if entry.parent == MoveOffset::NONE_PARENT {
return delta; return delta;
} }
slot = Id::preset(entry.parent); at = Id::preset(entry.parent);
// The chain itself, not just the fact that it was too long: a
// cycle and a tree genuinely nested deeper than the shader can
// follow are different faults with different fixes, and the
// slot numbers are the only thing that tells them apart.
debug_assert!( debug_assert!(
i + 1 < MOVE_CHAIN_LIMIT, i + 1 < MOVE_CHAIN_LIMIT,
"move offset chain exceeded MOVE_CHAIN_LIMIT; a widget's `parent` link is \ "move offset chain exceeded MOVE_CHAIN_LIMIT ({MOVE_CHAIN_LIMIT}): {chain} -- a \
probably cyclic" repeated slot means a `parent` link is cyclic, all-distinct slots mean the tree \
nests deeper than shader.wgsl's own walk of the same bound",
chain = Self::move_chain_debug(slot, offsets)
); );
} }
delta delta
} }
/// The parent chain from `slot`, as `slot(dx, dy) -> ...`, walked twice
/// `MOVE_CHAIN_LIMIT` so a cycle shows up as a repeated slot number
/// rather than as a chain that merely stops. Only ever called from the
/// failed assertion above.
fn move_chain_debug(slot: MoveIdx, offsets: &[MoveOffset]) -> String {
let mut parts = Vec::new();
let mut at = slot;
for _ in 0..MOVE_CHAIN_LIMIT * 2 {
let entry = &offsets[at.idx()];
parts.push(format!(
"{}({}, {})",
at.idx(),
entry.delta[0],
entry.delta[1]
));
if entry.parent == MoveOffset::NONE_PARENT {
break;
}
at = Id::preset(entry.parent);
}
parts.join(" -> ")
}
pub fn window_region(&self, id: &impl IdLike, rsc: &dyn UiRsc) -> Option<PixelRegion> { pub fn window_region(&self, id: &impl IdLike, rsc: &dyn UiRsc) -> Option<PixelRegion> {
let region = self.resolved_region(id, rsc)?; let region = self.resolved_region(id, rsc)?;
Some(region.to_px(self.output_size)) Some(region.to_px(self.output_size))
@@ -535,7 +773,10 @@ impl UiRenderState {
/// redraws a widget that's currently active (drawn) /// redraws a widget that's currently active (drawn)
pub fn redraw(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) { pub fn redraw(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) {
rsc.widgets_mut().needs_redraw.remove(&id); rsc.widgets_mut().needs_redraw.remove(&id);
self.draw_started.remove(&id); // An ancestor is drawing this widget right now, and that draw is
// about to write fresh primitives for it. Drawing it a second time
// here would leave one of the two copies on screen with nothing
// owning it -- see `draw_started`'s own doc.
if self.draw_started.contains(&id) { if self.draw_started.contains(&id) {
return; return;
} }
@@ -559,9 +800,9 @@ impl UiRenderState {
active.mask, active.mask,
Some(active.children), Some(active.children),
Some(active.move_slot), Some(active.move_slot),
active.own_mask,
rsc, rsc,
); );
// If this widget's own reported size changed, its parent's layout // If this widget's own reported size changed, its parent's layout
// (which placed it using the old size) is now stale and needs to // (which placed it using the old size) is now stale and needs to
// relay out too. Checked after the real draw, not before it -- // relay out too. Checked after the real draw, not before it --
+19
View File
@@ -41,6 +41,25 @@ pub trait Widget: Any {
fn access_role(&self) -> accesskit::Role { fn access_role(&self) -> accesskit::Role {
accesskit::Role::Unknown accesskit::Role::Unknown
} }
/// Advance whatever this widget is animating to `now`, and say whether
/// it is still animating afterwards. Default: nothing is, so a widget
/// opts in by overriding this *and* by something calling
/// [`crate::UiData::animate`] with its id when the animation starts --
/// which is that animation's path out, since the driver
/// ([`crate::UiData::tick_animations`]) drops every id whose `tick`
/// answers `false`.
///
/// Called once per frame, before the frame's draw, by whichever
/// backend owns the surface; a `true` answer is what makes that
/// backend ask for another frame. So this is the only thing in iris
/// that moves without an input event, and a widget that animates
/// without registering simply never moves -- which is exactly how a
/// finger fling looked on Iris's phone before this existed.
#[allow(unused_variables)]
fn tick(&mut self, now: std::time::Instant) -> bool {
false
}
} }
impl Widget for () { impl Widget for () {
+32
View File
@@ -0,0 +1,32 @@
[package]
name = "rig-input"
version.workspace = true
edition.workspace = true
# Layer 2's input half (docs/RUST.md's "Three test layers"): replays one
# of the `.touch` files the headless tests use into whatever window is
# under a Wayland compositor, so the *same recording* drives the
# assertion layer and the layer a person looks at.
#
# It exists because this machine's compositor has no pointer to move.
# `run-headless.sh` starts sway on the headless backend with no input
# devices at all (`WLR_LIBINPUT_NO_DEVICES=1`, `LIBSEAT_BACKEND=noop`),
# so `swaymsg seat - cursor press` reports success and nothing reaches
# the client -- `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 that is not reading libinput. The
# virtual-pointer protocol is what is left, and it is a client protocol,
# so it needs no devices and no root.
# Named for what it does rather than for the crate, since the crate may
# grow a keyboard replay beside it.
[[bin]]
name = "replay-touch"
path = "src/main.rs"
[dependencies]
# `TouchScript` -- the same parser the harness uses, so a file that
# replays here and one that replays headless can never disagree.
iris = { path = ".." }
wayland-client = "0.31.15"
wayland-protocols-wlr = { version = "0.3.12", features = ["client"] }
+164
View File
@@ -0,0 +1,164 @@
//! Replays a `.touch` file into the compositor as a left-button drag --
//! see this crate's `Cargo.toml` for why it exists rather than
//! `swaymsg seat - cursor`.
//!
//! WAYLAND_DISPLAY=… replay-touch WIDTH HEIGHT FILE
//!
//! `WIDTH`/`HEIGHT` are the output's own size, because the virtual
//! pointer protocol positions absolutely against an extent rather than
//! in pixels; passing the output size makes a script's coordinates mean
//! the same pixels they mean in the headless tests.
//!
//! Replayed in real time (the sleeps between samples are the gaps in the
//! file), because winit has no timestamp on a pointer event and dates
//! each one when it arrives -- so a 20ms flick has to actually take
//! 20ms here, unlike layer 1 where the sample carries its own time.
use iris::harness::{TouchAction, TouchScript};
use std::time::Duration;
use wayland_client::protocol::wl_pointer::ButtonState;
use wayland_client::protocol::{wl_registry, wl_seat};
use wayland_client::{Connection, Dispatch, QueueHandle, delegate_noop};
use wayland_protocols_wlr::virtual_pointer::v1::client::{
zwlr_virtual_pointer_manager_v1::ZwlrVirtualPointerManagerV1,
zwlr_virtual_pointer_v1::ZwlrVirtualPointerV1,
};
/// `linux/input-event-codes.h`. The protocol takes the kernel's own
/// button code, not a wayland enum.
const BTN_LEFT: u32 = 0x110;
/// How long the pointer sits at the gesture's first position before the
/// script starts -- see the comment at the pre-step in `main`.
const SETTLE: Duration = Duration::from_millis(200);
#[derive(Default)]
struct Globals {
seat: Option<wl_seat::WlSeat>,
manager: Option<ZwlrVirtualPointerManagerV1>,
}
impl Dispatch<wl_registry::WlRegistry, ()> for Globals {
fn event(
state: &mut Self,
registry: &wl_registry::WlRegistry,
event: wl_registry::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
let wl_registry::Event::Global {
name,
interface,
version,
} = event
else {
return;
};
match interface.as_str() {
"wl_seat" => {
state.seat = Some(registry.bind(name, version.min(7), qh, ()));
}
"zwlr_virtual_pointer_manager_v1" => {
state.manager = Some(registry.bind(name, version.min(2), qh, ()));
}
_ => {}
}
}
}
delegate_noop!(Globals: ignore wl_seat::WlSeat);
delegate_noop!(Globals: ZwlrVirtualPointerManagerV1);
delegate_noop!(Globals: ZwlrVirtualPointerV1);
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let [width, height, path] = args.as_slice() else {
eprintln!("usage: replay-touch WIDTH HEIGHT FILE");
std::process::exit(2);
};
let (width, height) = (parse(width, "WIDTH"), parse(height, "HEIGHT"));
let text = std::fs::read_to_string(path)
.unwrap_or_else(|e| fail(&format!("could not read {path}: {e}")));
let script = TouchScript::parse(&text).unwrap_or_else(|e| fail(&e));
let conn = Connection::connect_to_env().unwrap_or_else(|e| {
fail(&format!(
"no wayland display ({e}); is WAYLAND_DISPLAY set?"
))
});
let mut queue = conn.new_event_queue();
let qh = queue.handle();
let display = conn.display();
display.get_registry(&qh, ());
let mut globals = Globals::default();
queue
.roundtrip(&mut globals)
.unwrap_or_else(|e| fail(&format!("wayland roundtrip failed: {e}")));
let manager = globals.manager.as_ref().unwrap_or_else(|| {
fail(
"this compositor does not offer zwlr_virtual_pointer_manager_v1, so a pointer cannot \
be synthesised; sway and every wlroots compositor do",
)
});
let pointer = manager.create_virtual_pointer(globals.seat.as_ref(), &qh, ());
// Put the pointer where the gesture starts and let the compositor
// settle before anything is pressed. Without this the press is
// dropped: sway has just learned about this pointer, and a button
// sent in the same breath as the motion that first puts it over a
// window arrives before there is a focused surface to send it to --
// winit sees `CursorEntered`, the moves and the *release*, never the
// press, so the gesture reads as a hover and nothing scrolls. Found
// by printing winit's own events; the settle is what fixed it.
if let Some(first) = script.samples.first() {
pointer.motion_absolute(0, first.pos.x as u32, first.pos.y as u32, width, height);
pointer.frame();
conn.flush()
.unwrap_or_else(|e| fail(&format!("flush: {e}")));
std::thread::sleep(SETTLE);
}
let mut previous = 0;
for sample in &script.samples {
std::thread::sleep(Duration::from_millis(sample.t_ms - previous));
previous = sample.t_ms;
let t = sample.t_ms as u32;
pointer.motion_absolute(t, sample.pos.x as u32, sample.pos.y as u32, width, height);
// One frame per sample, so the compositor delivers them as
// separate pointer frames rather than coalescing the whole
// gesture -- the shape the file recorded is the point.
pointer.frame();
// The button goes in a frame of its own, *after* the motion has
// been committed. Sent in the same frame as the motion that
// first puts the pointer over the window, sway drops it: the
// client sees `CursorEntered` and the moves but never a
// `MouseInput { state: Pressed }`, so the whole gesture reads as
// a hover and nothing scrolls. Found exactly that way, by
// printing winit's events.
let state = match sample.action {
TouchAction::Down => Some(ButtonState::Pressed),
TouchAction::Up | TouchAction::Cancel => Some(ButtonState::Released),
TouchAction::Move => None,
};
if let Some(state) = state {
pointer.button(t, BTN_LEFT, state);
pointer.frame();
}
conn.flush()
.unwrap_or_else(|e| fail(&format!("flush: {e}")));
}
pointer.destroy();
conn.flush().ok();
}
fn parse(text: &str, what: &str) -> u32 {
text.parse()
.unwrap_or_else(|_| fail(&format!("{what} is not a whole number: {text:?}")))
}
fn fail(message: &str) -> ! {
eprintln!("replay-touch: {message}");
std::process::exit(1);
}
+65 -1
View File
@@ -3,6 +3,25 @@
# #
# ./run-headless.sh tabs [-- cargo args] # ./run-headless.sh tabs [-- cargo args]
# ./run-headless.sh tabs --shot /tmp/tabs.png --seconds 4 # ./run-headless.sh tabs --shot /tmp/tabs.png --seconds 4
# ./run-headless.sh phone --phone --shot /tmp/p.png -- -p transcript-fixture
# ./run-headless.sh phone --phone --replay transcript-fixture/touch/flick-120hz.touch \
# --shot /tmp/p.png -- -p transcript-fixture
#
# `--phone` is layer 2 of docs/RUST.md's "Three test layers": the output
# and the window take Iris's phone's own size and density (1080x2424 at
# `content_scale` 2.55, from docs/bench/iris-phone-v2-2026-09-06.md,
# carried in `transcript_fixture::PHONE_*`), and `IRIS_SCALE` hands that
# density to iris the way `DisplayMetrics.density` does on Android
# (`iris::default::content_scale`). So a screenshot from here and one
# from the phone are the same layout at the same density, and what
# differs is only the renderer. Without it the output stays desktop-
# shaped, which is what every other example wants.
#
# `--replay FILE` drives one of the `.touch` recordings the headless
# tests use (`iris/transcript-fixture/touch/`) into the window through
# `rig-input`'s `replay-touch` -- one recording, both layers. With
# `--shot` it also writes `<shot>-before.png` from just before the
# gesture, since "the list moved" is a claim about two pictures.
# #
# `--bin` runs a real crate binary instead of an example (E4's # `--bin` runs a real crate binary instead of an example (E4's
# `desktop-app`, which is a window a person runs, not a demo) -- # `desktop-app`, which is a window a person runs, not a demo) --
@@ -29,19 +48,31 @@ here=$(cd "$(dirname "$0")" && pwd)
run="${XDG_RUNTIME_DIR:-/tmp}/iris-headless" run="${XDG_RUNTIME_DIR:-/tmp}/iris-headless"
seconds=3 seconds=3
shot="" shot=""
replay=""
example="" example=""
kind=example kind=example
phone=no
# The phone Iris runs the bench on. Not typed from memory: these are
# `transcript_fixture::PHONE_WIDTH`/`PHONE_HEIGHT`/`PHONE_SCALE`, which
# in turn come from her own reports -- keep the three in step.
PHONE_MODE=1080x2424@120Hz
PHONE_SCALE=2.55
DESKTOP_MODE=1920x1200@60Hz
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--shot) shot=$2; shift 2 ;; --shot) shot=$2; shift 2 ;;
--seconds) seconds=$2; shift 2 ;; --seconds) seconds=$2; shift 2 ;;
--bin) kind=bin; shift ;; --bin) kind=bin; shift ;;
--phone) phone=yes; shift ;;
--replay) replay=$2; shift 2 ;;
--) shift; break ;; --) shift; break ;;
*) example=$1; shift ;; *) example=$1; shift ;;
esac esac
done done
[ -n "$example" ] || { echo "usage: $0 NAME [--bin] [--shot PNG] [--seconds N] [-- cargo args]" >&2; exit 2; } [ -n "$example" ] || { echo "usage: $0 NAME [--bin] [--phone] [--replay TOUCH] [--shot PNG] [--seconds N] [-- cargo args]" >&2; exit 2; }
[ -z "$replay" ] || [ -f "$replay" ] || { echo "run-headless: no touch script at $replay" >&2; exit 2; }
mkdir -p "$run" mkdir -p "$run"
export SWAYSOCK="$run/sway.sock" export SWAYSOCK="$run/sway.sock"
@@ -78,6 +109,27 @@ export WAYLAND_DISPLAY
echo "run-headless: $WAYLAND_DISPLAY (sway $(swaymsg -t get_version --raw | sed -n 's/.*"human_readable":"\([^"]*\)".*/\1/p'))" >&2 echo "run-headless: $WAYLAND_DISPLAY (sway $(swaymsg -t get_version --raw | sed -n 's/.*"human_readable":"\([^"]*\)".*/\1/p'))" >&2
# Set every run rather than only when it changes: this compositor is
# reused across runs (see the socket comment above), so a desktop-shaped
# run after a phone-shaped one would otherwise inherit the phone's output
# and silently screenshot the wrong size.
if [ "$phone" = yes ]; then
mode=$PHONE_MODE
export IRIS_SCALE="$PHONE_SCALE"
echo "run-headless: phone-shaped output $PHONE_MODE at IRIS_SCALE=$PHONE_SCALE" >&2
else
mode=$DESKTOP_MODE
fi
swaymsg output HEADLESS-1 mode "$mode" >/dev/null
# The extent `replay-touch` positions against, so a script's coordinates
# are the output's own pixels.
out_w=${mode%x*}
out_h=${mode#*x}; out_h=${out_h%@*}
# Built before the app starts, so a compile error is not reported as a
# window that failed to move.
[ -z "$replay" ] || cargo build --bin replay-touch -p rig-input >&2
cd "$here" cd "$here"
if [ "$kind" = bin ]; then if [ "$kind" = bin ]; then
cargo build --bin "$example" "$@" >&2 cargo build --bin "$example" "$@" >&2
@@ -111,6 +163,18 @@ while [ $i -lt "$((seconds * 2))" ]; do
i=$((i + 1)); sleep 0.5 i=$((i + 1)); sleep 0.5
done done
if [ -n "$replay" ] && kill -0 "$pid" 2>/dev/null; then
if [ -n "$shot" ]; then
grim "${shot%.png}-before.png"
echo "run-headless: wrote ${shot%.png}-before.png (before the gesture)" >&2
fi
"$here/target/debug/replay-touch" "$out_w" "$out_h" "$replay"
# A fling outlives the finger: the gesture's own last sample is not
# when the list stops. Long enough for Android's spline to settle
# (`FlingCalculator::duration` tops out around a second and a half).
sleep 2
fi
if kill -0 "$pid" 2>/dev/null; then if kill -0 "$pid" 2>/dev/null; then
[ -n "$shot" ] && grim "$shot" && echo "run-headless: wrote $shot" >&2 [ -n "$shot" ] && grim "$shot" && echo "run-headless: wrote $shot" >&2
kill "$pid" 2>/dev/null || true kill "$pid" 2>/dev/null || true
+32 -6
View File
@@ -45,16 +45,38 @@ pub struct Insets {
pub top: i32, pub top: i32,
pub right: i32, pub right: i32,
pub bottom: i32, pub bottom: i32,
/// The keyboard's own inset (`WindowInsetsCompat.Type.ime()`), separate /// The keyboard's own inset (`WindowInsets.Type.ime()`), in physical
/// from `bottom` (the system bars): a layout wants to know about the /// pixels, separate from `bottom` (the system bars): a layout wants to
/// keyboard specifically, since it usually means "make room" rather /// know about the keyboard specifically, since it usually means "make
/// than "stay clear of a corner". /// room" rather than "stay clear of a corner".
pub ime_bottom: i32, pub ime_bottom: i32,
/// `WindowInsets.isVisible(ime())` -- whether the keyboard is up, which
/// is **not** the same question as `ime_bottom > 0` and is why the two
/// are carried separately. They disagree for the frames the keyboard
/// spends sliding: visible, with a height still on its way to the full
/// one. Anything asking "make how much room" reads `ime_bottom`;
/// anything asking "is the keyboard up" reads this. See
/// `MainActivity.java`'s comment for the history -- the height used to
/// be sent *as* this boolean, which is what left the composer padded by
/// one pixel on Iris's phone.
pub ime_visible: bool,
} }
#[derive(Default)] #[derive(Default)]
pub struct Shared { pub struct Shared {
pub insets: Insets, pub insets: Insets,
/// How many times Java has called `applyWindowInsetsNative` for this
/// peer, whether or not the numbers changed. Deliberately **not** a
/// field of `Insets`, which is compared for equality each frame to
/// decide whether to re-run `on_insets_changed`; a counter in there
/// would make every dispatch look like a change.
///
/// It exists because "the keyboard does not push anything up" has two
/// completely different causes that look identical on screen -- the
/// listener never fired, or it fired with a zero `ime_bottom` -- and
/// Iris has no logcat on her phone (docs/IRIS_TODO.md). This number is
/// in the `Diagnostics` overlay, so one screenshot separates them.
pub updates: u64,
} }
type SharedMap = HashMap<jlong, SendWrapper<Rc<RefCell<Shared>>>>; type SharedMap = HashMap<jlong, SendWrapper<Rc<RefCell<Shared>>>>;
@@ -89,15 +111,19 @@ extern "system" fn apply_window_insets<'local>(
right: jint, right: jint,
bottom: jint, bottom: jint,
ime_bottom: jint, ime_bottom: jint,
ime_visible: jint,
) { ) {
if let Some(shared) = map().lock().unwrap().get(&peer) { if let Some(shared) = map().lock().unwrap().get(&peer) {
shared.borrow_mut().insets = Insets { let mut shared = shared.borrow_mut();
shared.insets = Insets {
left, left,
top, top,
right, right,
bottom, bottom,
ime_bottom, ime_bottom,
ime_visible: ime_visible != 0,
}; };
shared.updates += 1;
} }
// Insets can change (the keyboard opening) with no resize and no // Insets can change (the keyboard opening) with no resize and no
// touch, so nothing else here would otherwise ask for a frame. // touch, so nothing else here would otherwise ask for a frame.
@@ -115,7 +141,7 @@ pub fn register_native_methods<'local, 'other_local>(
&[ &[
NativeMethod { NativeMethod {
name: "applyWindowInsetsNative".into(), name: "applyWindowInsetsNative".into(),
sig: "(JIIIII)V".into(), sig: "(JIIIIII)V".into(),
fn_ptr: apply_window_insets as *mut c_void, fn_ptr: apply_window_insets as *mut c_void,
}, },
NativeMethod { NativeMethod {
+1
View File
@@ -17,6 +17,7 @@ mod attr;
mod ime; mod ime;
mod input; mod input;
mod insets; mod insets;
mod platform;
mod render; mod render;
mod view; mod view;
+89
View File
@@ -0,0 +1,89 @@
use crate::platform::OpenUrl;
use android_view::{
View,
jni::{
JNIEnv,
objects::{JObject, JValue},
},
};
use super::view::HasAndroidUiState;
/// Android's URL opener. Like `FocusHost::focus_gained`'s keyboard, the
/// real work is a JNI call and this runs deep inside the sensor dispatch
/// with no `CallbackCtx` in reach -- so it raises a flag that
/// `IrisViewPeer::after_input` consumes, exactly as
/// `pending_show_keyboard` does.
///
/// Last request wins: two links cannot be tapped in one frame, and a URL
/// left queued from a frame that somehow never reached `after_input`
/// would open at some unrelated later tap, which is worse than dropping
/// it.
impl<T: HasAndroidUiState> OpenUrl for T {
fn open_url(&mut self, url: &str) {
self.android_state_mut().pending_open_url = Some(url.to_string());
}
}
/// `startActivity(new Intent(ACTION_VIEW, Uri.parse(url)))` on the view's
/// own context.
///
/// `FLAG_ACTIVITY_NEW_TASK` because the context here is the view's, which
/// may be an application context rather than the activity's -- Android
/// throws `AndroidRuntimeException` for a non-activity context without it,
/// and it is harmless when the context *is* an activity's.
///
/// Every failure is logged with the URL and returns; there is nothing to
/// fall back to, and the reader will see that nothing happened.
pub(super) fn open_url<'local>(env: &mut JNIEnv<'local>, view: &View<'local>, url: &str) {
match try_open_url(env, view, url) {
Ok(()) => {}
Err(e) => {
// A pending Java exception makes every later JNI call fail in
// ways nowhere near here, so it is cleared at the boundary.
let _ = env.exception_clear();
log::warn!("could not open {url}: {e}");
}
}
}
fn try_open_url<'local>(
env: &mut JNIEnv<'local>,
view: &View<'local>,
url: &str,
) -> Result<(), android_view::jni::errors::Error> {
let context = env
.call_method(&view.0, "getContext", "()Landroid/content/Context;", &[])?
.l()?;
let jurl = env.new_string(url)?;
let uri = env.call_static_method(
"android/net/Uri",
"parse",
"(Ljava/lang/String;)Landroid/net/Uri;",
&[JValue::Object(jurl.as_ref())],
)?;
let action = env.new_string("android.intent.action.VIEW")?;
let intent = env.new_object(
"android/content/Intent",
"(Ljava/lang/String;Landroid/net/Uri;)V",
&[JValue::Object(action.as_ref()), JValue::Object(&uri.l()?)],
)?;
env.call_method(
&intent,
"addFlags",
"(I)Landroid/content/Intent;",
&[JValue::Int(FLAG_ACTIVITY_NEW_TASK)],
)?;
env.call_method(
&context,
"startActivity",
"(Landroid/content/Intent;)V",
&[JValue::Object(&JObject::from(intent))],
)?;
Ok(())
}
/// `android.content.Intent.FLAG_ACTIVITY_NEW_TASK`. A constant rather than
/// a static-field read: it is part of the platform's stable ABI and
/// reading it costs two more JNI calls that can each fail.
const FLAG_ACTIVITY_NEW_TASK: i32 = 0x1000_0000;
+177 -9
View File
@@ -7,9 +7,9 @@ use android_view::{
jni::{ jni::{
JNIEnv, JavaVM, JNIEnv, JavaVM,
objects::{GlobalRef, JValue}, objects::{GlobalRef, JValue},
sys::jint, sys::{jint, jlong},
}, },
ndk::event::{Keycode, MotionAction}, ndk::event::{Axis, Keycode, MotionAction},
}; };
// `marker::Sized` explicitly: `crate::prelude::*` below also brings in the // `marker::Sized` explicitly: `crate::prelude::*` below also brings in the
// `Sized` *widget* (`widget::position::sized::Sized`), and an unqualified // `Sized` *widget* (`widget::position::sized::Sized`), and an unqualified
@@ -20,7 +20,7 @@ use std::{
marker::{PhantomData, Sized}, marker::{PhantomData, Sized},
rc::Rc, rc::Rc,
sync::Arc, sync::Arc,
time::Instant, time::{Duration, Instant},
}; };
use super::{ use super::{
@@ -54,6 +54,10 @@ pub struct AndroidUiState {
/// inside the platform-agnostic sensor dispatch with no `CallbackCtx` /// inside the platform-agnostic sensor dispatch with no `CallbackCtx`
/// in reach. /// in reach.
pub pending_show_keyboard: bool, pub pending_show_keyboard: bool,
/// A URL a tapped link asked the platform to open, for the same
/// reason `pending_show_keyboard` is a flag rather than a call --
/// see `android/platform.rs`.
pub pending_open_url: Option<String>,
/// Window insets, filled in from outside the normal `ViewPeer` callback /// Window insets, filled in from outside the normal `ViewPeer` callback
/// path -- see `android/insets.rs` for why they need a registry of /// path -- see `android/insets.rs` for why they need a registry of
/// their own. /// their own.
@@ -113,6 +117,7 @@ impl AndroidUiState {
last_click: Instant::now(), last_click: Instant::now(),
compose_len: 0, compose_len: 0,
pending_show_keyboard: false, pending_show_keyboard: false,
pending_open_url: None,
shared, shared,
access_adapter: Default::default(), access_adapter: Default::default(),
access: AccessTree::new(), access: AccessTree::new(),
@@ -125,6 +130,28 @@ impl AndroidUiState {
pub fn insets(&self) -> Insets { pub fn insets(&self) -> Insets {
self.shared.borrow().insets self.shared.borrow().insets
} }
/// The insets state as one line for a diagnostics pane, including how
/// many times the platform has delivered any -- see
/// `insets::Shared::updates` for why the count is the load-bearing
/// part. `dispatches=0` says the listener has never run and the
/// numbers beside it are defaults rather than measurements, which is
/// the distinction a screenshot otherwise cannot make (UI_RULES.md,
/// "design the unknown state first").
pub fn insets_report(&self) -> String {
let shared = self.shared.borrow();
let i = shared.insets;
if shared.updates == 0 {
return "insets: dispatches=0 -- the platform has never called \
onApplyWindowInsets, so nothing below was measured"
.to_string();
}
format!(
"insets: dispatches={} left={} top={} right={} bottom={} ime_bottom={} \
ime_visible={}",
shared.updates, i.left, i.top, i.right, i.bottom, i.ime_bottom, i.ime_visible,
)
}
} }
impl HasRoot for AndroidUiState { impl HasRoot for AndroidUiState {
@@ -190,7 +217,12 @@ pub struct WindowInsets {
pub top: f32, pub top: f32,
pub right: f32, pub right: f32,
pub bottom: f32, pub bottom: f32,
/// How much of the window the keyboard covers, in physical pixels --
/// what a layout pads by. See `insets::Insets::ime_visible` for why
/// "is the keyboard up" is a separate field rather than this one
/// compared against zero.
pub ime_bottom: f32, pub ime_bottom: f32,
pub ime_visible: bool,
} }
impl WindowInsets { impl WindowInsets {
@@ -201,6 +233,7 @@ impl WindowInsets {
right: insets.right as f32, right: insets.right as f32,
bottom: insets.bottom as f32, bottom: insets.bottom as f32,
ime_bottom: insets.ime_bottom as f32, ime_bottom: insets.ime_bottom as f32,
ime_visible: insets.ime_visible,
} }
} }
} }
@@ -279,6 +312,12 @@ pub struct IrisViewPeer<State: AndroidAppState> {
pub(super) render: UiRenderState, pub(super) render: UiRenderState,
pub(super) state: State, pub(super) state: State,
task_recv: TaskMsgReceiver<AndroidRsc<State>>, task_recv: TaskMsgReceiver<AndroidRsc<State>>,
/// `(an Instant, the input-event nanosecond stamp it was taken at)`,
/// captured from the first `MotionEvent` this view receives and never
/// changed after -- how `on_touch_event` dates every touch sample. Its
/// path out is the peer's own drop: it holds nothing but two numbers
/// and is meaningless to any other view.
input_clock: Option<(Instant, jlong)>,
} }
impl<State: 'static, I: RscIdx<AndroidRsc<State>>> std::ops::Index<I> for AndroidRsc<State> { impl<State: 'static, I: RscIdx<AndroidRsc<State>>> std::ops::Index<I> for AndroidRsc<State> {
@@ -302,12 +341,13 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
} }
} }
/// Common tail for every callback that might have changed the cursor, /// One pointer sample through the sensors, plus the platform calls a
/// the text focus, or the widget tree: run the sensors that touch /// handler can only ask for by raising a flag. Split out of
/// input feeds, then ask for a frame if the result needs drawing. /// [`Self::after_input`] because a batched `MotionEvent` carries
/// Mirrors `default::DefaultApp::window_event`'s tail, split across /// several samples that all belong to the same *frame*
/// android-view's several entry points instead of winit's one. /// (`on_touch_event`): each one is a real input frame the widgets must
pub(super) fn after_input(&mut self, ctx: &mut CallbackCtx) { /// see, but only the last one ends the frame and asks for a redraw.
fn run_input_frame(&mut self, ctx: &mut CallbackCtx) {
let window_size = self.window_size(); let window_size = self.window_size();
let ui_state = self.state.android_state_mut(); let ui_state = self.state.android_state_mut();
let cursor = ui_state.cursor.clone(); let cursor = ui_state.cursor.clone();
@@ -324,6 +364,18 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
if std::mem::take(&mut ui_state.pending_show_keyboard) { if std::mem::take(&mut ui_state.pending_show_keyboard) {
show_soft_input(&mut ctx.env, &ctx.view); show_soft_input(&mut ctx.env, &ctx.view);
} }
if let Some(url) = ui_state.pending_open_url.take() {
super::platform::open_url(&mut ctx.env, &ctx.view, &url);
}
}
/// Common tail for every callback that might have changed the cursor,
/// the text focus, or the widget tree: run the sensors that touch
/// input feeds, then ask for a frame if the result needs drawing.
/// Mirrors `default::DefaultApp::window_event`'s tail, split across
/// android-view's several entry points instead of winit's one.
pub(super) fn after_input(&mut self, ctx: &mut CallbackCtx) {
self.run_input_frame(ctx);
// RUST.md's P0 box, "doesn't enter it until I hit space, and also // RUST.md's P0 box, "doesn't enter it until I hit space, and also
// doesn't move cursor forward": Gboard needs `updateSelection` // doesn't move cursor forward": Gboard needs `updateSelection`
@@ -368,6 +420,23 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
let current_insets = ui_state.insets(); let current_insets = ui_state.insets();
if current_insets != ui_state.last_insets { if current_insets != ui_state.last_insets {
let physical = WindowInsets::from_physical(current_insets); let physical = WindowInsets::from_physical(current_insets);
// One line per real insets change. Iris's phone is the only
// place several of these bugs reproduce and `adb logcat` is
// the only instrument there (this-machine-android: system
// tracing is broken on that device), so the numbers a layout
// is actually fed have to reach the log -- "the composer
// floats at launch" is unanswerable from a screenshot alone.
log::info!(
"iris insets: left={} top={} right={} bottom={} ime_bottom={} \
ime_visible={} window={:?}",
physical.left,
physical.top,
physical.right,
physical.bottom,
physical.ime_bottom,
physical.ime_visible,
self.window_size(),
);
self.state.android_state_mut().last_insets = current_insets; self.state.android_state_mut().last_insets = current_insets;
self.state.on_insets_changed(&mut self.rsc, physical); self.state.on_insets_changed(&mut self.rsc, physical);
} }
@@ -391,6 +460,12 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
// both count. See `iris_core::FrameReport`'s own doc for exactly // both count. See `iris_core::FrameReport`'s own doc for exactly
// what this does and does not measure. // what this does and does not measure.
let frame_start = Instant::now(); let frame_start = Instant::now();
// Anything moving on its own -- today a `List` coasting through a
// fling -- is advanced here, before the draw, and asks for the
// next frame at the end of this one. See
// `UiData::tick_animations`; `default/mod.rs`'s
// `RedrawRequested` arm is the same two lines for winit.
let animating = self.rsc.ui.tick_animations(frame_start);
let ui_state = self.state.android_state_mut(); let ui_state = self.state.android_state_mut();
self.render.update(&ui_state.root, &mut self.rsc); self.render.update(&ui_state.root, &mut self.rsc);
let ui_state = self.state.android_state_mut(); let ui_state = self.state.android_state_mut();
@@ -423,6 +498,12 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
.android_state_mut() .android_state_mut()
.frame_report .frame_report
.record_split(frame_start.elapsed(), submit_to_present); .record_split(frame_start.elapsed(), submit_to_present);
// A frame callback is one-shot, so an animation that wants
// another frame has to say so every frame -- unlike `after_input`,
// which only has to ask when input dirtied something.
if animating {
ctx.view.post_frame_callback(&mut ctx.env);
}
let ui_state = self.state.android_state(); let ui_state = self.state.android_state();
log::debug!( log::debug!(
"render(): after update active={} root_px={:?}", "render(): after update active={} root_px={:?}",
@@ -531,7 +612,67 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
// -- see `AndroidUiState::content_scale`'s field comment. // -- see `AndroidUiState::content_scale`'s field comment.
let x = event.x(&mut ctx.env); let x = event.x(&mut ctx.env);
let y = event.y(&mut ctx.env); let y = event.y(&mut ctx.env);
// The event's own clock, converted through one anchor taken on the
// first touch this view ever sees. Android reports sample times in
// the `SystemClock.uptimeMillis()` base, which is the same
// `CLOCK_MONOTONIC` an `Instant` reads, so a single
// `(Instant, nanos)` pair converts every later sample exactly.
// Anchoring **once** rather than per event is what keeps the times
// ordered: a fresh `Instant::now()` per event, minus each sample's
// age inside it, can date a later event's first historical sample
// before the previous event's last one whenever delivery jitters by
// more than the batch spans -- and `VelocityTracker::add_sample`'s
// debug assert would rightly fire on that. See `CursorState::time`.
let event_time = event.event_time_nanos(&mut ctx.env);
let (anchor_at, anchor_nanos) =
*self.input_clock.get_or_insert((Instant::now(), event_time));
let at = |sample_time: jlong| {
anchor_at + Duration::from_nanos(sample_time.saturating_sub(anchor_nanos).max(0) as u64)
};
// **Historical samples first.** A flick on a 120Hz screen is
// delivered as one or two `MotionEvent`s with the intermediate
// positions batched inside them, so reading only `x()`/`y()` threw
// away every sample but the last: the velocity tracker saw one
// `Pan` for the whole gesture, `VelocityTracker::velocity` answers
// 0.0 below two samples, and the release therefore flung at zero --
// Iris's phone, twice ("fling still doesn't work"), while a
// `ui-trace` swipe, which is many evenly-spaced events, flung fine.
// Replayed one at a time through the sensors rather than summarised,
// so the arbiter, the tracker and any other sensor all see the same
// motion the finger actually made; only the last sample ends the
// frame (`after_input`).
if matches!(action, MotionAction::Move) {
let history = event.history_size(&mut ctx.env);
// Android documents the historical samples as oldest first and
// the event's own sample as the newest of the batch; everything
// downstream (`VelocityTracker`, `DragArbiter`'s long-press
// clock) assumes it, so say so here rather than at each reader.
let mut previous = anchor_nanos;
for pos in 0..history {
let hx = event.historical_axis(&mut ctx.env, Axis::X, 0, pos);
let hy = event.historical_axis(&mut ctx.env, Axis::Y, 0, pos);
let ht = event.historical_event_time_nanos(&mut ctx.env, pos);
debug_assert!(
ht >= previous,
"historical sample {pos} of {history} is dated {ht}ns, before the {previous}ns \
sample ahead of it -- the input clock is not what this assumes"
);
previous = ht;
let ui_state = self.state.android_state_mut(); let ui_state = self.state.android_state_mut();
ui_state.cursor.pos = vec2(hx, hy);
ui_state.cursor.time = at(ht);
self.run_input_frame(ctx);
}
debug_assert!(
event_time >= previous,
"the event's own sample is dated {event_time}ns, before its last historical \
sample at {previous}ns"
);
}
let ui_state = self.state.android_state_mut();
ui_state.cursor.time = at(event_time);
match action { match action {
MotionAction::Down => { MotionAction::Down => {
ui_state.cursor.pos = vec2(x, y); ui_state.cursor.pos = vec2(x, y);
@@ -541,6 +682,13 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
MotionAction::Move => { MotionAction::Move => {
ui_state.cursor.pos = vec2(x, y); ui_state.cursor.pos = vec2(x, y);
} }
// `Cancel` ends the gesture the same way `Up` does, and must:
// a release that never arrives leaves whichever widget took
// pointer capture holding it forever, with every later touch
// delivered to a drag nobody is performing. Confirmed present
// before this pass rather than assumed -- it was one of the
// three suspects listed for the phone's missing fling, and it
// is not the cause.
MotionAction::Up | MotionAction::Cancel => { MotionAction::Up | MotionAction::Cancel => {
ui_state.cursor.pos = vec2(x, y); ui_state.cursor.pos = vec2(x, y);
ui_state.cursor.buttons.left.update(false); ui_state.cursor.buttons.left.update(false);
@@ -627,6 +775,12 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
// backgrounding) still goes through `AndroidRenderer::new` below, // backgrounding) still goes through `AndroidRenderer::new` below,
// since `renderer` is `None` in that case. // since `renderer` is `None` in that case.
let already_live = self.state.android_state().renderer.is_some(); let already_live = self.state.android_state().renderer.is_some();
log::info!(
"iris surface: surface_changed {width}x{height} already_live={already_live} \
glyphs_cached={} atlas_pages={}",
self.rsc.ui.text.atlas.glyph_count(),
self.rsc.ui.text.atlas.page_count(),
);
if already_live { if already_live {
let ui_state = self.state.android_state_mut(); let ui_state = self.state.android_state_mut();
ui_state ui_state
@@ -671,6 +825,13 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
// builds a new renderer, exactly where invalidation is // builds a new renderer, exactly where invalidation is
// needed, never on the reuse branch, where it would throw // needed, never on the reuse branch, where it would throw
// away perfectly valid GPU state for nothing. // away perfectly valid GPU state for nothing.
log::info!(
"iris surface: new renderer built ({:?}), clearing glyph atlas: \
glyphs={} pages={}",
renderer.adapter_backend,
self.rsc.ui.text.atlas.glyph_count(),
self.rsc.ui.text.atlas.page_count(),
);
self.rsc.ui.text.atlas.clear(); self.rsc.ui.text.atlas.clear();
self.rsc.ui.textures.reset(); self.rsc.ui.textures.reset();
self.state.android_state_mut().renderer = Some(renderer); self.state.android_state_mut().renderer = Some(renderer);
@@ -707,6 +868,12 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
_ctx: &mut CallbackCtx<'local>, _ctx: &mut CallbackCtx<'local>,
_holder: &android_view::SurfaceHolder<'local>, _holder: &android_view::SurfaceHolder<'local>,
) { ) {
log::info!(
"iris surface: surface_destroyed, tearing the renderer down \
(glyphs_cached={} atlas_pages={})",
self.rsc.ui.text.atlas.glyph_count(),
self.rsc.ui.text.atlas.page_count(),
);
self.state.android_state_mut().renderer = None; self.state.android_state_mut().renderer = None;
} }
@@ -852,6 +1019,7 @@ pub fn new_peer<'local, State: AndroidAppState>(
render, render,
state, state,
task_recv, task_recv,
input_clock: None,
}; };
let id = android_view::register_view_peer(peer); let id = android_view::register_view_peer(peer);
super::insets::register(id, shared); super::insets::register(id, shared);
+61 -5
View File
@@ -18,9 +18,14 @@ pub trait FocusHost {
/// side effect the way a real double-click timer does. /// side effect the way a real double-click timer does.
fn recent_click(&mut self) -> bool; fn recent_click(&mut self) -> bool;
fn set_focus(&mut self, id: Option<WeakWidget<TextEdit>>); fn set_focus(&mut self, id: Option<WeakWidget<TextEdit>>);
/// Called after a `TextEdit` becomes the focus target, with the region /// Called on every tap that should put the IME on `id`: the tap that
/// it was hit in (`None` when the widget could not be located, which /// *makes* a `TextEdit` the focus target, and any later tap on one that
/// happens for one it was just deselected from). /// already is. `region` is where it was hit (`None` when the widget
/// could not be located, which happens for one it was just deselected
/// from). Implementations must be idempotent -- both backends' calls
/// (`showSoftInput`, `set_ime_cursor_area`) already are, which is what
/// lets the repeat tap be handled by the same call rather than by a
/// second "re-show" entry point beside it.
fn focus_gained(&mut self, region: Option<PixelRegion>); fn focus_gained(&mut self, region: Option<PixelRegion>);
/// Whether `id` is the current focus target -- what [`select`] uses to /// Whether `id` is the current focus target -- what [`select`] uses to
/// tell a fresh press (which must wait to see whether it becomes a tap /// tell a fresh press (which must wait to see whether it becomes a tap
@@ -129,8 +134,59 @@ fn on_press(
sense: CursorSense, sense: CursorSense,
) { ) {
if state.is_focused(id) { if state.is_focused(id) {
let recent = matches!(sense, CursorSense::PressStart(_)) && state.recent_click(); // Already focused, so there is no keyboard to withhold -- but a
id.edit(rsc).select(pos, size, sense.is_dragging(), recent); // vertical drag still is not a selection. Android's own `EditText`
// scrolls its overflowed text on a vertical drag and starts a
// selection only from a long press; a scroll area wrapping this
// field (`Scroll::drag`) is what actually pans, and it needs the
// first frames of the gesture not to have selected anything behind
// it before it crosses `DRAG_SLOP` and takes pointer capture.
// `press_origin` carries the same meaning here as in the unfocused
// branch below -- "this gesture is still eligible", cleared the
// moment it becomes a drag -- so there is one flag, not two.
match sense {
CursorSense::PressStart(_) => {
let recent = state.recent_click();
id.edit(rsc).text.press_origin = Some(pos);
id.edit(rsc).select(pos, size, false, recent);
}
CursorSense::Pressing(_) | CursorSense::PressEnd(_) => {
let mut ctx = id.edit(rsc);
let Some(origin) = ctx.text.press_origin else {
return;
};
let (dx, dy) = (pos.x - origin.x, pos.y - origin.y);
if dy.abs() > DRAG_SLOP && dy.abs() >= dx.abs() {
ctx.text.press_origin = None;
return;
}
let ended = matches!(sense, CursorSense::PressEnd(_));
if ended {
ctx.text.press_origin = None;
}
ctx.select(pos, size, true, false);
// A tap on a field that is *already* focused asks for the
// keyboard again (Iris's phone, 2026-09-06: "I can't reopen
// keyboard by tapping on message box after it already
// happened once"). Dismissing the IME -- back gesture, or
// its own hide button -- takes the keyboard away but leaves
// the field focused, so without this the one branch that
// requests it (the unfocused one below) never runs again
// and the field is permanently unable to summon it.
// Android's own `EditText` does exactly this: every tap on
// a focused field calls `showSoftInput`, which is a no-op
// when the keyboard is already up.
//
// Gated on the same tap-vs-drag test the unfocused branch
// uses, not on `PressEnd` alone, so a drag-to-select that
// happens to finish inside the field does not summon a
// keyboard the reader was not asking for.
if ended && dx.abs() <= DRAG_SLOP && dy.abs() <= DRAG_SLOP {
state.focus_gained(render.window_region(&id, &*rsc));
}
}
_ => {}
}
return; return;
} }
+5 -3
View File
@@ -1,5 +1,5 @@
use crate::prelude::*; use crate::prelude::*;
use winit::dpi::{LogicalPosition, LogicalSize}; use winit::dpi::{PhysicalPosition, PhysicalSize};
impl<T: HasDefaultUiState> FocusHost for T { impl<T: HasDefaultUiState> FocusHost for T {
fn recent_click(&mut self) -> bool { fn recent_click(&mut self) -> bool {
@@ -18,9 +18,11 @@ impl<T: HasDefaultUiState> FocusHost for T {
let state = self.default_state_mut(); let state = self.default_state_mut();
let Some(region) = region else { return }; let Some(region) = region else { return };
state.window.set_ime_allowed(true); state.window.set_ime_allowed(true);
// Physical, like everything else this backend hands winit --
// `default::content_scale`.
state.window.set_ime_cursor_area( state.window.set_ime_cursor_area(
LogicalPosition::<f32>::from(region.top_left.tuple()), PhysicalPosition::<f32>::from(region.top_left.tuple()),
LogicalSize::<f32>::from(region.size().tuple()), PhysicalSize::<f32>::from(region.size().tuple()),
); );
} }
} }
+21 -17
View File
@@ -1,4 +1,10 @@
// `CursorState::time` is the sample's own time on every backend. winit
// carries no timestamp on a pointer event, so the moment it is handed to
// us is the closest measurement available here -- which is also what the
// drag code used to do for itself with `Instant::now()`, before Android's
// batched samples made the difference matter (see `sense::CursorState`).
use crate::prelude::*; use crate::prelude::*;
use std::time::Instant;
use winit::{ use winit::{
event::{MouseButton, MouseScrollDelta, WindowEvent}, event::{MouseButton, MouseScrollDelta, WindowEvent},
keyboard::{Key, NamedKey}, keyboard::{Key, NamedKey},
@@ -11,18 +17,19 @@ pub struct Input {
} }
impl Input { impl Input {
/// `scale_factor` converts winit's physical-pixel event coordinates /// winit's pointer coordinates are physical pixels, which is the
/// into the same logical units `UiRenderNode`'s window uniform now uses /// space the whole tree is laid out and hit-tested in -- see
/// (`default::render::UiRenderer::new`'s doc comment) -- without it, /// `default::content_scale`. Nothing is converted here; `dp(...)`
/// a cursor position and the widget tree it's tested against would be /// resolves against the density at layout time instead.
/// in two different units on any monitor whose scale factor isn't 1.0. pub fn event(&mut self, event: &WindowEvent) -> bool {
pub fn event(&mut self, event: &WindowEvent, scale_factor: f32) -> bool {
match event { match event {
WindowEvent::CursorMoved { position, .. } => { WindowEvent::CursorMoved { position, .. } => {
self.cursor.pos = Vec2::new(position.x as f32, position.y as f32) / scale_factor; self.cursor.pos = Vec2::new(position.x as f32, position.y as f32);
self.cursor.exists = true; self.cursor.exists = true;
self.cursor.time = Instant::now();
} }
WindowEvent::MouseInput { state, button, .. } => { WindowEvent::MouseInput { state, button, .. } => {
self.cursor.time = Instant::now();
let buttons = &mut self.cursor.buttons; let buttons = &mut self.cursor.buttons;
let pressed = state.is_pressed(); let pressed = state.is_pressed();
match button { match button {
@@ -35,15 +42,14 @@ impl Input {
WindowEvent::MouseWheel { delta, .. } => { WindowEvent::MouseWheel { delta, .. } => {
let mut delta = match *delta { let mut delta = match *delta {
MouseScrollDelta::LineDelta(x, y) => Vec2::new(x, y), MouseScrollDelta::LineDelta(x, y) => Vec2::new(x, y),
MouseScrollDelta::PixelDelta(pos) => { MouseScrollDelta::PixelDelta(pos) => Vec2::new(pos.x as f32, pos.y as f32),
Vec2::new(pos.x as f32, pos.y as f32) / scale_factor
}
}; };
if delta.x == 0.0 && self.modifiers.shift { if delta.x == 0.0 && self.modifiers.shift {
delta.x = delta.y; delta.x = delta.y;
delta.y = 0.0; delta.y = 0.0;
} }
self.cursor.scroll_delta = delta; self.cursor.scroll_delta = delta;
self.cursor.time = Instant::now();
} }
WindowEvent::CursorLeft { .. } => { WindowEvent::CursorLeft { .. } => {
self.cursor.exists = false; self.cursor.exists = false;
@@ -74,14 +80,12 @@ impl Input {
} }
impl DefaultUiState { impl DefaultUiState {
/// Physical pixels, matching `WindowEvent::Resized` (what
/// `UiRenderState::resize` is given) and the swapchain -- see
/// `default::content_scale`.
pub fn window_size(&self) -> Vec2 { pub fn window_size(&self) -> Vec2 {
let window = self.renderer.window(); let size = self.renderer.window().inner_size();
let size = window.inner_size(); Vec2::new(size.width as f32, size.height as f32)
let scale_factor = window.scale_factor() as f32;
Vec2::new(
size.width as f32 / scale_factor,
size.height as f32 / scale_factor,
)
} }
pub fn cursor_state(&self) -> &CursorState { pub fn cursor_state(&self) -> &CursorState {
+55 -3
View File
@@ -15,6 +15,7 @@ mod access;
mod app; mod app;
mod attr; mod attr;
mod input; mod input;
mod platform;
mod render; mod render;
pub use access::*; pub use access::*;
@@ -24,6 +25,38 @@ pub use render::*;
pub type Proxy<Event> = EventLoopProxy<Event>; pub type Proxy<Event> = EventLoopProxy<Event>;
/// The desktop's `content_scale`: physical pixels per dp, the same
/// quantity Android reads from `DisplayMetrics.density` and feeds to
/// `UiRenderState::set_density` (`android::view::AndroidUiState::
/// content_scale`'s field comment). Everything in this backend is
/// physical pixels -- the window size, the pointer, the widget tree --
/// and `dp(...)` is what resolves against this at layout time, exactly
/// as on the phone. That is a correction from an earlier version that
/// divided winit's coordinates into a separate "logical" space instead:
/// it left `UiRenderState::resize` (physical, from `WindowEvent::
/// Resized`) and the window uniform (logical) disagreeing on any
/// display whose scale factor is not 1.0, and it rasterised glyphs at
/// one resolution to display them at another -- the blur the phone's own
/// stopgap produced before `dp` existed.
///
/// **`IRIS_SCALE` overrides it**, which is how a phone-shaped desktop
/// window runs the phone's density (`run-headless.sh --phone`,
/// docs/RUST.md's layer 2). An unparsable value is a typo in a command
/// somebody just typed, so it says so and uses the window's own answer
/// rather than silently laying out at the wrong density.
pub fn content_scale(window: &Window) -> f32 {
match std::env::var("IRIS_SCALE") {
Err(_) => window.scale_factor() as f32,
Ok(text) => match text.trim().parse::<f32>() {
Ok(scale) if scale > 0.0 => scale,
_ => {
log::warn!("IRIS_SCALE={text:?} is not a positive number; using the window's own");
window.scale_factor() as f32
}
},
}
}
pub struct DefaultUiState { pub struct DefaultUiState {
pub root: Option<StrongWidget>, pub root: Option<StrongWidget>,
pub renderer: UiRenderer, pub renderer: UiRenderer,
@@ -213,8 +246,16 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
window.set_visible(true); window.set_visible(true);
let default_state = DefaultUiState::new(window, access_adapter); let default_state = DefaultUiState::new(window, access_adapter);
let (mut rsc, task_recv) = DefaultRsc::init(default_state.window.clone()); let (mut rsc, task_recv) = DefaultRsc::init(default_state.window.clone());
// Both copies of the density, set before the first widget is
// built so text shapes at the right size on the opening frame --
// the same pair `android::view::new_peer` sets from
// `content_scale`. See `iris_core::TextData::density` for why the
// shaper keeps its own.
let scale = content_scale(default_state.window.as_ref());
rsc.ui.text.density = scale;
let state = State::new(default_state, &mut rsc, proxy); let state = State::new(default_state, &mut rsc, proxy);
let render = UiRenderState::new(); let mut render = UiRenderState::new();
render.set_density(scale);
Self { Self {
rsc, rsc,
state, state,
@@ -246,8 +287,7 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
ui_state ui_state
.access_adapter .access_adapter
.process_event(&ui_state.window, &event); .process_event(&ui_state.window, &event);
let scale_factor = ui_state.renderer.window().scale_factor() as f32; let input_changed = ui_state.input.event(&event);
let input_changed = ui_state.input.event(&event, scale_factor);
let cursor_state = ui_state.cursor_state().clone(); let cursor_state = ui_state.cursor_state().clone();
let old = ui_state.focus; let old = ui_state.focus;
if cursor_state.buttons.left.is_start() { if cursor_state.buttons.left.is_start() {
@@ -266,9 +306,21 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
match &event { match &event {
WindowEvent::CloseRequested => event_loop.exit(), WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::RedrawRequested => { WindowEvent::RedrawRequested => {
// Before the draw, so this frame shows this instant's
// position (`UiData::tick_animations`' own doc), and the
// window is asked for another frame while anything is
// still moving -- the winit half of what
// `IrisViewPeer::render`'s `post_frame_callback` does on
// Android. Nothing else in iris moves without an input
// event.
let animating = rsc.ui_mut().tick_animations(std::time::Instant::now());
let ui_state = state.default_state_mut();
render.update(&ui_state.root, rsc); render.update(&ui_state.root, rsc);
ui_state.renderer.update(&mut rsc.ui, render); ui_state.renderer.update(&mut rsc.ui, render);
ui_state.renderer.draw(); ui_state.renderer.draw();
if animating {
ui_state.window.request_redraw();
}
// I4 (RUST.md): only produces a `TreeUpdate` when the named // I4 (RUST.md): only produces a `TreeUpdate` when the named
// set actually changed this frame -- see `AccessTree`'s doc // set actually changed this frame -- see `AccessTree`'s doc
// comment. `render` reflects the draw that just happened, // comment. `render` reflects the draw that just happened,
+33
View File
@@ -0,0 +1,33 @@
use crate::platform::OpenUrl;
use crate::prelude::HasDefaultUiState;
/// The desktop's URL opener: the platform's own "open this with whatever
/// is registered for it" command, detached so a browser starting slowly
/// cannot stall the event loop.
///
/// A command rather than a crate: `xdg-open`/`open`/`start` is what every
/// such crate shells out to anyway, and this is one call site.
impl<T: HasDefaultUiState> OpenUrl for T {
fn open_url(&mut self, url: &str) {
let (program, first): (&str, &[&str]) = if cfg!(target_os = "macos") {
("open", &[])
} else if cfg!(target_os = "windows") {
// `start` is a shell builtin, and its first argument is the
// window title -- an empty one, or a URL containing `&` ends
// up split.
("cmd", &["/C", "start", ""])
} else {
("xdg-open", &[])
};
match std::process::Command::new(program)
.args(first)
.arg(url)
.spawn()
{
Ok(_) => {}
// Named with the command that failed and the link it was for,
// since neither is recoverable from the OS error alone.
Err(e) => log::warn!("could not open {url} with {program}: {e}"),
}
}
}
+20 -22
View File
@@ -66,13 +66,11 @@ impl UiRenderer {
self.config.width = size.width; self.config.width = size.width;
self.config.height = size.height; self.config.height = size.height;
self.surface.configure(&self.device, &self.config); self.surface.configure(&self.device, &self.config);
// Logical, matching `new`'s own seed -- see the comment there. // Physical, matching `new`'s own seed -- see the comment there.
let scale_factor = self.window.scale_factor() as f32; self.ui.resize(
let logical = Vec2::new( Vec2::new(size.width as f32, size.height as f32),
size.width as f32 / scale_factor, &self.queue,
size.height as f32 / scale_factor,
); );
self.ui.resize(logical, &self.queue);
} }
fn create_encoder(device: &Device) -> CommandEncoder { fn create_encoder(device: &Device) -> CommandEncoder {
@@ -85,7 +83,16 @@ impl UiRenderer {
let size = window.inner_size(); let size = window.inner_size();
let instance = Instance::new(&InstanceDescriptor { let instance = Instance::new(&InstanceDescriptor {
backends: Backends::PRIMARY, // `force-gles` on the desktop too, not just on Android: the
// GLES backend has behaviour of its own (a one-layer array
// texture is a `GL_TEXTURE_2D` -- see
// `GpuTextures::create_array_texture`), and a machine with a
// real GPU is where that is cheap to reproduce and screenshot.
backends: if cfg!(feature = "force-gles") {
Backends::GL
} else {
Backends::PRIMARY
},
..Default::default() ..Default::default()
}); });
@@ -153,21 +160,12 @@ impl UiRenderer {
// by:" chain as the message, since `UiRenderNode::new` returns it // by:" chain as the message, since `UiRenderNode::new` returns it
// rather than letting wgpu's own default handler panic first (see // rather than letting wgpu's own default handler panic first (see
// that function's doc comment). // that function's doc comment).
// Logical size (physical / `scale_factor`), matching what the // Physical size, the same units the swapchain, `WindowEvent::
// Android backend now reports too (`android::render:: // Resized`, the pointer and the widget tree all use -- see
// AndroidRenderer::new`, `content_scale`) -- the swapchain still // `default::content_scale` for why this backend stopped dividing
// configures at the real physical resolution above; only the // into a separate logical space, and what disagreed while it did.
// window uniform layout/hit-testing agree on is scaled. Without let physical_size = Vec2::new(size.width as f32, size.height as f32);
// this a window on any monitor whose scale factor isn't 1.0 would let ui = UiRenderNode::new(&device, &queue, &config, physical_size)
// have the identical "everything too small" bug RUST.md's P0 box
// found on Iris's phone, just never noticed here because this
// crate's own dev monitors happen to run at 1.0.
let scale_factor = window.scale_factor() as f32;
let logical_size = Vec2::new(
size.width as f32 / scale_factor,
size.height as f32 / scale_factor,
);
let ui = UiRenderNode::new(&device, &queue, &config, logical_size)
.expect("Could not create iris render node!"); .expect("Could not create iris render node!");
Self { Self {
+396
View File
@@ -0,0 +1,396 @@
//! Layer 1 of docs/RUST.md's "Three test layers": a whole screen driven
//! in-process with **no window, no compositor and no GPU**, on an
//! explicit clock and a replayed touch stream.
//!
//! `layout_tests.rs` and `sense_tests.rs` already build trees over
//! `UiRenderState` with a hand-rolled `Rsc` each; this is the same idea
//! carried far enough to open a real app screen (`transcript-ui`'s, over
//! the bench fixture -- see the `transcript-fixture` crate) at the
//! phone's size and density, feed it a recorded flick, and assert on
//! where the list ended up. What it answers that the emulator cannot:
//! Android batches a 120Hz flick into one or two `MotionEvent`s
//! (`CursorState::time`), and a `ui-trace` swipe is many evenly-spaced
//! ones -- so the gesture shape a finger actually makes is only
//! reproducible from a *file* of timestamped samples.
//!
//! It is a third backend in the sense `default/` and `android/` are, and
//! deliberately the smallest one: the platform half of each of those
//! (a surface, an IME, a URL opener) becomes a recorded fact here --
//! [`HarnessState::keyboard_shown`], [`HarnessState::opened_urls`] --
//! so a test can assert the platform *was asked*, which is the only
//! thing either backend does with those calls anyway.
//!
//! ```ignore
//! let mut h = Harness::new(phone_size(), PHONE_SCALE);
//! let screen = transcript_ui::build(&mut h.rsc, &mut h.state, rows);
//! h.frame(0);
//! h.replay(&TouchScript::parse(include_str!("flick.touch"))?);
//! h.frames_until(20, 2_000, 8);
//! ```
use crate::prelude::*;
use std::marker::PhantomData;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
/// One replayed pointer sample: what Android's `MotionEvent` carries, cut
/// down to the part iris reads (`IrisViewPeer::on_touch_event`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TouchAction {
Down,
Move,
Up,
/// The gesture taken away by the system (a parent view claiming it, a
/// call arriving). It ends the press exactly as `Up` does -- a
/// release that never arrives leaves pointer capture held forever --
/// which is why a replay file can say it.
Cancel,
}
impl TouchAction {
fn parse(word: &str) -> Option<Self> {
match word {
"down" => Some(Self::Down),
"move" => Some(Self::Move),
"up" => Some(Self::Up),
"cancel" => Some(Self::Cancel),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct TouchSample {
/// Milliseconds since the start of the recording -- the sample's own
/// time, which becomes `CursorState::time`. See that field's doc for
/// why a replay may not date its samples by when the loop got to
/// them.
pub t_ms: u64,
pub action: TouchAction,
pub pos: Vec2,
}
/// A recorded gesture: one `t_ms action x y` line per sample, `#` and
/// blank lines ignored. Deliberately a plain text file rather than a
/// serialisation format -- it is written by hand as often as it is
/// recorded, and a diff of one has to be readable.
pub struct TouchScript {
pub samples: Vec<TouchSample>,
}
impl TouchScript {
/// Parses a script, naming the line and what was wrong with it: these
/// are hand-written files, so a typo is the ordinary case and
/// "expected 4 fields" without a line number is not enough to fix it.
pub fn parse(text: &str) -> Result<Self, String> {
let mut samples: Vec<TouchSample> = Vec::new();
for (i, line) in text.lines().enumerate() {
let line = line.split('#').next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
let at = |what: &str| format!("touch script line {}: {what}: {line:?}", i + 1);
let mut words = line.split_whitespace();
let (Some(t), Some(action), Some(x), Some(y), None) = (
words.next(),
words.next(),
words.next(),
words.next(),
words.next(),
) else {
return Err(at("expected `t_ms action x y`"));
};
let t_ms: u64 = t.parse().map_err(|_| at("t_ms is not a whole number"))?;
let action = TouchAction::parse(action)
.ok_or_else(|| at("action is not down/move/up/cancel"))?;
let x: f32 = x.parse().map_err(|_| at("x is not a number"))?;
let y: f32 = y.parse().map_err(|_| at("y is not a number"))?;
if let Some(last) = samples.last()
&& t_ms < last.t_ms
{
return Err(at("samples must be in time order"));
}
samples.push(TouchSample {
t_ms,
action,
pos: Vec2::new(x, y),
});
}
Ok(Self { samples })
}
/// The last sample's time, i.e. how long the recording runs.
pub fn end_ms(&self) -> u64 {
self.samples.last().map(|s| s.t_ms).unwrap_or(0)
}
}
/// Counts the frames something asked for without drawing any -- the
/// harness's `RequestRedraw`. A `List` coasting through a fling asks for
/// the next frame through this (`List::set_redraw_handle`), so a test can
/// tell "nothing moved" from "nothing was even asked to move".
#[derive(Default)]
pub struct RedrawCounter(AtomicUsize);
impl RedrawCounter {
pub fn count(&self) -> usize {
self.0.load(Ordering::Relaxed)
}
}
impl RequestRedraw for RedrawCounter {
fn request_redraw(&self) {
self.0.fetch_add(1, Ordering::Relaxed);
}
}
/// The harness's app state: what each real backend keeps for the platform
/// half, recorded instead of performed.
pub struct HarnessState {
pub root: Option<StrongWidget>,
pub focus: Option<WeakWidget<TextEdit>>,
last_click: Instant,
/// How many times a tap asked for the keyboard (`FocusHost::
/// focus_gained` with a region -- `showSoftInput` on Android,
/// `set_ime_cursor_area` on winit). The platform's own answer is not
/// available here, so this says what was *asked*, and a test must not
/// read it as "the IME is up".
pub keyboard_shown: usize,
/// Every URL a tapped link asked the platform to open, in order.
pub opened_urls: Vec<String>,
}
impl HarnessState {
fn new() -> Self {
Self {
root: None,
focus: None,
last_click: Instant::now(),
keyboard_shown: 0,
opened_urls: Vec::new(),
}
}
}
impl HasRoot for HarnessState {
fn set_root(&mut self, root: StrongWidget) {
self.root = Some(root);
}
}
impl FocusHost for HarnessState {
fn recent_click(&mut self) -> bool {
crate::attr::recent_click(&mut self.last_click)
}
fn set_focus(&mut self, id: Option<WeakWidget<TextEdit>>) {
self.focus = id;
}
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool {
self.focus == Some(id)
}
fn focus_gained(&mut self, region: Option<PixelRegion>) {
if region.is_some() {
self.keyboard_shown += 1;
}
}
}
impl OpenUrl for HarnessState {
fn open_url(&mut self, url: &str) {
self.opened_urls.push(url.to_string());
}
}
/// The harness's `Rsc` -- identical in substance to `DefaultRsc`/
/// `AndroidRsc` minus the windowing, for the same reason those two are
/// separate types (`AndroidRsc`'s own doc).
pub struct HarnessRsc {
pub ui: UiData,
pub events: EventManager<Self>,
pub tasks: Tasks<Self>,
pub state: WidgetState,
_state: PhantomData<HarnessState>,
}
impl UiRsc for HarnessRsc {
fn ui(&self) -> &UiData {
&self.ui
}
fn ui_mut(&mut self) -> &mut UiData {
&mut self.ui
}
fn on_draw(&mut self, active: &ActiveData) {
self.events.draw(active);
}
fn on_undraw(&mut self, active: &ActiveData) {
self.events.undraw(active);
}
fn on_remove(&mut self, id: WidgetId) {
self.events.remove(id);
self.state.remove(id);
}
}
impl HasState for HarnessRsc {
type State = HarnessState;
}
impl HasEvents for HarnessRsc {
fn events(&self) -> &EventManager<Self> {
&self.events
}
fn events_mut(&mut self) -> &mut EventManager<Self> {
&mut self.events
}
}
impl HasTasks for HarnessRsc {
fn tasks_mut(&mut self) -> &mut Tasks<Self> {
&mut self.tasks
}
}
impl HasWidgetState for HarnessRsc {
fn widget_state(&self) -> &WidgetState {
&self.state
}
fn widget_state_mut(&mut self) -> &mut WidgetState {
&mut self.state
}
}
impl<I: RscIdx<HarnessRsc>> std::ops::Index<I> for HarnessRsc {
type Output = I::Output;
fn index(&self, index: I) -> &Self::Output {
index.get(self)
}
}
impl<I: RscIdx<HarnessRsc>> std::ops::IndexMut<I> for HarnessRsc {
fn index_mut(&mut self, index: I) -> &mut Self::Output {
index.get_mut(self)
}
}
/// A screen running with no window: the widget tree, the frame loop and
/// the pointer, all advanced by the caller. See the module doc.
pub struct Harness {
pub rsc: HarnessRsc,
pub render: UiRenderState,
pub state: HarnessState,
task_recv: TaskMsgReceiver<HarnessRsc>,
redraws: Arc<RedrawCounter>,
cursor: CursorState,
/// Time zero. Every `t_ms` in this harness is an offset from here, so
/// nothing reads the wall clock -- see [`Self::at`].
base: Instant,
size: Vec2,
}
impl Harness {
/// `size` is in physical pixels and `density` is physical pixels per
/// dp, the pair Android reads from the surface and
/// `DisplayMetrics.density` (`AndroidUiState::content_scale`). The
/// phone's own numbers are `transcript_fixture::PHONE_SIZE`/
/// `PHONE_SCALE`.
pub fn new(size: Vec2, density: f32) -> Self {
let redraws = Arc::new(RedrawCounter::default());
let (tasks, task_recv) = Tasks::init(redraws.clone());
let mut rsc = HarnessRsc {
ui: UiData::default(),
events: EventManager::default(),
tasks,
state: WidgetState::default(),
_state: PhantomData,
};
rsc.ui.text.density = density;
let mut render = UiRenderState::new();
render.set_density(density);
render.resize(size);
Self {
rsc,
render,
state: HarnessState::new(),
task_recv,
redraws,
cursor: CursorState::default(),
base: Instant::now(),
size,
}
}
/// The `Instant` this harness means by `t_ms`. Public because a
/// caller driving `List::tick_fling` or `DragGesture` by hand needs
/// to date those calls on the same clock the touch samples use.
pub fn at(&self, t_ms: u64) -> Instant {
self.base + Duration::from_millis(t_ms)
}
pub fn size(&self) -> Vec2 {
self.size
}
/// How many frames were asked for so far -- see [`RedrawCounter`].
pub fn redraws(&self) -> usize {
self.redraws.count()
}
/// One frame at `t_ms`: drain finished tasks, advance anything
/// animating, lay out and "draw". The same three steps
/// `DefaultApp::window_event`'s `RedrawRequested` arm and
/// `IrisViewPeer::render` take, minus handing primitives to a GPU.
pub fn frame(&mut self, t_ms: u64) {
while let Ok(update) = self.task_recv.try_recv() {
update(&mut self.state, &mut self.rsc);
}
let now = self.at(t_ms);
self.rsc.ui.tick_animations(now);
self.render.update(&self.state.root, &mut self.rsc);
}
/// Frames every `step_ms` up to and including `end_ms` -- what a
/// fling needs, since it moves only while something ticks it
/// (`List::fling`'s doc). Returns the time of the last frame run.
pub fn frames_until(&mut self, from_ms: u64, end_ms: u64, step_ms: u64) -> u64 {
debug_assert!(step_ms > 0, "a frame loop with no step never ends");
let mut t = from_ms;
while t <= end_ms {
self.frame(t);
t += step_ms;
}
t - step_ms
}
/// One pointer sample through the sensors, then the frame it belongs
/// to -- `IrisViewPeer::on_touch_event` and `after_input`, in one
/// call. Each sample is its own input frame, dated by the sample
/// rather than by when this ran.
pub fn touch(&mut self, action: TouchAction, pos: Vec2, t_ms: u64) {
self.cursor.time = self.at(t_ms);
self.cursor.pos = pos;
match action {
TouchAction::Down => {
self.cursor.exists = true;
self.cursor.buttons.left.update(true);
}
TouchAction::Move => {}
TouchAction::Up | TouchAction::Cancel => self.cursor.buttons.left.update(false),
}
let cursor = self.cursor.clone();
self.render
.run_sensors(&mut self.rsc, &mut self.state, cursor, self.size);
self.frame(t_ms);
self.cursor.end_frame();
}
/// Replays a whole recorded gesture. Nothing is inserted between the
/// samples: a file with three lines produces three input frames, so
/// the batched shape a real flick arrives in is preserved exactly as
/// recorded rather than smoothed into evenly-spaced motion.
pub fn replay(&mut self, script: &TouchScript) {
for sample in &script.samples {
self.touch(sample.action, sample.pos, sample.t_ms);
}
}
}
+397 -2
View File
@@ -68,7 +68,7 @@ fn an_unchanged_frame_draws_and_rewrites_nothing() {
render.take_counters(); // discard the first, real draw render.take_counters(); // discard the first, real draw
render.update(&root, &mut rsc); render.update(&root, &mut rsc);
let (draws, rewrites, moves) = render.take_counters(); let (draws, rewrites, moves, _shapes) = render.take_counters();
assert_eq!((draws, rewrites, moves), (0, 0, 0)); assert_eq!((draws, rewrites, moves), (0, 0, 0));
} }
@@ -101,7 +101,7 @@ fn scrolling_moves_in_o1_without_a_redraw() {
// already clamped) rather than actually moving anything. // already clamped) rather than actually moving anything.
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-40.0); rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-40.0);
render.update(&root, &mut rsc); render.update(&root, &mut rsc);
let (draws, _rewrites, moves) = render.take_counters(); let (draws, _rewrites, moves, _shapes) = render.take_counters();
// The pass condition (LAYOUT.md section 8, condition 3) is 0 draws and // The pass condition (LAYOUT.md section 8, condition 3) is 0 draws and
// 1 move_offsets write, independent of how many rects are in the // 1 move_offsets write, independent of how many rects are in the
@@ -147,6 +147,37 @@ fn hit_testing_follows_a_scrolled_widget() {
); );
} }
/// `ActiveData::mask` is the mask a widget was drawn **under**, not the one
/// it set for itself -- `redraw` feeds it straight back in as the inherited
/// mask, so storing the set one hands a `Masked` its own mask the second
/// time round and trips `Painter::set_mask`'s nested-mask assert. That was
/// an abort (`assertion failed: self.mask == MaskIdx::NONE`) the first time
/// the composer's new scroll area was redrawn on the emulator; a targeted
/// redraw of a `Masked` is what any real screen does whenever anything
/// inside it changes.
#[test]
fn redrawing_a_masked_widget_does_not_nest_its_own_mask() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (_scroll, inner_root, _rects) = scrolled_rects(&mut rsc, 8);
let masked = rsc.ui.widgets.add_strong(Masked { inner: inner_root });
let masked_id = masked.id();
let root = masked.any();
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
render.update(&root, &mut rsc);
render.redraw(masked_id, &mut rsc);
render.redraw(masked_id, &mut rsc);
assert_eq!(
render.active.get(&masked_id).unwrap().mask,
MaskIdx::NONE,
"a `Masked` at the root is drawn under no mask of its own"
);
}
#[test] #[test]
fn a_mask_stays_put_while_its_scrolled_content_moves() { fn a_mask_stays_put_while_its_scrolled_content_moves() {
let mut rsc = TestRsc { let mut rsc = TestRsc {
@@ -226,6 +257,14 @@ fn composing_text_after_a_keyboard_resize_lands_in_the_bars_own_region() {
render.resize((1080.0, 2298.0)); render.resize((1080.0, 2298.0));
render.update(&root, &mut rsc); render.update(&root, &mut rsc);
// Focusing a field is what places its caret on a real tap
// (`attr.rs`'s `on_press` -> `TextEditCtx::select`), and an insert
// with no caret is a routing bug rather than a state to simulate --
// `insert_str`'s own `debug_assert!` says so, and caught this test
// typing into an unfocused field when it was added.
field
.edit(&mut rsc)
.select(vec2(40.0, 2250.0), vec2(1080.0, 2298.0), false, false);
field.edit(&mut rsc).insert("a"); field.edit(&mut rsc).insert("a");
render.update(&root, &mut rsc); render.update(&root, &mut rsc);
@@ -260,3 +299,359 @@ fn composing_text_after_a_keyboard_resize_lands_in_the_bars_own_region() {
"expected the bar near the bottom of the shorter window: {after_px:?}" "expected the bar near the bottom of the shorter window: {after_px:?}"
); );
} }
/// `Scroll` used to be documented as resolving its own lengths against
/// `Painter::output_size` -- the window -- which read as if a scroll area
/// smaller than the screen could not work, and cost a session's
/// investigation before the composer was wired up (docs/RUST.md,
/// 2026-09-06). It measures `painter.px_size()` now, so this pins the
/// three numbers that follow from the offered box: what it reports
/// upward, what its capping parent reports, and how far it can pan.
#[test]
fn a_scroll_measures_the_box_it_was_offered_not_the_window() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let tall = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(),
x: None,
y: Some(Len::abs(1000.0)),
});
let scroll = rsc.ui.widgets.add_strong(Scroll::new(tall.any(), Axis::Y));
let scroll_w = scroll.weak();
let scroll_id = scroll.id();
let capped = rsc.ui.widgets.add_strong(MaxSize {
inner: scroll.any(),
x: None,
y: Some(Len::abs(100.0)),
});
let capped_id = capped.id();
let root = capped.any();
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
// Two passes: the first offers the content a zero-length region
// (nothing measured yet) and learns the real content length from what
// comes back -- see `scrolling_moves_in_o1_without_a_redraw` for why
// that warm-up is deliberate rather than a bug.
render.update(&root, &mut rsc);
rsc.ui.widgets.get_mut(&scroll_w).unwrap().scroll(0.0);
render.update(&root, &mut rsc);
// Reports the *content*, so the cap above it has something to cap;
// reporting the container instead would make the answer a function of
// itself, since the container is sized from this very number.
assert_eq!(
render.active.get(&scroll_id).unwrap().size.y,
Len::abs(1000.0)
);
assert_eq!(
render.active.get(&capped_id).unwrap().size.y,
Len::abs(100.0),
"the cap, not the content and not the window"
);
// Panning is bounded by content minus *container*: 900, not the 400
// a 600px window would give.
rsc.ui.widgets.get_mut(&scroll_w).unwrap().scroll(-10_000.0);
assert!(
(rsc.ui.widgets.get_mut(&scroll_w).unwrap().amt() - 900.0).abs() < 0.01,
"amt={}",
rsc.ui.widgets.get_mut(&scroll_w).unwrap().amt()
);
}
/// The half `hit_testing_follows_a_scrolled_widget` could not see: it
/// checks a *descendant* of the widget `Scroll` actually moves, whose own
/// `region` is stale and is corrected entirely by the move chain. The
/// moved widget itself had its `region` updated *and* the chain delta
/// added on top, so its hit box sat at twice the pan -- which is why a
/// finger pan of the composer left its field untappable. See
/// `ActiveData::move_applied`.
#[test]
fn a_panned_widgets_own_hit_box_moves_exactly_once() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let tall = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(),
x: None,
y: Some(Len::abs(1000.0)),
});
let tall_w = tall.weak();
let scroll = rsc.ui.widgets.add_strong(Scroll::new(tall.any(), Axis::Y));
let scroll_w = scroll.weak();
let root = scroll.any();
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
render.update(&root, &mut rsc);
rsc.ui.widgets.get_mut(&scroll_w).unwrap().scroll(0.0);
render.update(&root, &mut rsc);
let before = render.window_region(&tall_w, &rsc).unwrap();
rsc.ui.widgets.get_mut(&scroll_w).unwrap().scroll(-37.0);
render.update(&root, &mut rsc);
let after = render.window_region(&tall_w, &rsc).unwrap();
assert!(
(after.top_left.y - (before.top_left.y - 37.0)).abs() < 0.01,
"the pan was applied twice: before={before:?} after={after:?}"
);
}
/// A `Masked` used to allocate a **new** mask slot on every draw, and
/// `draw_inner`'s unchanged-region fast path means its descendants are
/// mostly *not* redrawn with it -- so they went on referencing the slot
/// they were first drawn under, whose region had since stopped being the
/// widget's. Measured 2026-09-06 on the composer's tree: four live mask
/// entries, none of them the `Masked`'s current box, and the field it was
/// meant to clip drew nothing at all on the emulator. The slot is
/// allocated once and rewritten in place now (`ActiveData::own_mask`), so
/// this pins both halves: one entry, and that entry is the widget's own
/// region.
#[test]
fn a_masked_widget_keeps_one_mask_slot_that_is_always_its_own_region() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (_scroll, inner_root, _rects) = scrolled_rects(&mut rsc, 8);
let masked = rsc.ui.widgets.add_strong(Masked { inner: inner_root });
let masked_id = masked.id();
// Placed at the bottom of a `Span::DOWN` behind a `rest(1)` sibling,
// which is what moves the bar away from the provisional slot it is
// first drawn at -- the move that left the stale mask behind.
let filler = rsc.ui.widgets.add_strong(Rect::new(UiColor::BLACK));
let filler = rsc.ui.widgets.add_strong(Sized {
inner: filler.any(),
x: None,
y: Some(rest(1)),
});
let capped = rsc.ui.widgets.add_strong(MaxSize {
inner: masked.any(),
x: None,
y: Some(Len::abs(60.0)),
});
let mut span = Span::empty(Dir::DOWN);
span.push(filler.any());
span.push(capped.any());
let root = rsc.ui.widgets.add_strong(span).any();
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
for _ in 0..3 {
render.update(&root, &mut rsc);
render.redraw(masked_id, &mut rsc);
}
assert_eq!(
rsc.ui.masks.iter().count(),
1,
"one `Masked` must own exactly one mask slot, however often it is redrawn"
);
let mask = *rsc.ui.masks.iter().next().unwrap();
assert_eq!(
mask.region,
render.active.get(&masked_id).unwrap().region,
"the mask a descendant clips against must be this widget's current box"
);
}
/// A `dp` cap that has done its job must be reported in pixels. `Span`
/// places a child using the `abs`/`rel` of the length it reported, so a
/// `MaxSize` handing back the caller's own `dp(168)` gave the composer's
/// bar a slot of **zero** the moment its content grew past six lines --
/// and the `Scroll` inside then measured its container at -63px (the
/// padding, subtracted from nothing) and panned the whole message out of
/// view. Measured on this checkout's emulator, 2026-09-06:
/// `container=-63 content=415.8 amt=478.8`. See `Len::fold_dp`.
#[test]
fn a_dp_cap_is_reported_in_pixels_so_a_span_can_place_it() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let tall = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(),
x: None,
y: Some(Len::abs(1000.0)),
});
let capped = rsc.ui.widgets.add_strong(MaxSize {
inner: tall.any(),
x: None,
y: Some(Len::dp(100.0)),
});
let capped_w = capped.weak();
let filler = rsc.ui.widgets.add_strong(Rect::new(UiColor::BLACK));
let filler = rsc.ui.widgets.add_strong(Sized {
inner: filler.any(),
x: None,
y: Some(rest(1)),
});
let mut span = Span::empty(Dir::DOWN);
span.push(filler.any());
span.push(capped.any());
let root = rsc.ui.widgets.add_strong(span).any();
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
render.set_density(2.5);
render.update(&root, &mut rsc);
render.update(&root, &mut rsc);
let box_px = render.window_region(&capped_w, &rsc).unwrap();
let height = box_px.bot_right.y - box_px.top_left.y;
assert!(
(height - 250.0).abs() < 0.01,
"expected the 100dp cap at density 2.5 to be a 250px slot, got {height} ({box_px:?})"
);
}
/// The sibling of `a_panned_widgets_own_hit_box_moves_exactly_once`, on
/// the branch that fix had no reason to touch: `draw_inner`'s
/// size-independent fast path rewrites a widget's primitives *in place*
/// and leaves its move slot alone, so unlike `mov` there is no slot delta
/// for `region` to have absorbed. Counting one there anyway makes
/// `resolved_region` subtract a delta the chain never held, and the
/// widget's hit box lands short of where it is drawn by exactly the
/// distance it just moved -- with nothing on screen to say so, since the
/// primitives are in the right place.
#[test]
fn a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let top = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let spacer = rsc.ui.widgets.add_strong(Sized {
inner: top.any(),
x: None,
y: Some(Len::abs(100.0)),
});
let spacer_w = spacer.weak();
// `Rect` is `is_size_independent`, so growing the spacer above it
// offers this one a region that changed *both* position and size --
// the one shape that reaches the branch under test.
let below = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let below_w = below.weak();
let mut span = Span::empty(Dir::DOWN);
span.push(spacer.any());
span.push(below.any());
let root = rsc.ui.widgets.add_strong(span).any();
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
render.update(&root, &mut rsc);
// `Span` draws each child once at the full region to measure it and
// then places it, so this widget has already been through the branch
// once by the end of the very first frame.
let first = render.window_region(&below_w, &rsc).unwrap();
assert!(
(first.top_left.y - 100.0).abs() < 0.01,
"hit box at {:?}, drawn at y=100",
first.top_left
);
rsc.ui.widgets.get_mut(&spacer_w).unwrap().y = Some(Len::abs(250.0));
render.update(&root, &mut rsc);
let after = render.window_region(&below_w, &rsc).unwrap();
assert!(
(after.top_left.y - 250.0).abs() < 0.01,
"hit box at {:?}, drawn at y=250",
after.top_left
);
}
/// A parent that both `mov`s a child (its own layout moved the box it
/// offers) and `reposition`s it inside that box in the same frame -- what
/// `List::place`'s Bottom-known branch does once a row's cached height
/// stops matching what the row reports, which is reachable as soon as a
/// transcript row's blocks wrap (docs/IRIS_TODO.md's "Found by P1a").
struct MoveThenPlace {
inner: StrongWidget,
/// Where the child is *offered* a (constant-size) box, moved between
/// frames by the test.
offer_top: f32,
/// Where the child is then placed within this widget's own region.
place_top: f32,
}
impl Widget for MoveThenPlace {
fn draw(&mut self, painter: &mut Painter) -> Size {
let offer = UiRegion::new(
UiSpan::FULL,
UiSpan::new(
UiScalar::abs(self.offer_top),
UiScalar::abs(self.offer_top + 40.0),
),
);
painter.widget_within(&self.inner, offer);
let place = UiRegion::new(
UiSpan::FULL,
UiSpan::new(
UiScalar::abs(self.place_top),
UiScalar::abs(self.place_top + 40.0),
),
);
painter.reposition(&self.inner, place);
Size::default()
}
}
/// `mov` accumulates a delta onto a widget's move slot and `reposition`
/// overwrites it, and both can legitimately land on one widget in one
/// frame (see `MoveThenPlace`). `reposition` used to write its own delta
/// alone, which dropped the move and put the child back at the position
/// the offered box had *before* it moved; a `debug_assert!` that
/// `move_applied` was zero hid that behind a panic instead of fixing it.
/// The slot has one owner and one meaning now --
/// `move_applied + repositioned` -- so the child stays where it was
/// placed however its offered box moves. Fails at the offer's position
/// (200) rather than the placement's (100) without that.
#[test]
fn a_widget_moved_by_its_parent_and_then_placed_inside_it_lands_at_the_placement() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let child = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(),
x: None,
y: Some(Len::abs(40.0)),
});
let child_w = child.weak();
let parent = rsc.ui.widgets.add_strong(MoveThenPlace {
inner: child.any(),
offer_top: 0.0,
place_top: 100.0,
});
let parent_w = parent.weak();
let root = parent.any();
let mut render = UiRenderState::new();
render.resize((200.0, 400.0));
render.update(&root, &mut rsc);
let before = render.window_region(&child_w, &rsc).unwrap();
assert!(
(before.top_left.y - 100.0).abs() < 0.01,
"the child should be drawn where it was placed, not where it was offered: {before:?}"
);
// Move the offered box without changing its size (the `mov` fast path)
// and place the child at the same spot as before. Marking the parent
// dirty is what a real container's own content change does; the child
// itself is untouched, which is the case `mov` exists for.
{
let parent = rsc.ui.widgets.get_mut(&parent_w).unwrap();
parent.offer_top = 200.0;
}
rsc.ui.widgets.needs_redraw.insert(parent_w.id());
render.update(&root, &mut rsc);
let after = render.window_region(&child_w, &rsc).unwrap();
assert!(
(after.top_left.y - 100.0).abs() < 0.01,
"the placement did not change, so neither should the child: before={before:?} \
after={after:?}"
);
}
+3
View File
@@ -21,6 +21,8 @@ pub mod default;
pub mod attr; pub mod attr;
pub mod event; pub mod event;
pub mod harness;
pub mod platform;
pub mod sense; pub mod sense;
pub mod state; pub mod state;
pub mod task; pub mod task;
@@ -47,6 +49,7 @@ pub mod prelude {
pub use event::*; pub use event::*;
pub use iris_core::*; pub use iris_core::*;
pub use iris_macro::*; pub use iris_macro::*;
pub use platform::*;
pub use sense::*; pub use sense::*;
pub use state::*; pub use state::*;
pub use task::*; pub use task::*;
+22
View File
@@ -0,0 +1,22 @@
//! Capabilities a widget tree needs from whatever is hosting it, that
//! neither iris nor the app can perform itself.
//!
//! Same shape as [`crate::attr::FocusHost`], and for the same reason: the
//! interface is declared here, below, and implemented by each backend
//! above (`default/platform.rs`, `android/platform.rs`), so a widget can
//! ask for the capability by trait bound instead of a caller threading a
//! callback down through every builder.
/// Hand a URL to whatever the platform opens URLs with.
///
/// One method rather than a general "run an intent"/"exec" surface: the
/// only thing a transcript needs is to follow a link a reader tapped, and
/// a narrower capability is a narrower thing to get wrong.
///
/// **Nothing is reported back.** There is no answer worth branching on --
/// the platform either shows a browser or does not, and both are outside
/// this process -- so failures are logged where they happen (each impl)
/// rather than turned into a `Result` every call site would discard.
pub trait OpenUrl {
fn open_url(&mut self, url: &str);
}
+593 -98
View File
@@ -95,12 +95,39 @@ impl CursorSense {
} }
} }
#[derive(Default, Clone)] #[derive(Clone)]
pub struct CursorState { pub struct CursorState {
pub pos: Vec2, pub pos: Vec2,
pub exists: bool, pub exists: bool,
pub buttons: CursorButtons, pub buttons: CursorButtons,
pub scroll_delta: Vec2, pub scroll_delta: Vec2,
/// When this pointer state was *sampled*, from the platform's own
/// input clock -- not when the handler reading it happened to run.
///
/// It exists because Android batches touch samples: a flick on a
/// 120Hz screen arrives as one or two `MotionEvent`s carrying the
/// intermediate positions as *historical* samples
/// (`getHistoricalX`/`getHistoricalEventTime`), which
/// `IrisViewPeer::on_touch_event` replays through the sensor pass one
/// at a time. Every one of those replays happens within the same few
/// microseconds, so a gesture timing itself with `Instant::now()`
/// would see a span of nearly zero across the whole flick and divide
/// by it -- the velocity would be an artefact of how fast we looped,
/// which is exactly the inferred-as-measured number UI_RULES.md
/// forbids. Carrying the sample's own time makes the span real.
pub time: Instant,
}
impl Default for CursorState {
fn default() -> Self {
Self {
pos: Vec2::ZERO,
exists: false,
buttons: CursorButtons::default(),
scroll_delta: Vec2::ZERO,
time: Instant::now(),
}
}
} }
#[derive(Default, Clone)] #[derive(Default, Clone)]
@@ -487,6 +514,12 @@ enum ArbiterState {
/// caller-supplied `Instant` rather than a real clock. /// caller-supplied `Instant` rather than a real clock.
pub struct DragArbiter { pub struct DragArbiter {
state: ArbiterState, state: ArbiterState,
/// Which way a pan runs. A transcript pans down its list and a code
/// fence pans across its own long lines, and the two decisions are
/// the same one with the axes swapped -- so the axis is a field
/// rather than a second copy of this state machine, and everything
/// below reads `along`/`across` instead of `dy`/`dx`.
axis: Axis,
origin: Vec2, origin: Vec2,
origin_at: Instant, origin_at: Instant,
last: Vec2, last: Vec2,
@@ -494,20 +527,28 @@ pub struct DragArbiter {
impl Default for DragArbiter { impl Default for DragArbiter {
fn default() -> Self { fn default() -> Self {
Self { Self::on(Axis::Y)
state: ArbiterState::Idle,
origin: Vec2::ZERO,
origin_at: Instant::now(),
last: Vec2::ZERO,
}
} }
} }
impl DragArbiter { impl DragArbiter {
/// A vertical arbiter -- what a list, and every caller before the
/// axis became a field, wants.
pub fn new() -> Self { pub fn new() -> Self {
Self::default() Self::default()
} }
/// An arbiter whose pan runs along `axis`.
pub fn on(axis: Axis) -> Self {
Self {
state: ArbiterState::Idle,
axis,
origin: Vec2::ZERO,
origin_at: Instant::now(),
last: Vec2::ZERO,
}
}
/// A fresh press-down at `pos`. `already_selected` is whatever the /// A fresh press-down at `pos`. `already_selected` is whatever the
/// caller's selection state was *before* this press -- it decides /// caller's selection state was *before* this press -- it decides
/// whether an early horizontal move extends that selection instead of /// whether an early horizontal move extends that selection instead of
@@ -548,25 +589,25 @@ impl DragArbiter {
match self.state { match self.state {
ArbiterState::Idle => DragOutcome::Undecided, ArbiterState::Idle => DragOutcome::Undecided,
ArbiterState::Panning => { ArbiterState::Panning => {
let dy = pos.y - self.last.y; let along = pos.axis(self.axis) - self.last.axis(self.axis);
self.last = pos; self.last = pos;
DragOutcome::Pan(dy) DragOutcome::Pan(along)
} }
ArbiterState::Selecting => { ArbiterState::Selecting => {
self.last = pos; self.last = pos;
DragOutcome::SelectExtend DragOutcome::SelectExtend
} }
ArbiterState::Undecided { already_selected } => { ArbiterState::Undecided { already_selected } => {
let dx = pos.x - self.origin.x; let along = pos.axis(self.axis) - self.origin.axis(self.axis);
let dy = pos.y - self.origin.y; let across = pos.axis(!self.axis) - self.origin.axis(!self.axis);
if already_selected && dx.abs() > DRAG_SLOP && dx.abs() > dy.abs() { if already_selected && across.abs() > DRAG_SLOP && across.abs() > along.abs() {
self.state = ArbiterState::Selecting; self.state = ArbiterState::Selecting;
self.last = pos; self.last = pos;
DragOutcome::SelectExtend DragOutcome::SelectExtend
} else if dy.abs() > DRAG_SLOP && dy.abs() >= dx.abs() { } else if along.abs() > DRAG_SLOP && along.abs() >= across.abs() {
self.state = ArbiterState::Panning; self.state = ArbiterState::Panning;
self.last = pos; self.last = pos;
// `dy` here is the *whole* drag since `press_start`, // `along` here is the *whole* drag since `press_start`,
// not since the last frame -- nothing panned while // not since the last frame -- nothing panned while
// `Undecided` was withholding the slop, so applying it // `Undecided` was withholding the slop, so applying it
// in full on this one frame is a visible jump the // in full on this one frame is a visible jump the
@@ -584,10 +625,10 @@ impl DragArbiter {
// `ViewConfiguration.getScaledTouchSlop()` once from // `ViewConfiguration.getScaledTouchSlop()` once from
// the first scroll past it rather than replaying the // the first scroll past it rather than replaying the
// whole pre-threshold drag in one step. // whole pre-threshold drag in one step.
DragOutcome::Pan(dy - DRAG_SLOP.copysign(dy)) DragOutcome::Pan(along - DRAG_SLOP.copysign(along))
} else if now.duration_since(self.origin_at) >= LONG_PRESS } else if now.duration_since(self.origin_at) >= LONG_PRESS
&& dx.abs() <= DRAG_SLOP && across.abs() <= DRAG_SLOP
&& dy.abs() <= DRAG_SLOP && along.abs() <= DRAG_SLOP
{ {
self.state = ArbiterState::Selecting; self.state = ArbiterState::Selecting;
self.last = pos; self.last = pos;
@@ -613,6 +654,15 @@ impl DragArbiter {
pub fn is_panning(&self) -> bool { pub fn is_panning(&self) -> bool {
matches!(self.state, ArbiterState::Panning) matches!(self.state, ArbiterState::Panning)
} }
/// Whether a press is in flight that has committed to neither a pan
/// nor a selection -- what a release checks to tell a **tap** from
/// the end of a drag. A tap is exactly "pressed and let go without
/// ever deciding", so it is read here rather than timed separately:
/// one gesture machine, one answer.
pub fn is_undecided(&self) -> bool {
matches!(self.state, ArbiterState::Undecided { .. })
}
} }
/// What a [`DragGesture`] decided this frame -- [`DragOutcome`] plus the /// What a [`DragGesture`] decided this frame -- [`DragOutcome`] plus the
@@ -626,6 +676,14 @@ pub enum GestureOutcome {
Pan(f32), Pan(f32),
SelectStart, SelectStart,
SelectExtend, SelectExtend,
/// The press ended without ever committing to a pan or a selection --
/// a tap. Distinct from `Released(None)`, which is the end of a
/// gesture that *did* commit (a selection, or a pan too slow to
/// fling): a caller acting on a tap -- following a markdown link --
/// must not also act when the finger was panning the list past that
/// link, which is the tap-vs-drag rule this enum exists to state
/// once for every caller rather than per widget.
Tapped,
/// The drag ended -- `PressEnd` or the capture's own terminal `Drop`. /// The drag ended -- `PressEnd` or the capture's own terminal `Drop`.
/// `Some(velocity)` only if the gesture had committed to panning /// `Some(velocity)` only if the gesture had committed to panning
/// (never a tap, a long-press selection, or one still `Undecided`); /// (never a tap, a long-press selection, or one still `Undecided`);
@@ -661,8 +719,13 @@ impl Default for DragGesture {
impl DragGesture { impl DragGesture {
pub fn new() -> Self { pub fn new() -> Self {
Self::on(Axis::Y)
}
/// A gesture whose pan runs along `axis` -- see [`DragArbiter::on`].
pub fn on(axis: Axis) -> Self {
Self { Self {
arbiter: DragArbiter::new(), arbiter: DragArbiter::on(axis),
velocity: VelocityTracker::new(), velocity: VelocityTracker::new(),
} }
} }
@@ -696,24 +759,53 @@ impl DragGesture {
match sense { match sense {
CursorSense::PressStart(_) => { CursorSense::PressStart(_) => {
self.velocity.reset(); self.velocity.reset();
// The press itself is a sample: nothing has moved yet, but
// *when* the finger went down is real and measured, and
// without it a gesture whose whole motion arrives in one
// frame has a single sample and therefore no time span to
// divide by -- `velocity` answers 0.0 and the release does
// not fling. Batched touch delivery makes that shape
// ordinary rather than rare (see `CursorState::time`), and
// `VELOCITY_WINDOW` trims this entry back out the moment
// the gesture is long enough not to need it, so a slow
// drag's velocity is still its recent motion and not its
// whole history.
self.velocity.add_sample(0.0, now);
self.arbiter.press_start(pos_window, now, already_selected); self.arbiter.press_start(pos_window, now, already_selected);
self.dispatch(render, id, pos_window, now) self.dispatch(render, id, pos_window, now)
} }
CursorSense::Drop | CursorSense::PressEnd(_) => { CursorSense::Drop | CursorSense::PressEnd(_) => {
let released = if self.arbiter.is_panning() { let outcome = if self.arbiter.is_panning() {
Some(self.velocity.velocity()) GestureOutcome::Released(Some(self.velocity.velocity()))
} else if self.arbiter.is_undecided() {
GestureOutcome::Tapped
} else { } else {
None GestureOutcome::Released(None)
}; };
// The one line that settles "why did that flick not fling"
// from a logcat, which is the only instrument available on
// Iris's phone (this-machine-android: system tracing does
// not work there). Every input to the decision is here, so
// a zero velocity can be told apart from a gesture that
// never reached `Panning` at all -- the two look identical
// on screen and had to be guessed between twice.
log::info!(
"iris drag release: samples={} span={:.1}ms v={:.0} outcome={:?}",
self.velocity.sample_count(),
self.velocity.span().as_secs_f32() * 1000.0,
self.velocity.velocity(),
outcome,
);
self.arbiter.release(); self.arbiter.release();
render.release_pointer(); render.release_pointer();
GestureOutcome::Released(released) outcome
} }
// See `DragArbiter::update`'s own doc: a `Pressing` frame can // See `DragArbiter::update`'s own doc: a `Pressing` frame can
// arrive with no matching `PressStart` if the touch-down // arrive with no matching `PressStart` if the touch-down
// landed outside whichever hit region first noticed it. // landed outside whichever hit region first noticed it.
_ if self.arbiter.is_idle() => { _ if self.arbiter.is_idle() => {
self.velocity.reset(); self.velocity.reset();
self.velocity.add_sample(0.0, now);
self.arbiter.press_start(pos_window, now, already_selected); self.arbiter.press_start(pos_window, now, already_selected);
self.dispatch(render, id, pos_window, now) self.dispatch(render, id, pos_window, now)
} }
@@ -781,6 +873,12 @@ impl VelocityTracker {
/// Record one frame's motion. `delta` is this frame's movement since /// Record one frame's motion. `delta` is this frame's movement since
/// the last sample, not a cumulative position. /// the last sample, not a cumulative position.
pub fn add_sample(&mut self, delta: f32, at: Instant) { pub fn add_sample(&mut self, delta: f32, at: Instant) {
// A caller that samples out of order (a restored/replayed
// gesture, a test) would silently produce a negative `span` in
// `velocity`, handled only by its `span <= 0.0 => 0.0` catch-all
// -- masking the bug that produced it rather than surfacing it
// (docs/REVIEW-2026-09-06.md finding 4).
debug_assert!(self.samples.back().is_none_or(|&(last, _)| at >= last));
self.samples.push_back((at, delta)); self.samples.push_back((at, delta));
while let Some(&(when, _)) = self.samples.front() { while let Some(&(when, _)) = self.samples.front() {
if at.duration_since(when) > VELOCITY_WINDOW { if at.duration_since(when) > VELOCITY_WINDOW {
@@ -791,6 +889,22 @@ impl VelocityTracker {
} }
} }
/// How many samples are currently inside the window, and how long they
/// span. Reported beside the velocity in `DragGesture`'s release log,
/// because a `v=0` on its own cannot say whether the gesture was slow
/// or whether the tracker was simply never fed -- which is exactly the
/// distinction the phone's missing fling turned on.
pub fn sample_count(&self) -> usize {
self.samples.len()
}
pub fn span(&self) -> Duration {
match (self.samples.front(), self.samples.back()) {
(Some(&(first, _)), Some(&(last, _))) => last.duration_since(first),
_ => Duration::ZERO,
}
}
/// The estimated speed, in units-per-second, over whatever samples /// The estimated speed, in units-per-second, over whatever samples
/// currently fall inside the tracking window: total motion divided by /// currently fall inside the tracking window: total motion divided by
/// the elapsed time between the oldest and newest sample still held. /// the elapsed time between the oldest and newest sample still held.
@@ -800,34 +914,45 @@ impl VelocityTracker {
return 0.0; return 0.0;
} }
let total: f32 = self.samples.iter().map(|&(_, d)| d).sum(); let total: f32 = self.samples.iter().map(|&(_, d)| d).sum();
let span = self let span = self.span().as_secs_f32();
.samples
.back()
.unwrap()
.0
.duration_since(self.samples.front().unwrap().0)
.as_secs_f32();
if span <= 0.0 { 0.0 } else { total / span } if span <= 0.0 { 0.0 } else { total / span }
} }
} }
/// Android's fling deceleration curve, ported from AOSP's /// Android's fling deceleration curve, ported from AOSP's
/// `android.widget.OverScroller.SplineOverScroller` (the same curve /// `android.widget.OverScroller.SplineOverScroller` (the same curve
/// Compose's `androidx.compose.ui.gestures.AndroidFlingSpline` and /// Compose's `androidx.compose.animation.AndroidFlingSpline` and
/// `androidx.compose.foundation.gestures.FlingCalculator` reuse) so a /// `androidx.compose.animation.FlingCalculator` reuse) so a fling here
/// fling here travels the same distance a Compose `LazyColumn`'s own /// travels the same distance a Compose `LazyColumn`'s own
/// `ScrollableDefaults.flingBehavior()` would for the same initial /// `ScrollableDefaults.flingBehavior()` would for the same initial
/// velocity -- RUST.md's "Benchmark v2" box asked the two apps' fling /// velocity -- RUST.md's "Benchmark v2" box asked the two apps' fling
/// phase to be comparable, and IRIS_TODO.md's "swiping has no momentum" /// phase to be comparable, and IRIS_TODO.md's "swiping has no momentum"
/// asked for the same physics a reader's muscle memory already expects /// asked for the same physics a reader's muscle memory already expects
/// from every other Android scroll view. /// from every other Android scroll view. Both sources were read at
/// `frameworks/base`'s `core/java/android/widget/OverScroller.java` and
/// `androidx.compose.animation:animation:1.12.0`'s `SplineBasedDecay.kt`
/// (2026-09-07); they agree line for line.
/// ///
/// The curve is a cubic-Bezier-derived spline sampled into two lookup /// **One table, indexed by even steps of *time*.** `SPLINE_POSITION[i]`
/// tables at start-up (`SPLINE`, built once via [`std::sync::OnceLock`]): /// is the fraction of the total distance covered at time fraction
/// `SPLINE_POSITION[i]`/`SPLINE_TIME[i]` give the fraction of total /// `i / NB_SAMPLES`, so a lookup brackets `t` between `index / N` and
/// distance/time elapsed at the `i`th of 100 even steps along the curve's /// `(index + 1) / N` -- never between table entries. AOSP builds a second
/// own parameter. A lookup at an arbitrary time fraction interpolates /// table, `SPLINE_TIME`, purely for `adjustDuration` (re-timing a fling
/// between the two bracketing samples. /// whose target moved), which nothing here has; it is deliberately not
/// built, so there is one array and one indexing rule rather than two of
/// each to pick the wrong one from.
///
/// The wrong one was picked, and this is what it cost. Until 2026-09-07
/// the two halves of AOSP's build loop were transposed -- the bisection
/// solved the tension curve and the sample evaluated the `P1`/`P2` one,
/// where AOSP does the opposite -- which made this table and `SPLINE_TIME`
/// *identical*, and the old lookup, which bracketed `t` between
/// `SPLINE_TIME` entries, then returned exactly `t` for every `t`. A fling
/// coasted at constant speed for its whole duration and stopped dead:
/// Iris's phone report of 2026-09-07, "just linear velocity with an abrupt
/// stop", verbatim out of the arithmetic. Every test it had compared the
/// curve with itself, so none of them could see it;
/// `the_spline_matches_aosps_own_table` pins the absolute numbers now.
mod android_fling_spline { mod android_fling_spline {
use std::sync::OnceLock; use std::sync::OnceLock;
@@ -840,24 +965,30 @@ mod android_fling_spline {
const P1: f32 = START_TENSION * INFLEXION; const P1: f32 = START_TENSION * INFLEXION;
const P2: f32 = 1.0 - END_TENSION * (1.0 - INFLEXION); const P2: f32 = 1.0 - END_TENSION * (1.0 - INFLEXION);
pub(super) struct Spline { /// What a lookup answers: how far along the fling is, and how fast it
position: [f32; NB_SAMPLES + 1], /// is going there -- AOSP's `distanceCoef`/`velocityCoef` and Compose's
time: [f32; NB_SAMPLES + 1], /// `AndroidFlingSpline.FlingResult`. Both are fractions of the fling's
/// *total* distance, the second per unit of its *total* duration, so a
/// caller scales them by `distance` and `distance / duration`.
pub(super) struct SplineSample {
pub(super) distance_fraction: f32,
pub(super) velocity_fraction: f32,
} }
fn build() -> Spline { fn build() -> [f32; NB_SAMPLES + 1] {
let mut position = [0.0f32; NB_SAMPLES + 1]; let mut position = [0.0f32; NB_SAMPLES + 1];
let mut time = [0.0f32; NB_SAMPLES + 1]; let mut x_min = 0.0f32;
let (mut x_min, mut y_min) = (0.0f32, 0.0f32); for (i, slot) in position.iter_mut().enumerate().take(NB_SAMPLES) {
for i in 0..NB_SAMPLES {
let alpha = i as f32 / NB_SAMPLES as f32; let alpha = i as f32 / NB_SAMPLES as f32;
let mut x_max = 1.0f32; let mut x_max = 1.0f32;
let (mut x, mut coef); let (mut x, mut coef);
loop { loop {
x = x_min + (x_max - x_min) / 2.0; x = x_min + (x_max - x_min) / 2.0;
coef = 3.0 * x * (1.0 - x); coef = 3.0 * x * (1.0 - x);
let tx = coef * ((1.0 - x) * START_TENSION + x * END_TENSION) + x * x * x; // Solved on the `P1`/`P2` curve and sampled on the tension
// one. Transposing these two is the defect this module's
// doc comment describes; they are not interchangeable.
let tx = coef * ((1.0 - x) * P1 + x * P2) + x * x * x;
if (tx - alpha).abs() < 1e-5 { if (tx - alpha).abs() < 1e-5 {
break; break;
} }
@@ -867,50 +998,37 @@ mod android_fling_spline {
x_min = x; x_min = x;
} }
} }
position[i] = coef * ((1.0 - x) * P1 + x * P2) + x * x * x; *slot = coef * ((1.0 - x) * START_TENSION + x * END_TENSION) + x * x * x;
let mut y_max = 1.0f32;
let (mut y, mut coef_y);
loop {
y = y_min + (y_max - y_min) / 2.0;
coef_y = 3.0 * y * (1.0 - y);
let dy = coef_y * ((1.0 - y) * START_TENSION + y * END_TENSION) + y * y * y;
if (dy - alpha).abs() < 1e-5 {
break;
}
if dy > alpha {
y_max = y;
} else {
y_min = y;
}
}
time[i] = coef_y * ((1.0 - y) * P1 + y * P2) + y * y * y;
} }
position[NB_SAMPLES] = 1.0; position[NB_SAMPLES] = 1.0;
time[NB_SAMPLES] = 1.0; position
Spline { position, time }
} }
static SPLINE: OnceLock<Spline> = OnceLock::new(); static SPLINE_POSITION: OnceLock<[f32; NB_SAMPLES + 1]> = OnceLock::new();
/// The fraction of total distance covered at `time_fraction` (0..=1 /// Sample the curve at `time_fraction` (0..=1 of the fling's total
/// of the fling's total duration). Finds the bracketing samples in /// duration), exactly as AOSP's `SplineOverScroller.update` and
/// `SPLINE_TIME` and interpolates linearly between their matching /// Compose's `AndroidFlingSpline.flingPosition` do.
/// `SPLINE_POSITION` entries, exactly as AOSP's `SplineOverScroller pub(super) fn sample(time_fraction: f32) -> SplineSample {
/// .flingPosition` does. let position = SPLINE_POSITION.get_or_init(build);
pub(super) fn distance_fraction(time_fraction: f32) -> f32 {
let spline = SPLINE.get_or_init(build);
let t = time_fraction.clamp(0.0, 1.0); let t = time_fraction.clamp(0.0, 1.0);
let index = ((t * NB_SAMPLES as f32) as usize).min(NB_SAMPLES - 1); let index = (t * NB_SAMPLES as f32) as usize;
let t_inf = spline.time[index]; if index >= NB_SAMPLES {
let t_sup = spline.time[index + 1]; // The end of the fling: all of the distance covered and
let d_inf = spline.position[index]; // nothing left moving. AOSP's `distanceCoef = 1f` /
let d_sup = spline.position[index + 1]; // `velocityCoef = 0f` defaults, which its
let span = t_sup - t_inf; // `if (index < NB_SAMPLES)` leaves in place.
if span <= 0.0 { return SplineSample {
d_inf distance_fraction: 1.0,
} else { velocity_fraction: 0.0,
d_inf + (d_sup - d_inf) * (t - t_inf) / span };
}
let t_inf = index as f32 / NB_SAMPLES as f32;
let t_sup = (index + 1) as f32 / NB_SAMPLES as f32;
let velocity_fraction = (position[index + 1] - position[index]) / (t_sup - t_inf);
SplineSample {
distance_fraction: position[index] + (t - t_inf) * velocity_fraction,
velocity_fraction,
} }
} }
} }
@@ -920,6 +1038,17 @@ mod android_fling_spline {
/// friction of `0.84` per frame at 60Hz corresponds to /// friction of `0.84` per frame at 60Hz corresponds to
/// (`ln(0.78)/ln(0.9)`, `SplineOverScroller.DECELERATION_RATE`). /// (`ln(0.78)/ln(0.9)`, `SplineOverScroller.DECELERATION_RATE`).
const FLING_FRICTION: f32 = 0.015; const FLING_FRICTION: f32 = 0.015;
/// AOSP's own look-and-feel tuning constant, the argument
/// `SplineOverScroller`'s constructor passes to `computeDeceleration` when
/// it builds `mPhysicalCoeff` -- *not* the scroll friction, which is a
/// different number used a different place in the same formula. This was
/// `FLING_FRICTION` here until 2026-09-07, making the coefficient 56x too
/// small, which put an `ln` of a 56x-too-large ratio through
/// `exp(_/(rate-1))`: an ordinary flick came out lasting **30 seconds**
/// instead of 1.6. Nothing could see it while a finger fling never
/// animated at all (`List::fling`'s doc), which is why two defects had to
/// be fixed before either was visible.
const FLING_TUNING: f32 = 0.84;
fn deceleration_rate() -> f32 { fn deceleration_rate() -> f32 {
(0.78f32.ln()) / (0.9f32.ln()) (0.78f32.ln()) / (0.9f32.ln())
} }
@@ -931,11 +1060,15 @@ const GRAVITY_EARTH: f32 = 9.80665;
/// ported the same way Compose's `FlingCalculator` is, including its /// ported the same way Compose's `FlingCalculator` is, including its
/// `density`-dependent physical coefficient (`computeDeceleration`, /// `density`-dependent physical coefficient (`computeDeceleration`,
/// `GravityEarth * 39.37 * density * 160 * friction`). Density and /// `GravityEarth * 39.37 * density * 160 * friction`). Density and
/// velocity/distance units cancel algebraically as long as velocity and /// `density` is physical pixels per `dp`, and the velocity handed in has
/// the returned distance share one pixel space (physical or logical) -- /// to be in those same physical pixels -- which is what a touch event
/// [`crate::widget::List::fling`] relies on exactly that cancellation to /// carries. It does **not** cancel out: `duration` is
/// avoid needing a display density of its own, since iris's `List` /// `exp(ln(k*v/C) / (rate-1))` with `C` proportional to density, so the
/// already works in logical (density-independent) pixels throughout. /// wrong density changes how long a fling lasts exponentially rather than
/// scaling it. An earlier version of this comment claimed the opposite and
/// `List::fling` passed `1.0`; on a 2.75-density screen that gave a
/// one-second flick a 45-second coast (measured 2026-09-07). `List` reads
/// its density from the painter now.
pub struct FlingCalculator { pub struct FlingCalculator {
physical_coefficient: f32, physical_coefficient: f32,
} }
@@ -943,7 +1076,7 @@ pub struct FlingCalculator {
impl FlingCalculator { impl FlingCalculator {
pub fn new(density: f32) -> Self { pub fn new(density: f32) -> Self {
Self { Self {
physical_coefficient: GRAVITY_EARTH * 39.37 * density * 160.0 * FLING_FRICTION, physical_coefficient: GRAVITY_EARTH * 39.37 * density * 160.0 * FLING_TUNING,
} }
} }
@@ -956,6 +1089,10 @@ impl FlingCalculator {
/// Total signed distance the fling travels before settling, in the /// Total signed distance the fling travels before settling, in the
/// same pixel units `velocity` was given in. /// same pixel units `velocity` was given in.
pub fn distance(&self, velocity: f32) -> f32 { pub fn distance(&self, velocity: f32) -> f32 {
// See `List::fling`'s matching assertion -- a non-finite velocity
// here silently produces a NaN distance rather than surfacing the
// bug that produced it (docs/REVIEW-2026-09-06.md finding 3).
debug_assert!(velocity.is_finite());
if velocity == 0.0 { if velocity == 0.0 {
return 0.0; return 0.0;
} }
@@ -968,6 +1105,8 @@ impl FlingCalculator {
/// How long the fling takes to settle. /// How long the fling takes to settle.
pub fn duration(&self, velocity: f32) -> Duration { pub fn duration(&self, velocity: f32) -> Duration {
// See `distance`'s matching assertion, above.
debug_assert!(velocity.is_finite());
if velocity == 0.0 { if velocity == 0.0 {
return Duration::ZERO; return Duration::ZERO;
} }
@@ -977,17 +1116,35 @@ impl FlingCalculator {
} }
/// The signed distance covered by `elapsed` into a fling of this /// The signed distance covered by `elapsed` into a fling of this
/// `velocity` that started at `t0` -- what a per-frame ticker /// `velocity` -- what a per-frame ticker (`List::tick_fling`) calls to
/// (`List::tick_fling`) calls to find how far to have scrolled by now. /// find how far to have scrolled by now. Clamped to the full
/// Clamped to the full `distance()` once `elapsed` reaches /// `distance()` once `elapsed` reaches `duration()`, so a caller need
/// `duration()`, so a caller need not special-case "past the end." /// not special-case "past the end."
pub fn position_at(&self, velocity: f32, elapsed: Duration) -> f32 { pub fn position_at(&self, velocity: f32, elapsed: Duration) -> f32 {
let duration = self.duration(velocity); let duration = self.duration(velocity);
if duration.is_zero() { if duration.is_zero() {
return 0.0; return 0.0;
} }
let fraction = (elapsed.as_secs_f32() / duration.as_secs_f32()).min(1.0); let fraction = elapsed.as_secs_f32() / duration.as_secs_f32();
self.distance(velocity) * android_fling_spline::distance_fraction(fraction) self.distance(velocity) * android_fling_spline::sample(fraction).distance_fraction
}
/// The signed *speed* at `elapsed` into the same fling, in the units
/// `velocity` was given in -- AOSP's `mCurrVelocity` and Compose's
/// `FlingInfo.velocity`. It falls from roughly `velocity` at the start
/// to zero at `duration()`, which is the whole difference between a
/// fling and a constant-speed slide, so it is what
/// `List::tick_fling`'s debug line reports: successive frames printing
/// a shrinking number is the evidence that the curve is being followed
/// at all.
pub fn velocity_at(&self, velocity: f32, elapsed: Duration) -> f32 {
let duration = self.duration(velocity);
if duration.is_zero() {
return 0.0;
}
let fraction = elapsed.as_secs_f32() / duration.as_secs_f32();
android_fling_spline::sample(fraction).velocity_fraction * self.distance(velocity)
/ duration.as_secs_f32()
} }
} }
@@ -1097,6 +1254,39 @@ mod fling_calculator_tests {
} }
} }
/// The absolute numbers, against AOSP's own formula worked by hand --
/// the one thing every other test here cannot see, because they all
/// compare this calculator with itself (monotonic, signed, integrates
/// to the closed form) and so pass just as happily with a coefficient
/// 56x out. That is exactly the state this file was in: an ordinary
/// flick lasted 30 seconds on the emulator and every test was green.
///
/// `SplineOverScroller` at ppi = 2.75*160 = 440:
/// `mPhysicalCoeff = 9.80665 * 39.37 * 440 * 0.84 = 142,698`;
/// `l = ln(0.35 * v / (0.015 * mPhysicalCoeff))`;
/// `duration = exp(l / (DECELERATION_RATE - 1))`.
/// For v = 3000 px/s that is 0.592s and 621px; for 11444 px/s,
/// 1.586s.
#[test]
fn a_flick_lasts_what_aosps_own_formula_says_it_does() {
let calc = FlingCalculator::new(2.75);
let slow = calc.duration(3000.0).as_secs_f32();
assert!(
(slow - 0.592).abs() < 0.02,
"3000px/s at density 2.75 should settle in ~0.59s, got {slow}s"
);
let distance = calc.distance(3000.0);
assert!(
(distance - 621.5).abs() < 5.0,
"3000px/s at density 2.75 should travel ~621px, got {distance}"
);
let fast = calc.duration(11444.0).as_secs_f32();
assert!(
(fast - 1.586).abs() < 0.05,
"11444px/s at density 2.75 should settle in ~1.59s, got {fast}s"
);
}
#[test] #[test]
fn position_at_is_monotonic_and_clamped_past_the_end() { fn position_at_is_monotonic_and_clamped_past_the_end() {
let calc = FlingCalculator::new(1.0); let calc = FlingCalculator::new(1.0);
@@ -1119,6 +1309,93 @@ mod fling_calculator_tests {
total total
); );
} }
/// The table itself, against AOSP's own entries. Every number here
/// came out of `benches/fling_spline_reference.py`, which is a
/// separate hand transcription of `OverScroller.java` and
/// `SplineBasedDecay.kt` -- so this is the one test in the file that
/// is not the Rust code grading its own homework, and the only kind
/// that could have caught the transposed build loop
/// `android_fling_spline`'s doc describes.
///
/// The property that names the old defect directly: the curve is
/// **not** the identity. At a tenth of the way through its time a
/// fling has covered 27.4% of its distance, and at half its time
/// 85.8%. The old table returned 0.100 and 0.500 -- a constant-speed
/// slide -- so the two `assert!`s below fail by a factor of three.
#[test]
fn the_spline_matches_aosps_own_table() {
for (t, expected) in [
(0.0f32, 0.000023f32),
(0.1, 0.274002),
(0.25, 0.583811),
(0.5, 0.858411),
(0.75, 0.971068),
(0.9, 0.995811),
(1.0, 1.0),
] {
let got = android_fling_spline::sample(t).distance_fraction;
assert!(
(got - expected).abs() < 1e-4,
"distance fraction at t={t}: got {got}, AOSP says {expected}"
);
}
// Speed falls monotonically to nothing -- the difference between
// a fling and a slide, and what the abrupt stop was.
let mut last = f32::INFINITY;
for step in 0..=100 {
let v = android_fling_spline::sample(step as f32 / 100.0).velocity_fraction;
assert!(v <= last + 1e-4, "speed rose at t={step}/100: {v} > {last}");
last = v;
}
assert_eq!(android_fling_spline::sample(1.0).velocity_fraction, 0.0);
}
/// The same curve carried through `distance`/`duration` at Iris's own
/// phone density (2.55, `docs/bench/iris-phone-v2-2026-09-06.md`),
/// again with every number from `benches/fling_spline_reference.py`.
/// A fling's *speed* a third of the way through is 4733px/s out of an
/// initial 11064 -- what a reader sees as deceleration, and the
/// quantity that was constant before this.
///
/// The sample fractions are deliberately not round: the velocity
/// coefficient is piecewise constant across each of the 100 samples,
/// so `0.75` sits exactly on a step and the assertion would be about
/// which side of it the last float landed rather than about the curve.
#[test]
fn a_flick_decelerates_the_way_aosp_says_it_does() {
let calc = FlingCalculator::new(2.55);
let velocity = 11064.0f32;
let duration = calc.duration(velocity);
assert!(
(duration.as_secs_f32() - 1.6357).abs() < 0.01,
"duration {duration:?}"
);
assert!(
(calc.distance(velocity) - 6334.2).abs() < 5.0,
"distance {}",
calc.distance(velocity)
);
for (fraction, position, speed) in [
(0.125f32, 2123.3f32, 9202.1f32),
(0.335, 4458.3, 4733.0),
(0.505, 5459.0, 2649.6),
(0.755, 6158.7, 950.9),
] {
let at = duration.mul_f32(fraction);
let got_position = calc.position_at(velocity, at);
let got_speed = calc.velocity_at(velocity, at);
assert!(
(got_position - position).abs() < 5.0,
"position at {fraction} of the fling: got {got_position}, AOSP says {position}"
);
assert!(
(got_speed - speed).abs() < 20.0,
"speed at {fraction} of the fling: got {got_speed}, AOSP says {speed}"
);
}
assert_eq!(calc.velocity_at(velocity, duration), 0.0);
}
} }
#[cfg(test)] #[cfg(test)]
@@ -1280,6 +1557,74 @@ mod drag_arbiter_tests {
assert!(!a.is_idle()); assert!(!a.is_idle());
} }
/// The tap-vs-drag rule a markdown link is followed by
/// (`transcript-ui`'s `row.rs`): a press that never committed is a
/// tap, and a press that panned or selected is not -- read from this
/// one machine rather than timed a second time beside it.
#[test]
fn a_press_that_never_moved_is_still_undecided_at_release() {
let mut a = DragArbiter::new();
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
a.update(Vec2::new(1.0, 1.0), t(10));
assert!(a.is_undecided());
assert!(!a.is_panning());
}
/// The half the tap rule had no reason to touch: a gesture that
/// panned must not also read as a tap when the finger comes up over
/// the link it started on.
#[test]
fn a_press_that_panned_is_not_undecided_at_release() {
let mut a = DragArbiter::new();
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
a.update(Vec2::new(0.0, 40.0), t(10));
assert!(a.is_panning());
assert!(!a.is_undecided());
}
/// A long press that grew a selection is not a tap either.
#[test]
fn a_long_press_that_selected_is_not_undecided() {
let mut a = DragArbiter::new();
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
assert_eq!(
a.update(Vec2::new(0.0, 1.0), t(LONG_PRESS.as_millis() as u64 + 10)),
DragOutcome::SelectStart
);
assert!(!a.is_undecided());
}
/// Both axes are one machine with the axis passed in: a horizontal
/// arbiter (a code fence panning across its own long lines) pans on
/// exactly the drag a vertical one ignores, and ignores the one it
/// pans on.
#[test]
fn a_horizontal_arbiter_pans_on_the_drag_a_vertical_one_ignores() {
let mut across = DragArbiter::on(Axis::X);
across.press_start(Vec2::new(0.0, 0.0), t(0), false);
assert_eq!(
across.update(Vec2::new(20.0, 0.0), t(10)),
DragOutcome::Pan(12.0)
);
let mut down = DragArbiter::new();
down.press_start(Vec2::new(0.0, 0.0), t(0), false);
assert_eq!(
down.update(Vec2::new(20.0, 0.0), t(10)),
DragOutcome::Undecided
);
// ...and a vertical drag over the horizontal one stays undecided,
// which is what lets the list behind a code fence still be
// panned by a finger that started on the fence.
let mut across = DragArbiter::on(Axis::X);
across.press_start(Vec2::new(0.0, 0.0), t(0), false);
assert_eq!(
across.update(Vec2::new(0.0, 20.0), t(10)),
DragOutcome::Undecided
);
}
#[test] #[test]
fn release_resets_to_idle() { fn release_resets_to_idle() {
let mut a = DragArbiter::new(); let mut a = DragArbiter::new();
@@ -1292,3 +1637,153 @@ mod drag_arbiter_tests {
); );
} }
} }
/// [`DragGesture`] end to end, at the shape Android actually delivers a
/// flick in. The arbiter and the tracker each behave correctly on their
/// own (the two modules above); what these cover is the join between them
/// at release, which is where the phone's missing fling lived.
#[cfg(test)]
mod drag_gesture_tests {
use super::*;
use std::sync::LazyLock;
static BASE: LazyLock<Instant> = LazyLock::new(Instant::now);
fn t(ms: u64) -> Instant {
*BASE + Duration::from_millis(ms)
}
/// A `UiRenderState` with nothing in it. `DragGesture` only ever calls
/// `capture_pointer`/`release_pointer` on it, which are bookkeeping on
/// a `Cell` and need no widget tree behind them.
fn render() -> UiRenderState {
UiRenderState::new()
}
/// The id `capture_pointer` records. Any id will do -- nothing here
/// resolves it -- so it comes from a real (empty) widget registry
/// rather than being fabricated.
fn some_id(ui: &mut UiData) -> WidgetId {
ui.widgets.add_strong(Rect::new(UiColor::WHITE)).id()
}
/// **The phone's shape.** A 120Hz flick reaches the app as very few
/// `MotionEvent`s, so before `on_touch_event` replayed the historical
/// samples inside them a whole gesture could be press, one move past
/// the slop, release. That released at `v=0` -- `velocity()` needs two
/// samples and the single `Pan` frame was the only one -- so the list
/// stopped dead under the finger while the same gesture driven as many
/// evenly-spaced `ui-trace` events flung perfectly. The press is a
/// sample now, so even this minimum still carries a real speed.
#[test]
fn a_flick_delivered_as_one_move_frame_still_releases_with_a_velocity() {
let mut ui = UiData::default();
let id = some_id(&mut ui);
let r = render();
let mut g = DragGesture::new();
g.handle(
&r,
id,
CursorSense::PressStart(CursorButton::Left),
Vec2::ZERO,
t(0),
false,
);
g.handle(
&r,
id,
CursorSense::Pressing(CursorButton::Left),
Vec2::new(0.0, 100.0),
t(8),
false,
);
let out = g.handle(
&r,
id,
CursorSense::PressEnd(CursorButton::Left),
Vec2::new(0.0, 100.0),
t(16),
false,
);
// (100 - DRAG_SLOP) px over the 8ms between the press and the one
// move that arrived: a real measurement of what was delivered, not
// an estimate of what the finger "probably" did in between.
let expected = (100.0 - DRAG_SLOP) / 0.008;
match out {
GestureOutcome::Released(Some(v)) => {
assert!((v - expected).abs() < 1.0, "expected ~{expected}, got {v}");
}
other => panic!("expected a released pan, got {other:?}"),
}
}
/// The other half of the same join, and the case the fix had no
/// reason to touch: a press and release with no motion at all is a
/// tap, and must not acquire a velocity from the seeded press sample.
#[test]
fn a_tap_is_still_a_tap_and_flings_nothing() {
let mut ui = UiData::default();
let id = some_id(&mut ui);
let r = render();
let mut g = DragGesture::new();
g.handle(
&r,
id,
CursorSense::PressStart(CursorButton::Left),
Vec2::ZERO,
t(0),
false,
);
let out = g.handle(
&r,
id,
CursorSense::PressEnd(CursorButton::Left),
Vec2::ZERO,
t(20),
false,
);
assert_eq!(out, GestureOutcome::Tapped);
}
/// A long-press selection released while the finger was still moving
/// must not fling either -- `Released(None)`, never the tracked
/// velocity. Also untouched by the press-seeding above, which is why
/// it is checked here rather than assumed.
#[test]
fn a_selection_release_carries_no_velocity() {
let mut ui = UiData::default();
let id = some_id(&mut ui);
let r = render();
let mut g = DragGesture::new();
g.handle(
&r,
id,
CursorSense::PressStart(CursorButton::Left),
Vec2::ZERO,
t(0),
false,
);
// Held still past LONG_PRESS, which is what starts a selection.
g.handle(
&r,
id,
CursorSense::Pressing(CursorButton::Left),
Vec2::ZERO,
t(0) + LONG_PRESS,
false,
);
let out = g.handle(
&r,
id,
CursorSense::PressEnd(CursorButton::Left),
Vec2::new(0.0, 50.0),
t(0) + LONG_PRESS + Duration::from_millis(10),
false,
);
assert_eq!(out, GestureOutcome::Released(None));
}
}
+71
View File
@@ -51,6 +51,7 @@ fn cursor_at(pos: Vec2) -> CursorState {
exists: true, exists: true,
buttons: Default::default(), buttons: Default::default(),
scroll_delta: Vec2::ZERO, scroll_delta: Vec2::ZERO,
..Default::default()
} }
} }
@@ -246,3 +247,73 @@ fn capturing_one_widget_starves_every_other_widget_of_events() {
"while a's drag holds capture, b must see no hover at all" "while a's drag holds capture, b must see no hover at all"
); );
} }
/// IRIS_TODO.md's "the composer has no touch-drag scroll": `Scroll` only
/// answered a wheel, so a finger drag over overflowed text did nothing.
/// End-to-end over the real wiring -- `scrollable()`'s own registration,
/// `run_sensors`' dispatch, `Scroll::drag`, `DragGesture`'s arbitration and
/// pointer capture -- rather than only `Scroll::drag`'s own unit tests in
/// `scroll.rs`, because the registration is exactly the half those cannot
/// see.
#[test]
fn a_finger_drag_over_a_scroll_area_pans_it() {
let mut rsc = SenseRsc {
ui: UiData::default(),
events: EventManager::default(),
};
// 1000px of content in a 100px window: room to pan.
let scroll_strong = rect(UiColor::WHITE)
.height(Len::abs(1000.0))
.scrollable()
.add_strong(&mut rsc);
let scroll = scroll_strong.weak();
let root = scroll_strong.any();
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
// `Scroll` reads its content length back from the draw it just did, so
// the frame after is the first one that knows there is anything to pan
// -- the one-frame lag LAYOUT.md section 4 documents. `scroll(0.0)` is
// how `layout_tests.rs` asks for that second frame, and it also drops
// `snap_end`, leaving this parked at the start of the content.
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
render.update(&root, &mut rsc);
assert_eq!(rsc.ui.widgets.get(&scroll).unwrap().amt(), 0.0);
let mut state = ();
let mut down = cursor_at((50.0, 80.0).into());
down.buttons.left = ActivationState::Start;
render.run_sensors(&mut rsc, &mut state, down, (100.0, 100.0).into());
assert_eq!(
rsc.ui.widgets.get(&scroll).unwrap().amt(),
0.0,
"the touch-down alone must not move anything"
);
// Inside the slop: still a tap as far as anything can tell.
let mut nudge = cursor_at((50.0, 80.0 - (DRAG_SLOP - 1.0)).into());
nudge.buttons.left = ActivationState::On;
render.run_sensors(&mut rsc, &mut state, nudge, (100.0, 100.0).into());
assert_eq!(
rsc.ui.widgets.get(&scroll).unwrap().amt(),
0.0,
"a press inside DRAG_SLOP must not scroll"
);
// Past it, upward: the content follows the finger up, which for this
// widget means more `amt`.
let mut drag = cursor_at((50.0, 80.0 - (DRAG_SLOP + 40.0)).into());
drag.buttons.left = ActivationState::On;
render.run_sensors(&mut rsc, &mut state, drag, (100.0, 100.0).into());
let after = rsc.ui.widgets.get(&scroll).unwrap().amt();
assert!(
(after - 40.0).abs() < 0.01,
"expected the 40px past the slop to pan it, got {after}"
);
// And the gesture holds the pointer, so the rest of it reaches this
// widget even once the finger leaves its box.
assert_eq!(render.captured_pointer(), Some(scroll.id()));
}
+349 -11
View File
@@ -225,6 +225,21 @@ pub struct List {
/// (headless tests, a caller driving `tick_fling` by hand as /// (headless tests, a caller driving `tick_fling` by hand as
/// `bench_client.rs`'s scripted phases do). /// `bench_client.rs`'s scripted phases do).
redraw: Option<Arc<dyn RequestRedraw>>, redraw: Option<Arc<dyn RequestRedraw>>,
/// Physical pixels per `dp`, copied from the painter on every `draw`
/// -- what [`Self::fling`] hands `FlingCalculator`. 1.0 until this
/// list has been drawn once, which is also the only state in which a
/// fling is impossible (`fling` needs an anchor, and an anchor comes
/// from a draw).
///
/// It has to be the real one: the deceleration constant is
/// `GRAVITY * 39.37 * density * 160 * friction`, and the velocity fed
/// in is in the same physical pixels the touch events arrive in, so a
/// hardcoded 1.0 against a 2.75-density screen does not cancel out --
/// it makes the fling last exponentially too long. Measured on this
/// checkout's emulator, 2026-09-07, once flings could animate at all:
/// a flick that should coast for about a second ran for **45
/// seconds**.
density: f32,
/// Whether the last `draw` found no more content above the topmost /// Whether the last `draw` found no more content above the topmost
/// visible row (its top edge at or past the viewport's own top, with /// visible row (its top edge at or past the viewport's own top, with
/// no `prev_slot`) -- what `tick_fling` clamps a fling moving toward /// no `prev_slot`) -- what `tick_fling` clamps a fling moving toward
@@ -244,7 +259,16 @@ pub struct List {
struct Fling { struct Fling {
calc: FlingCalculator, calc: FlingCalculator,
velocity: f32, velocity: f32,
started_at: Instant, /// When the fling's own curve begins -- **the first `tick_fling`,
/// not the release**. It is set there rather than in `fling` so the
/// only clock this widget reads is the one its driver hands it: a
/// caller running frames on an explicit clock (`iris::harness`, and
/// `bench_client.rs`'s scripted phases) would otherwise start every
/// fling at the wall clock and advance it on a different one, and a
/// fling released at t=500ms would arrive already over. The
/// difference in a running app is at most one frame, since that is
/// how soon the fling is first ticked.
started_at: Option<Instant>,
applied: f32, applied: f32,
} }
@@ -261,6 +285,7 @@ impl List {
last_viewport_len: 0.0, last_viewport_len: 0.0,
fling: None, fling: None,
redraw: None, redraw: None,
density: 1.0,
at_start: false, at_start: false,
at_end: false, at_end: false,
pending_tap: None, pending_tap: None,
@@ -418,20 +443,48 @@ impl List {
/// has no idea a finger came back down, and Android's own `Scroller` /// has no idea a finger came back down, and Android's own `Scroller`
/// relies on the view calling `abortAnimation` for the same reason. /// relies on the view calling `abortAnimation` for the same reason.
/// ///
/// Density cancels out of the underlying spline as long as velocity /// The density handed to `FlingCalculator` is this list's own
/// and the distance it produces share one pixel space (see /// (`self.density`, taken from the painter in `draw`), not `1.0`: it
/// `FlingCalculator`'s own doc) -- `List` works entirely in logical /// does **not** cancel out of the spline -- see `FlingCalculator`'s
/// pixels, so `1.0` here is not a placeholder for "unknown density," /// doc, which used to claim the opposite, and the 45-second coast that
/// it is the correct density for a self-consistent unit system. /// claim produced.
///
/// **Sets the fling; it does not drive it.** A fling moves only while
/// something calls [`Self::tick_fling`] once per frame, and what does
/// that in a running app is `UiData::tick_animations`, over the ids
/// `UiData::animate` was given. So a caller starting a fling from a
/// gesture registers the list in the same breath:
///
/// ```ignore
/// list(ui).fling(-velocity);
/// let id = list.id();
/// ui.ui_mut().animate(id);
/// ```
///
/// Split that way because the two halves have different owners: the
/// velocity is the list's business, and whether anything animates at
/// all is the frame loop's. Missing the second call is what a finger
/// fling did on Iris's phone for two builds -- the velocity was right
/// and nothing ever advanced it, which looks exactly like a list that
/// stops dead under the finger. A caller driving frames itself
/// (`bench_client.rs`'s fling phase, the headless tests) calls
/// `tick_fling` directly instead and does not register.
pub fn fling(&mut self, velocity_px_per_s: f32) { pub fn fling(&mut self, velocity_px_per_s: f32) {
// A NaN/inf velocity (a `VelocityTracker::velocity()` divide-by-
// near-zero span, or a caller passing a raw device value straight
// through) would propagate silently into `deceleration_for`'s
// `.ln()` -- the fling either never settles or jumps to NaN
// positions with nothing on screen saying why (docs/
// REVIEW-2026-09-06.md finding 3).
debug_assert!(velocity_px_per_s.is_finite());
if velocity_px_per_s == 0.0 || self.anchor.is_none() { if velocity_px_per_s == 0.0 || self.anchor.is_none() {
self.fling = None; self.fling = None;
return; return;
} }
self.fling = Some(Fling { self.fling = Some(Fling {
calc: FlingCalculator::new(1.0), calc: FlingCalculator::new(self.density),
velocity: velocity_px_per_s, velocity: velocity_px_per_s,
started_at: Instant::now(), started_at: None,
applied: 0.0, applied: 0.0,
}); });
} }
@@ -444,6 +497,17 @@ impl List {
self.fling.is_some() self.fling.is_some()
} }
/// The velocity a fling in progress is coasting at, in this list's
/// own pixel space -- `None` when nothing is flinging. What a
/// release's decision looks like from the outside: a
/// `GestureOutcome::Released(Some(v))` is the only thing that puts a
/// value here, so a test (or a diagnostic) can read what the gesture
/// measured at the place it landed, rather than re-timing the
/// gesture itself.
pub fn fling_velocity(&self) -> Option<f32> {
self.fling.as_ref().map(|f| f.velocity)
}
/// Cancel any fling in progress with no further movement -- the next /// Cancel any fling in progress with no further movement -- the next
/// touch-down's job, per `fling`'s own doc. /// touch-down's job, per `fling`'s own doc.
pub fn cancel_fling(&mut self) { pub fn cancel_fling(&mut self) {
@@ -465,12 +529,26 @@ impl List {
let Some(f) = &mut self.fling else { let Some(f) = &mut self.fling else {
return false; return false;
}; };
let elapsed = now.saturating_duration_since(f.started_at); let elapsed = now.saturating_duration_since(*f.started_at.get_or_insert(now));
let target = f.calc.position_at(f.velocity, elapsed); let target = f.calc.position_at(f.velocity, elapsed);
let delta = target - f.applied; let delta = target - f.applied;
f.applied = target; f.applied = target;
let settled_on_schedule = elapsed >= f.calc.duration(f.velocity); let settled_on_schedule = elapsed >= f.calc.duration(f.velocity);
let velocity = f.velocity; let velocity = f.velocity;
// The evidence that the spline is actually being followed, at the
// one granularity where a linear coast and a decelerating one look
// different: successive `dy` and `speed` shrinking. It was neither
// observable nor observed while `distance_fraction` returned `t`
// (`android_fling_spline`'s doc), which is why this is here rather
// than the total-travel line the release log already carries.
log::debug!(
"iris fling tick: t={:.3}s dy={:+.1}px speed={:.0}px/s of {:.0} left={:.1}px",
elapsed.as_secs_f32(),
delta,
f.calc.velocity_at(velocity, elapsed),
velocity,
f.calc.distance(velocity) - target,
);
self.scroll(delta); self.scroll(delta);
// Clamp: a fling moving toward the start that has already reached // Clamp: a fling moving toward the start that has already reached
@@ -763,6 +841,17 @@ impl List {
/// one-frame lag `Scroll`'s own content-length cache accepts, per /// one-frame lag `Scroll`'s own content-length cache accepts, per
/// LAYOUT.md. /// LAYOUT.md.
fn place(&mut self, painter: &mut Painter, slot: isize, placement: Placement) -> (f32, f32) { fn place(&mut self, painter: &mut Painter, slot: isize, placement: Placement) -> (f32, f32) {
// Every current caller derives `slot` from `repair_anchor`/
// `prev_slot`/`next_slot`, which already check existence -- but
// that invariant is enforced by convention across three call
// sites, not by this function, which would otherwise fail with a
// bare "index out of bounds" and no context (docs/
// REVIEW-2026-09-06.md finding 2). `slot_widget`, called from
// here, is what actually indexes/`.expect`s on it.
debug_assert!(
self.slot_exists(slot),
"place() called with a slot that doesn't exist: {slot:?}"
);
let axis = self.axis; let axis = self.axis;
let output_len = painter.output_size().axis(axis); let output_len = painter.output_size().axis(axis);
let container_len = painter.region().axis(axis).len(); let container_len = painter.region().axis(axis).len();
@@ -858,8 +947,20 @@ impl List {
const GENEROUS_PADDING: f32 = 100_000.0; const GENEROUS_PADDING: f32 = 100_000.0;
impl Widget for List { impl Widget for List {
/// A `List` animates exactly one thing, a fling
/// ([`Self::tick_fling`]). The registration that makes this run is
/// `UiData::animate` beside the `fling` call -- see `fling`'s own doc.
fn tick(&mut self, now: Instant) -> bool {
self.tick_fling(now)
}
fn draw(&mut self, painter: &mut Painter) -> Size { fn draw(&mut self, painter: &mut Painter) -> Size {
let axis = self.axis; let axis = self.axis;
// Learned from the frame rather than passed in: a fling's
// deceleration is a physical quantity and needs the real display
// density, and `draw` is where this widget meets the only thing
// that knows it. See `fling`.
self.density = painter.density();
let output_len = painter.output_size().axis(axis); let output_len = painter.output_size().axis(axis);
self.viewport_len = painter.region().axis(axis).len().to_abs(output_len); self.viewport_len = painter.region().axis(axis).len().to_abs(output_len);
@@ -1086,7 +1187,7 @@ mod tests {
.push_front(ListRow::new(key, w)); .push_front(ListRow::new(key, w));
} }
render.update(&root, &mut rsc); render.update(&root, &mut rsc);
let (draws, _rewrites, _moves) = render.take_counters(); let (draws, _rewrites, _moves, _shapes) = render.take_counters();
// None of the already-visible rows (11, 12) were touched: the // None of the already-visible rows (11, 12) were touched: the
// extents for those keys are numerically unchanged, and the only // extents for those keys are numerically unchanged, and the only
@@ -1224,7 +1325,7 @@ mod tests {
rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(5.0); rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(5.0);
render.update(&root, &mut rsc); render.update(&root, &mut rsc);
let (draws, _rewrites, moves) = render.take_counters(); let (draws, _rewrites, moves, _shapes) = render.take_counters();
// The visible window is a fixed ~10 rows regardless of n; an // The visible window is a fixed ~10 rows regardless of n; an
// O(n) regression would show up as draws/moves scaling with // O(n) regression would show up as draws/moves scaling with
@@ -1287,6 +1388,52 @@ mod tests {
); );
} }
/// Neither `replacing_the_last_row_stays_pinned_to_the_bottom` nor
/// its sibling below ever asserts the *evicted* key's own bookkeeping
/// is actually gone -- both replace row 4 with another row also keyed
/// `4`, so `heights.remove(&old.key)` removing and re-inserting the
/// same key would pass either test even if it did nothing (docs/
/// REVIEW-2026-09-06.md finding 10; this is `Selection`'s finding 1
/// class of bug -- a stale handle outliving what it points to --
/// production-tested from `List`'s own side). Replacing with a
/// **different** key is what actually exercises the removal.
#[test]
fn replace_back_forgets_the_evicted_keys_own_height() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = List::new(Axis::Y);
push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0);
let (list_weak, root) = add_list(&mut rsc, list);
let mut render = UiRenderState::new();
render.resize((100.0, 60.0));
render.update(&root, &mut rsc);
assert!(
rsc.ui
.widgets
.get(&list_weak)
.unwrap()
.heights
.contains_key(&4)
);
let (_weak, new_row) = fixed_row(&mut rsc, 40.0);
let old = rsc
.ui
.widgets
.get_mut(&list_weak)
.unwrap()
.replace_back(ListRow::new(100, new_row));
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
assert_eq!(old.map(|o| o.key), Some(4));
assert!(
!list_ref.heights.contains_key(&4),
"the evicted key's cached height must not outlive the row it measured"
);
}
/// The other half of the same fix's contract: replacing a row that is /// The other half of the same fix's contract: replacing a row that is
/// *not* on screen must not move anything that is. `replace_back` only /// *not* on screen must not move anything that is. `replace_back` only
/// touches the last slot's own widget and this file's own `heights`/ /// touches the last slot's own widget and this file's own `heights`/
@@ -1403,6 +1550,69 @@ mod tests {
); );
} }
/// The doubled `Compacted:` row from Iris's phone (docs/bench/
/// iris-phone-v2-2026-09-06.md), reproduced at its mechanism.
///
/// `replacing_the_last_row_many_times_does_not_leak_primitives` above
/// counts *widgets*, which is why it passed all along: the orphan's
/// owner is very much alive -- it is an earlier set of that same
/// widget's primitives that got stranded. What strands them is a row
/// marked dirty and then reached by its **ancestor's** redraw rather
/// than by its own: `draw_inner` only *read* the dirty mark, so the
/// whole branch that frees a redrawn widget's previous primitives was
/// skipped, and the fresh `ActiveData` overwrote the only handles that
/// could ever have freed them. `List` sets no mask, so that copy then
/// draws every frame at whatever region it last had -- including,
/// where the row was being measured at `GENEROUS_PADDING`, well below
/// the list's own box and under the composer.
///
/// Two rows, two shapes of the same fault: row 2 has a cached height
/// (one `widget_within`), row 4 is replaced so it has none (`place`'s
/// `draw_twice`, which reaches `draw_inner` twice for one id in one
/// frame and so orphans a copy even with no ancestor involved).
#[test]
fn an_ancestor_redrawing_a_dirty_row_leaves_no_stale_copy() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = List::new(Axis::Y);
// Rows that own a primitive *at their own id* (a background rect),
// not only through a child: an orphan is a widget's own primitive
// outliving its own redraw, so a row whose top-level widget paints
// nothing itself cannot show one however broken the path is.
let mut rows = Vec::new();
for key in 0..5u64 {
let (bg_id, row) = background_styled_row(&mut rsc, 20.0);
rows.push((row.id(), bg_id));
list.push_back(ListRow::new(key, row));
}
let (list_weak, root) = add_list(&mut rsc, list);
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
assert!(render.orphaned_primitives().is_empty());
// A streamed row's content changing: the row is marked dirty (any
// `.set()` on it does this)...
let (row2, row2_bg) = rows[2];
rsc.ui.widgets.get_dyn_mut(row2).unwrap();
rsc.ui.widgets.get_dyn_mut(row2_bg).unwrap();
// Redraw the *list* by name, so the dirty row is reached by its
// ancestor's draw rather than by `redraw_updates` happening to
// pick it first -- which is the order `HashSet` iteration makes
// arbitrary, and the reason this went unnoticed.
render.redraw(list_weak.id(), &mut rsc);
let orphans = render.orphaned_primitives();
assert!(
orphans.is_empty(),
"{} primitive(s) survived their own widget's redraw: {orphans:?}",
orphans.len(),
);
}
/// Enough rows, tall enough, that a fling toward the start has real /// Enough rows, tall enough, that a fling toward the start has real
/// room to travel before `at_start` clamps it -- shared by the fling /// room to travel before `at_start` clamps it -- shared by the fling
/// tests below. /// tests below.
@@ -1446,6 +1656,56 @@ mod tests {
assert!(!rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling()); assert!(!rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
} }
/// The half `fling` itself does not do: a registered list is advanced
/// by the frame loop's own driver, and unregisters itself when the
/// fling settles. Written against `UiData::tick_animations` rather
/// than `tick_fling` because the defect it pins is exactly the gap
/// between the two -- a fling with a correct velocity that nothing
/// ever advanced, which is what a finger fling did on the phone.
#[test]
fn a_registered_fling_is_driven_by_tick_animations_and_then_unregisters() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
let before = rsc
.ui
.widgets
.get(&list_weak)
.unwrap()
.anchor_position_display();
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(-8000.0);
rsc.ui.animate(list_weak.id());
let start = Instant::now();
let mut animating = true;
let mut steps = 0;
while animating && steps < 600 {
animating = rsc
.ui
.tick_animations(start + std::time::Duration::from_millis(steps * 16));
render.update(&root, &mut rsc);
steps += 1;
}
assert!(!animating, "the driver never stopped within 600 frames");
assert!(steps > 1, "the fling settled without ever moving");
assert!(!rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
assert_ne!(
before,
rsc.ui
.widgets
.get(&list_weak)
.unwrap()
.anchor_position_display(),
"the list is where it started -- the fling was registered but never applied"
);
// Nothing left registered, so the next frame costs nothing: the
// path out of `animate` is the `false` answer, not a caller
// remembering to remove it.
assert!(!rsc.ui.tick_animations(start));
}
#[test] #[test]
fn fling_distance_is_positive_toward_the_end() { fn fling_distance_is_positive_toward_the_end() {
let mut rsc = TestRsc { let mut rsc = TestRsc {
@@ -1480,6 +1740,84 @@ mod tests {
} }
} }
/// `fling_moves_the_list_and_then_settles`/
/// `fling_distance_is_positive_toward_the_end` only check that a fling
/// started, moved the right way and eventually stopped -- both
/// unaffected by *how* the interior ticks split up the total travel
/// (docs/REVIEW-2026-09-06.md finding 9). A regression that made
/// `tick_fling` apply the whole spline distance every tick instead of
/// just this tick's incremental slice would still pass both, while
/// being wildly wrong every intermediate frame -- this pins the
/// per-tick delta to a decelerating curve (`FlingCalculator::
/// position_at`'s own monotonic-and-clamped property, one level
/// down, already covers the calculator alone; this is the same
/// property through `List::tick_fling`'s `scroll`/`extents`
/// accumulation).
#[test]
fn tick_fling_applies_shrinking_incremental_deltas() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
rsc.ui.widgets.get_mut(&list_weak).unwrap().jump_to_start();
render.update(&root, &mut rsc);
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(8000.0);
let start = Instant::now();
let mut prev_top = rsc.ui.widgets.get(&list_weak).unwrap().extents[&0].top;
let mut deltas = Vec::new();
for step in 1..600 {
let now = start + std::time::Duration::from_millis(step * 16);
let still = rsc.ui.widgets.get_mut(&list_weak).unwrap().tick_fling(now);
render.update(&root, &mut rsc);
let Some(top) = rsc
.ui
.widgets
.get(&list_weak)
.unwrap()
.extents
.get(&0)
.map(|e| e.top)
else {
break; // row 0 scrolled out of the loaded extents
};
deltas.push((prev_top - top).abs());
prev_top = top;
if !still {
break;
}
}
assert!(
deltas.len() >= 3,
"fling settled or left row 0's extent before collecting enough samples"
);
// Skip the first tick (the slop-transition jump the arbiter
// applies is a `List::fling`-adjacent concern, not this curve,
// but the very first frame can still carry rounding noise from
// `jump_to_start`'s own layout settling).
for w in deltas[1..].windows(2) {
assert!(
w[1] <= w[0] + 0.01,
"fling's per-tick delta grew instead of decelerating: {:?} then {:?}",
w[0],
w[1]
);
}
// Non-increasing is not deceleration: a fling that coasts at a
// constant speed and then stops dead satisfies every `<=` above,
// and that is exactly what iris shipped until 2026-09-07
// (`android_fling_spline`'s doc). Over the samples collected here
// -- the earliest part of the curve, since row 0 leaves the loaded
// extents soon after -- AOSP's spline has already lost more than
// a fifth of its speed.
let (first, last) = (deltas[1], *deltas.last().unwrap());
assert!(
last < first * 0.8,
"fling barely slowed across {} ticks: {first} -> {last}",
deltas.len()
);
}
#[test] #[test]
fn cancel_fling_stops_it_with_no_further_movement() { fn cancel_fling_stops_it_with_no_further_movement() {
let mut rsc = TestRsc { let mut rsc = TestRsc {
+8 -1
View File
@@ -15,7 +15,14 @@ impl MaxSize {
}; };
let len_px = len.apply_rest(density).to_abs(output); let len_px = len.apply_rest(density).to_abs(output);
let max_px = max.apply_rest(density).to_abs(output); let max_px = max.apply_rest(density).to_abs(output);
if len_px > max_px { max } else { len } // `fold_dp`, not the caller's `max` as written: a reported `Len`
// may not carry an unresolved `dp` -- see `Len::fold_dp` for the
// collapsed composer bar this caused.
if len_px > max_px {
max.fold_dp(density)
} else {
len
}
} }
/// The span (in this widget's own local, `UiRegion::FULL`-relative /// The span (in this widget's own local, `UiRegion::FULL`-relative
+258 -5
View File
@@ -1,4 +1,6 @@
use crate::prelude::*; use crate::prelude::*;
use crate::sense::{DragGesture, GestureOutcome};
use std::time::Instant;
pub struct Scroll { pub struct Scroll {
inner: StrongWidget, inner: StrongWidget,
@@ -7,6 +9,12 @@ pub struct Scroll {
snap_end: bool, snap_end: bool,
container_len: f32, container_len: f32,
content_len: f32, content_len: f32,
/// Touch panning, from the same `DragGesture` `List` is driven by
/// (`transcript-ui::Selection::drag`) rather than a second copy of its
/// wiring: arbitration, `DRAG_SLOP` and pointer capture all live in
/// `sense.rs` and only what a committed pan *means* is decided here.
/// See [`Self::drag`].
gesture: DragGesture,
} }
impl Widget for Scroll { impl Widget for Scroll {
@@ -25,10 +33,20 @@ impl Widget for Scroll {
// length itself (read below from what was actually drawn) is never // length itself (read below from what was actually drawn) is never
// stale, so this self-corrects the next frame and never leaves the // stale, so this self-corrects the next frame and never leaves the
// scroll range wrong for long. See LAYOUT.md section 4. // scroll range wrong for long. See LAYOUT.md section 4.
//
// Every length here is resolved against the box this widget was
// **offered** (`px_size`), never `output_size`: a `Scroll` is
// routinely smaller than the window -- the composer's field is
// capped at six lines by a `MaxSize` around it -- and measuring
// the window instead would make the pan range, and so where the
// content sits, a function of the screen rather than of the box.
// (What the previous arithmetic here computed came to the same
// number by a longer route, through a `within_len` against a
// window-relative scalar; it read as if the window were the
// container and cost a session working out that it was not.)
let axis = self.axis; let axis = self.axis;
let output_len = painter.output_size().axis(axis); let container_len = painter.px_size().axis(axis);
let container_len = painter.region().axis(axis).len(); self.container_len = container_len;
self.container_len = container_len.to_abs(output_len);
if self.snap_end { if self.snap_end {
self.amt = self.content_len - self.container_len; self.amt = self.content_len - self.container_len;
@@ -41,12 +59,22 @@ impl Widget for Scroll {
let used = painter.widget_within(&self.inner, region); let used = painter.widget_within(&self.inner, region);
// A child reporting `rel` means "this fraction of what I was
// offered", and what it was offered is this scroll area -- so the
// container, again, is what that resolves against.
self.content_len = used self.content_len = used
.axis(axis) .axis(axis)
.apply_rest(painter.density()) .apply_rest(painter.density())
.within_len(container_len) .to_abs(container_len);
.to_abs(output_len);
// The **content's** size, not the container's. A parent that can
// grow (the composer's bar) should hug the text until its own cap
// stops it, and reporting the container instead would make this
// widget's answer a function of the answer -- the bar is sized
// from what is reported here, so it collapses to nothing and
// never recovers. What keeps the content inside the offered box
// is the mask a caller puts around it (`.scrollable().masked()`),
// not this number.
used used
} }
} }
@@ -60,6 +88,60 @@ impl Scroll {
snap_end: true, snap_end: true,
container_len: 0.0, container_len: 0.0,
content_len: 0.0, content_len: 0.0,
gesture: DragGesture::on(axis),
}
}
/// Feed one frame of a touch gesture over this scroll area through.
/// Wired by `WidgetLike::scrollable`; a caller building a `Scroll` by
/// hand registers the same senses and calls this.
///
/// `id` is this widget's own id, which `DragGesture` takes pointer
/// capture on once the gesture commits -- so the rest of the drag
/// reaches here even after the finger has left this area, and, just as
/// importantly, stops reaching whatever is *inside* it. That is what
/// resolves a vertical drag over a focused text field: the field sees
/// the first few frames, iris::attr's `on_press` gives up its pending
/// selection the moment they pass `DRAG_SLOP` vertically, and this
/// takes the gesture over. Android's own `EditText` behaves the same
/// way -- a vertical drag scrolls, and only a long press selects.
///
/// No fling: unlike `List`, `Scroll` has no per-frame tick to animate
/// one with (`List::set_redraw_handle`/`tick_fling`), and the areas
/// this wraps today -- a six-line composer, a diagnostics pane -- are
/// at most a screenful, where Android does not fling either. The
/// released velocity is deliberately dropped rather than approximated.
pub fn drag(
&mut self,
render: &UiRenderState,
id: WidgetId,
sense: CursorSense,
pos_window: Vec2,
now: Instant,
) {
// `already_selected: false` -- a scroll area has no selection of
// its own to extend, so a horizontal drag stays `Undecided` and a
// vertical one past the slop pans, which is the whole contract
// here. A caller that *does* own a selection (the transcript's
// `Selection`) drives `DragGesture` itself instead.
match self
.gesture
.handle(render, id, sense, pos_window, now, false)
{
// `scroll(dy)`, not `scroll(-dy)` -- `Selection::drag` passes
// `-dy` to `List::scroll` because a `List`'s anchor offset and
// this widget's `amt` run in *opposite* directions (offset is
// where the anchored edge sits; `amt` is how far the content
// has been pulled up past the top), even though `List::scroll`'s
// own doc claims to mirror this one's convention. The rule that
// holds for both, and the one to check a sign against, is that
// the content follows the finger.
GestureOutcome::Pan(dy) => self.scroll(dy),
GestureOutcome::Undecided
| GestureOutcome::Tapped
| GestureOutcome::SelectStart
| GestureOutcome::SelectExtend
| GestureOutcome::Released(_) => {}
} }
} }
@@ -70,8 +152,179 @@ impl Scroll {
self.snap_end = self.amt == len; self.snap_end = self.amt == len;
} }
/// How far the content has been pulled past the container's leading
/// edge, in pixels -- 0 at the start of the content. Read-only, for a
/// caller that needs to observe a pan (a test, a scroll indicator).
pub fn amt(&self) -> f32 {
self.amt
}
pub fn scroll(&mut self, amt: f32) { pub fn scroll(&mut self, amt: f32) {
self.amt -= amt; self.amt -= amt;
self.update_amt(); self.update_amt();
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::sense::{CursorButton, DRAG_SLOP};
use iris_core::UiData;
use std::time::Duration;
/// A scroll area with 1000px of content in a 100px box, already
/// settled somewhere in the middle so a drag has room in both
/// directions.
fn area() -> (UiData, Scroll, WidgetId) {
let mut ui = UiData::default();
let inner = ui.widgets.add_strong(Rect::new(UiColor::WHITE)).any();
let id = inner.id();
let mut s = Scroll::new(inner, Axis::Y);
s.content_len = 1000.0;
s.container_len = 100.0;
s.amt = 400.0;
s.snap_end = false;
(ui, s, id)
}
fn press(
s: &mut Scroll,
render: &UiRenderState,
id: WidgetId,
sense: CursorSense,
y: f32,
t: Instant,
) {
s.drag(render, id, sense, Vec2::new(0.0, y), t);
}
#[test]
fn a_vertical_finger_drag_pans_the_content_with_the_finger() {
let (_ui, mut s, id) = area();
let render = UiRenderState::new();
let t = Instant::now();
press(
&mut s,
&render,
id,
CursorSense::PressStart(CursorButton::Left),
0.0,
t,
);
// Finger down by well past the slop: the content follows it down,
// which for this widget means *less* `amt`.
press(
&mut s,
&render,
id,
CursorSense::Pressing(CursorButton::Left),
DRAG_SLOP + 30.0,
t + Duration::from_millis(20),
);
assert!(
(s.amt - 370.0).abs() < 0.01,
"expected the 30px past the slop to be applied downward, got amt={}",
s.amt
);
// ...and the next frame's motion is a plain per-frame delta.
press(
&mut s,
&render,
id,
CursorSense::Pressing(CursorButton::Left),
DRAG_SLOP + 50.0,
t + Duration::from_millis(40),
);
assert!((s.amt - 350.0).abs() < 0.01, "amt={}", s.amt);
}
/// The half the change had no reason to touch: a press that never
/// leaves the slop is a tap, and must move nothing at all -- otherwise
/// every tap on a scrollable field nudges its text.
#[test]
fn a_press_that_stays_inside_the_slop_does_not_scroll() {
let (_ui, mut s, id) = area();
let render = UiRenderState::new();
let t = Instant::now();
press(
&mut s,
&render,
id,
CursorSense::PressStart(CursorButton::Left),
0.0,
t,
);
for (i, y) in [1.0, -2.0, DRAG_SLOP - 0.5].into_iter().enumerate() {
press(
&mut s,
&render,
id,
CursorSense::Pressing(CursorButton::Left),
y,
t + Duration::from_millis(10 * (i as u64 + 1)),
);
}
press(
&mut s,
&render,
id,
CursorSense::PressEnd(CursorButton::Left),
DRAG_SLOP - 0.5,
t + Duration::from_millis(50),
);
assert!(
(s.amt - 400.0).abs() < 0.01,
"a tap scrolled: amt={}",
s.amt
);
}
/// A horizontal drag is not this widget's gesture: it must stay put
/// rather than pick up the vertical noise in a sideways swipe.
#[test]
fn a_horizontal_drag_does_not_scroll() {
let (_ui, mut s, id) = area();
let render = UiRenderState::new();
let t = Instant::now();
s.drag(
&render,
id,
CursorSense::PressStart(CursorButton::Left),
Vec2::new(0.0, 0.0),
t,
);
s.drag(
&render,
id,
CursorSense::Pressing(CursorButton::Left),
Vec2::new(120.0, 3.0),
t + Duration::from_millis(20),
);
assert!((s.amt - 400.0).abs() < 0.01, "amt={}", s.amt);
}
/// Panning stops at the ends of the content rather than running off,
/// which is `update_amt`'s clamp -- checked through `drag` so the two
/// cannot drift apart.
#[test]
fn a_pan_past_the_end_clamps_instead_of_running_off() {
let (_ui, mut s, id) = area();
let render = UiRenderState::new();
let t = Instant::now();
s.drag(
&render,
id,
CursorSense::PressStart(CursorButton::Left),
Vec2::new(0.0, 0.0),
t,
);
s.drag(
&render,
id,
CursorSense::Pressing(CursorButton::Left),
Vec2::new(0.0, 5000.0),
t + Duration::from_millis(20),
);
assert!((s.amt - 0.0).abs() < 0.01, "amt={}", s.amt);
}
}
+5 -2
View File
@@ -26,9 +26,12 @@ impl Widget for Sized {
region.y = y.apply_rest(density).align(AxisAlign::Neg); region.y = y.apply_rest(density).align(AxisAlign::Neg);
} }
let used = painter.widget_within(&self.inner, region); let used = painter.widget_within(&self.inner, region);
// `fold_dp` on the way out: a declared size is a `Len` the caller
// wrote (`.width(dp(48))`), and a *reported* one may not carry an
// unresolved `dp` -- see `Len::fold_dp`.
Size { Size {
x: self.x.unwrap_or(used.x), x: self.x.map(|x| x.fold_dp(density)).unwrap_or(used.x),
y: self.y.unwrap_or(used.y), y: self.y.map(|y| y.fold_dp(density)).unwrap_or(used.y),
} }
} }
} }
+37 -6
View File
@@ -3,7 +3,13 @@ use crate::prelude::*;
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub struct Rect { pub struct Rect {
pub color: UiColor, pub color: UiColor,
pub radius: f32, /// A `Len` rather than a raw `f32` so a corner can be written in `dp`
/// and come out the same physical size on every display -- resolved
/// against `Painter::density` in [`Rect::draw`], the same place every
/// other `dp` is resolved. A plain number still works and still means
/// physical pixels (`impl<N: UiNum> From<N> for Len`), which is what
/// a hairline wants.
pub radius: Len,
pub thickness: f32, pub thickness: f32,
pub inner_radius: f32, pub inner_radius: f32,
} }
@@ -12,7 +18,7 @@ impl Rect {
pub fn new(color: UiColor) -> Self { pub fn new(color: UiColor) -> Self {
Self { Self {
color, color,
radius: 0.0, radius: Len::ZERO,
inner_radius: 0.0, inner_radius: 0.0,
thickness: 0.0, thickness: 0.0,
} }
@@ -21,8 +27,8 @@ impl Rect {
self.color = color; self.color = color;
self self
} }
pub fn radius(mut self, radius: impl UiNum) -> Self { pub fn radius(mut self, radius: impl Into<Len>) -> Self {
self.radius = radius.to_f32(); self.radius = radius.into();
self self
} }
} }
@@ -31,15 +37,40 @@ impl Widget for Rect {
fn draw(&mut self, painter: &mut Painter) -> Size { fn draw(&mut self, painter: &mut Painter) -> Size {
painter.primitive(RectPrimitive { painter.primitive(RectPrimitive {
color: self.color, color: self.color,
radius: self.radius, // `rel` has no meaning for a corner (a rect that fills its
// parent has no length of its own to take a fraction of), so
// only the `abs`/`dp` halves are folded.
radius: self.radius.fold_dp(painter.density()).abs,
thickness: self.thickness, thickness: self.thickness,
inner_radius: self.inner_radius, inner_radius: self.inner_radius,
}); });
Size::REST // fills whatever it was given -- used == available Size::REST // fills whatever it was given -- used == available
} }
/// **No** -- despite drawing one primitive and nothing else.
///
/// `is_size_independent` asks whether the widget's *content* is
/// unaffected by how big a region it was given, so that
/// `draw_inner` may keep the primitives it already has and rewrite
/// their regions in place. A `Rect`'s content **is** its region: it
/// returns `Size::REST` and fills whatever it was handed, so the fast
/// path's `r.outside(&from).within(&region)` remap has to reproduce
/// the whole of `draw` -- and it does not, because a region carries
/// `rel` and `abs` components that the round trip cannot recover
/// separately.
///
/// What that looked like: a fenced code block's background
/// (`transcript-ui`'s `BlockFrame::Verbatim`, a `Rect` behind a
/// `Pad` in a `Stack`) kept the height of the *provisional* full-
/// region draw `Span` does in its first phase, so one fence's panel
/// covered every block below it -- and every row below that -- while
/// the text itself was laid out correctly. Visible in
/// `docs/bench/p1a-2026-09-06/`'s history and reproduced by this
/// crate's `transcript` example. Answering `false` costs a redraw of
/// one primitive when a rect is resized, which is what the fast path
/// was saving.
fn is_size_independent(&self) -> bool { fn is_size_independent(&self) -> bool {
true // content never depends on region size false
} }
} }
+91 -7
View File
@@ -246,7 +246,21 @@ impl<'a> TextEditCtx<'a> {
self.clear_span(); self.clear_span();
let at = match self.text.selection { let at = match self.text.selection {
Some(sel) => sel.focus().index(), Some(sel) => sel.focus().index(),
None => return, // No caret means nowhere to put the text, so this drops the
// keystroke -- which is invisible, and was the whole of the
// "typed text never appears" defect (see `select`'s comment).
// A field the IME is talking to has been focused, and focusing
// one places a caret, so reaching here is a bug in whoever
// routed the input rather than something to recover from.
None => {
debug_assert!(
false,
"insert into a text field with no caret: '{}' was given input \
without being focused, so the keystroke would be dropped silently",
text,
);
return;
}
}; };
let at = at.min(self.text.view.buf.text().len()); let at = at.min(self.text.view.buf.text().len());
self.text.view.buf.edit().insert_str(at, text); self.text.view.buf.edit().insert_str(at, text);
@@ -350,6 +364,29 @@ impl<'a> TextEditCtx<'a> {
self.set_caret(index); self.set_caret(index);
} }
/// The byte offset in the text that `pos` (in the same window-space
/// coordinates a `CursorSense` reports, with `size` the region the
/// event was measured against) lands on.
///
/// The one thing a caller outside this module needs to turn a tap into
/// a *range* of the text -- which markdown link is under the finger,
/// which inline-code chip was pressed. `layout()` is private because a
/// caller holding a parley `Layout` could shape it against stale text;
/// this hands back the answer rather than the layout, and does the
/// same region-relative transform [`select`](Self::select) does, so
/// the two cannot disagree about where a point is.
///
/// Parley clamps a point outside the laid-out text to the nearest
/// cursor position, so a tap in the field's padding answers with the
/// nearest offset rather than failing -- a caller wanting "was this
/// actually *on* something" checks its own ranges, which is what
/// makes a tap in the padding hit no link.
pub fn byte_at(&mut self, pos: Vec2, size: Vec2) -> usize {
let pos = pos - self.text.region().top_left().to_abs(size);
let layout = self.layout();
Selection::from_point(layout, pos.x, pos.y).focus().index()
}
pub fn select_all(&mut self) { pub fn select_all(&mut self) {
let len = self.text.view.buf.text().len(); let len = self.text.view.buf.text().len();
if len == 0 { if len == 0 {
@@ -368,14 +405,28 @@ impl<'a> TextEditCtx<'a> {
// The layout borrows `self`, so the whole decision is made in here and // The layout borrows `self`, so the whole decision is made in here and
// only the answer escapes. // only the answer escapes.
//
// **A press that reaches here has already been hit-tested to this
// widget, so there is no "outside" to clear the selection for.**
// This used to compare `pos` against the *laid-out text's* box and
// set `selection = None` for anything beyond it -- but the laid-out
// text is smaller than the field (padding, and for an empty field a
// box of literally zero width), so tapping an **empty** composer
// granted focus, opened the keyboard, and left `selection` at
// `None` -- and `insert_str` returns early on `None`, so every
// keystroke after that was silently dropped and nothing ever
// appeared. That is RUST.md's P0 box item 2, "composed text never
// becomes visible at all": the buffer was empty the whole time, and
// Gboard's suggestion strip (its own composing state, not ours) is
// what made it look otherwise. Parley's `from_point`/
// `extend_to_point` already clamp a point outside the layout to the
// nearest cursor position, which is what a tap in a field's padding
// should do anyway. Losing focus is a separate path
// (`TextEditCtx::deselect`, called from the backend's focus
// handling), not this one.
let outcome = { let outcome = {
let layout = self.layout(); let layout = self.layout();
let inside = if drag {
pos.x >= 0.0 && pos.y >= 0.0 && pos.x <= layout.width() && pos.y <= layout.height();
if !inside {
if drag { None } else { Some((None, None)) }
} else if drag {
prev_sel.map(|sel| (Some(sel.extend_to_point(layout, pos.x, pos.y)), prev_hit)) prev_sel.map(|sel| (Some(sel.extend_to_point(layout, pos.x, pos.y)), prev_hit))
} else { } else {
let hit = Selection::from_point(layout, pos.x, pos.y); let hit = Selection::from_point(layout, pos.x, pos.y);
@@ -669,6 +720,39 @@ mod tests {
assert_eq!(t.selection.unwrap().focus().index(), 0); assert_eq!(t.selection.unwrap().focus().index(), 0);
} }
/// The defect itself: an empty field's laid-out text is a zero-sized
/// box, so a tap anywhere in it used to land "outside" and clear the
/// selection -- leaving a focused composer that silently swallowed
/// every keystroke (RUST.md's P0 box item 2).
#[test]
fn tapping_an_empty_field_places_a_caret_so_typing_lands() {
let (mut t, mut d) = edit("", EditMode::MultiLine);
ctx(&mut t, &mut d).select(vec2(40.0, 20.0), vec2(1080.0, 2400.0), false, false);
assert!(t.selection.is_some(), "a tap must leave a caret behind");
ctx(&mut t, &mut d).insert("hi");
assert_eq!(content(&t), "hi");
}
/// The half the fix had no reason to touch: a field that *does* hold
/// text, tapped past the end of it (a multi-line composer's padding
/// below the last line) keeps a caret rather than losing the one it
/// had, and the caret lands at the nearest position -- the end.
#[test]
fn tapping_past_the_end_of_the_text_clamps_to_the_end() {
let (mut t, mut d) = edit("abc", EditMode::MultiLine);
ctx(&mut t, &mut d).select(vec2(9000.0, 9000.0), vec2(1080.0, 2400.0), false, false);
assert_eq!(t.selection.unwrap().focus().index(), 3);
}
/// A drag still needs something to extend: with no previous selection
/// there is nothing to drag from, and one must not be invented.
#[test]
fn dragging_without_a_previous_selection_selects_nothing() {
let (mut t, mut d) = edit("abc", EditMode::MultiLine);
ctx(&mut t, &mut d).select(vec2(10.0, 10.0), vec2(1080.0, 2400.0), true, false);
assert!(t.selection.is_none());
}
#[test] #[test]
fn a_single_line_field_refuses_newlines() { fn a_single_line_field_refuses_newlines() {
let (mut t, mut d) = edit("", EditMode::SingleLine); let (mut t, mut d) = edit("", EditMode::SingleLine);
+67
View File
@@ -60,8 +60,17 @@ impl TextView {
} else { } else {
None None
}; };
// The atlas generation is part of the cache key, not a separate
// invalidation path: a `RenderedText` is only meaningful against the
// atlas its glyphs were placed in, and a renderer rebuild clears
// that atlas out from under every widget at once
// (`GlyphAtlas::clear`). Without this the text drawn before the
// rebuild is re-emitted with the old atlas's coordinates and comes
// back as fragments of whatever now occupies them.
let generation = painter.atlas_generation();
if width == self.width if width == self.width
&& let Some(tex) = &self.tex && let Some(tex) = &self.tex
&& tex.generation == generation
&& !self.attrs.changed && !self.attrs.changed
&& !self.buf.changed && !self.buf.changed
{ {
@@ -69,6 +78,12 @@ impl TextView {
} }
self.width = width; self.width = width;
let tex = painter.render_text(&mut self.buf, &self.attrs, width); let tex = painter.render_text(&mut self.buf, &self.attrs, width);
log::debug!(
"iris text render: chars={} width={width:?} glyphs={} size={:?}",
self.buf.text().chars().count(),
tex.glyphs.len(),
tex.size,
);
self.tex = Some(tex.clone()); self.tex = Some(tex.clone());
self.attrs.changed = false; self.attrs.changed = false;
self.buf.changed = false; self.buf.changed = false;
@@ -151,3 +166,55 @@ impl DerefMut for TextView {
&mut self.attrs &mut self.attrs
} }
} }
#[cfg(test)]
mod tests {
use crate::layout_tests::TestRsc;
use crate::prelude::*;
/// A renderer rebuild empties the glyph atlas under every widget at
/// once (`iris_core::GlyphAtlas::clear`, called from
/// `IrisViewPeer::surface_changed`'s new-renderer branch). Anything
/// still holding a `RenderedText` from before then owns UV rectangles
/// into a texture that no longer exists -- what Iris photographed on
/// 2026-09-06 as every pre-resume glyph coming back as fragments while
/// the text drawn after the resume was perfect.
///
/// The check is the atlas repopulating: `TextView::render`'s cache
/// short-circuits before `TextData::place`, so without the generation
/// in its key the second frame rasterises nothing and the atlas stays
/// empty. (`Painter::glyphs`'s `debug_assert!` fires here too, which is
/// the same finding from the submission side.)
#[test]
fn clearing_the_atlas_re_renders_cached_text_instead_of_reusing_it() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let root = wtext("hello there")
.size(18)
.color(UiColor::WHITE)
.add_strong(&mut rsc)
.any();
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
render.update(&root, &mut rsc);
let rasterised = rsc.ui.text.atlas.glyph_count();
assert!(rasterised > 0, "the first frame rasterised no glyphs");
// Exactly what the new-renderer branch does, in order: empty the
// atlas, then redraw everything (`resize` is what marks the tree
// for a full redraw, and a real `surface_changed` always calls it).
rsc.ui.text.atlas.clear();
assert_eq!(rsc.ui.text.atlas.glyph_count(), 0);
render.resize((800.0, 600.0));
render.update(&root, &mut rsc);
assert_eq!(
rsc.ui.text.atlas.glyph_count(),
rasterised,
"the second frame re-emitted its cached glyphs instead of \
re-rendering them against the fresh atlas"
);
}
}
+26 -3
View File
@@ -84,12 +84,35 @@ widget_trait! {
} }
fn scrollable(self) -> impl WidgetIdFn<Rsc, Scroll> where Rsc: HasEvents { fn scrollable(self) -> impl WidgetIdFn<Rsc, Scroll> where Rsc: HasEvents {
self.scrollable_on(Axis::Y)
}
// `scrollable` along `axis`. A code fence pans across its own long
// lines exactly the way a transcript pans down its rows, so the two
// are one function with the axis passed in rather than a second copy
// -- `DragArbiter::on` is the other half. (A `///` doc comment here
// is not accepted by `widget_trait!`, which parses its body itself.)
fn scrollable_on(self, axis: Axis) -> impl WidgetIdFn<Rsc, Scroll> where Rsc: HasEvents {
move |state| { move |state| {
Scroll::new(self.add_strong(state), Axis::Y) Scroll::new(self.add_strong(state), axis)
.on(CursorSense::Scroll, |ctx, rsc| { .on(CursorSense::Scroll, move |ctx, rsc| {
let delta = ctx.data.scroll_delta.y * 50.0; let delta = ctx.data.scroll_delta.axis(axis) * 50.0;
ctx.widget(rsc).scroll(delta); ctx.widget(rsc).scroll(delta);
}) })
// A finger drag, through the same `DragGesture` the
// transcript's `List` is panned by -- `Scroll::drag`'s doc
// has the arbitration and why there is no fling. The wheel
// above and this are the two inputs of one scroll, so they
// are registered together rather than left to each caller.
.on(
CursorSense::click_or_drag() | CursorSense::unclick(),
|ctx, rsc| {
let id = ctx.widget.id();
let (sense, pos) = (ctx.data.sense, ctx.data.cursor.pos);
ctx.widget(rsc)
.drag(ctx.data.render, id, sense, pos, ctx.data.cursor.time);
},
)
.add(state) .add(state)
} }
} }
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "transcript-fixture"
version.workspace = true
edition.workspace = true
# The bench fixture, opened as a real transcript screen with no server --
# docs/RUST.md's "Three test layers". It was `iris-android-app`'s
# `bench_client.rs` alone until 2026-09-07; the fixture-loading and
# fold-driving half moved here so the headless harness (layer 1), the
# phone-shaped desktop window (layer 2) and the Android bench (layer 3)
# all open the *same* screen from the same bytes, per AGENTS.md's rule
# that nothing UI-shaped lives in a platform crate.
[dependencies]
iris = { path = ".." }
transcript-ui = { path = "../transcript-ui" }
client-core = { path = "../../client-core" }
event-model = { path = "../../event-model" }
# `float_roundtrip` for the same reason `server/Cargo.toml` has it: a `ts`
# read back must be the one that was written (AGENTS.md).
serde_json = { version = "1", features = ["float_roundtrip"] }
[dev-dependencies]
winit = { workspace = true }
+73
View File
@@ -0,0 +1,73 @@
//! Layer 2 of docs/RUST.md's "Three test layers": the fixture-backed
//! transcript screen in a phone-shaped window, for looking at.
//!
//! iris/run-headless.sh phone --phone --shot /tmp/phone.png -- -p transcript-fixture
//!
//! `--phone` sets the headless sway output to the phone's own 1080x2424
//! and exports `IRIS_SCALE=2.55`, so this draws at the density Iris's
//! phone reports (`transcript_fixture::PHONE_SCALE`) rather than the
//! desktop's 1.0 -- same screen, same fixture and the same folding as
//! the Android bench and the headless tests, so what differs between a
//! screenshot here and one from the phone is the renderer, never the
//! data.
//!
//! No server: `transcript-fixture` embeds the transcript. Colour,
//! spacing, type and anything a person has to *see* is answered here;
//! anything with an assertion behind it belongs in `tests/
//! phone_screen.rs` one layer down.
use iris::prelude::*;
use winit::{dpi::PhysicalSize, window::WindowAttributes};
fn main() {
DefaultApp::<Client>::run();
}
#[derive(DefaultUiState)]
pub struct Client {
ui_state: DefaultUiState,
#[allow(dead_code)]
screen: Option<transcript_ui::TranscriptScreen>,
}
impl DefaultAppState for Client {
fn window_attributes() -> WindowAttributes {
WindowAttributes::default()
.with_title("iris transcript (bench fixture)")
.with_inner_size(PhysicalSize::new(
transcript_fixture::PHONE_WIDTH,
transcript_fixture::PHONE_HEIGHT,
))
}
fn new(
mut ui_state: DefaultUiState,
rsc: &mut DefaultRsc<Self>,
_: Proxy<Self::Event>,
) -> Self {
let screen = match transcript_fixture::open(rsc, &mut ui_state) {
Ok(opened) => {
// A fling coasts only while something asks for the next
// frame; on the desktop that is the window's own redraw
// request (`List::fling`'s doc).
let handle = rsc.tasks.redraw_handle();
(opened.screen.list)(rsc).set_redraw_handle(handle);
Some(opened.screen)
}
// On screen rather than a panic: this window exists to be
// looked at, and "the fixture stopped folding" is something
// to read, not a process that vanished (UI_RULES.md).
Err(message) => {
let text = wtext(format!("Couldn't fold the bench fixture: {message}"))
.color(Color::WHITE)
.wrap(true)
.pad(dp(16))
.add_strong(rsc)
.any();
ui_state.set_root(text);
None
}
};
Self { ui_state, screen }
}
}
+166
View File
@@ -0,0 +1,166 @@
//! The checked-in bench fixture, opened as a real transcript screen with
//! no server -- shared by every layer of docs/RUST.md's test rig.
//!
//! The bytes are `app/bench-fixture/assets/transcript.jsonl` (1,915,760
//! bytes, generated by `app/bench-fixture/generate.py`, never a real
//! transcript -- that file's own README), embedded with `include_str!`.
//! The first [`BACKLOG_COUNT`] non-blank lines are the opening window,
//! folded once through `client_core::transcript_fold::fold_page` exactly
//! as a real `/transcript` page would be; the rest are the streaming
//! tail, replayed one at a time through `fold_event` the way a live SSE
//! frame arrives.
//!
//! This half used to live in `iris-android-app`'s `bench_client.rs`, and
//! moved here on 2026-09-07 so the headless harness and a desktop window
//! open the same screen from the same bytes (AGENTS.md: nothing
//! UI-shaped in a platform crate). What stayed there is the JNI half --
//! the clipboard, the battery sampler, the IME calls and the report.
use client_core::transcript_fold::{TranscriptItem, TranscriptRow, fold_page, group_tool_runs};
use event_model::SeqEvent;
use iris::prelude::*;
/// bench-fixture/README.md: the first `BACKLOG_COUNT` non-blank lines are
/// the opening window; the rest are the streaming tail. Kept in sync with
/// `BenchFixture.kt`'s identical constant by hand -- both read the same
/// checked-in file, so a mismatch would only mean the two apps' bench
/// builds open a different split of it, not a wrong-vs-right answer.
pub const BACKLOG_COUNT: usize = 3200;
const FIXTURE_JSONL: &str = include_str!("../../../app/bench-fixture/assets/transcript.jsonl");
/// Iris's phone as `docs/bench/iris-phone-v2-2026-09-06.md` and
/// `docs/IRIS_TODO.md` record it: a 1080x2424 surface at
/// `content_scale: 2.55`, 120Hz. Read from those reports, never typed
/// from memory -- every layer of the rig lays out at this size and
/// density so a screenshot and a headless assertion are about the same
/// screen.
pub const PHONE_WIDTH: f32 = 1080.0;
pub const PHONE_HEIGHT: f32 = 2424.0;
pub const PHONE_SCALE: f32 = 2.55;
/// 120Hz, the refresh rate that report ran at: 8.3ms a frame.
pub const PHONE_FRAME_MS: u64 = 8;
pub fn phone_size() -> Vec2 {
Vec2::new(PHONE_WIDTH, PHONE_HEIGHT)
}
/// The fixture split the way the wire delivers it: raw JSON values for
/// the opening page (`fold_page` takes a page of wire JSON, same as a
/// real `/transcript` response) and parsed `SeqEvent`s for the tail
/// (`fold_event` takes one live event at a time, same as an SSE frame).
pub struct Fixture {
pub backlog: Vec<serde_json::Value>,
pub stream_tail: Vec<SeqEvent>,
}
impl Fixture {
/// Parses the whole fixture. Panics on malformed input: this is a
/// generated file compiled into the binary, so a parse failure is a
/// broken build rather than a condition a caller could recover from
/// (CODE_RULES: separate recoverable conditions from programmer
/// error).
pub fn parse() -> Self {
let mut backlog = Vec::with_capacity(BACKLOG_COUNT);
let mut stream_tail = Vec::new();
for (i, line) in FIXTURE_JSONL
.lines()
.filter(|line| !line.trim().is_empty())
.enumerate()
{
let value: serde_json::Value =
serde_json::from_str(line).expect("bench fixture is generated JSON, always valid");
if i < BACKLOG_COUNT {
backlog.push(value);
} else {
stream_tail.push(
serde_json::from_value(value)
.expect("bench fixture event matches event-model's SeqEvent"),
);
}
}
Self {
backlog,
stream_tail,
}
}
/// The opening page folded into transcript items -- the same
/// `fold_page` a real first load runs. `Err` carries the fold's own
/// message, which a caller shows on screen rather than panicking, so
/// a fixture that stops folding is visible in the app instead of
/// being a crash on launch.
pub fn backlog_items(&self) -> Result<Vec<TranscriptItem>, String> {
fold_page(&self.backlog)
}
}
/// The fixture's opening page as the rows a screen is built from.
pub fn rows(items: &[TranscriptItem]) -> Vec<TranscriptRow> {
group_tool_runs(items)
}
/// Everything a caller needs to run the fixture as an app screen would:
/// the screen, the folded items behind it, and the events not yet
/// streamed. The tree itself comes back separately from
/// [`build_screen`], since whoever takes it owns it.
pub struct Opened {
pub screen: transcript_ui::TranscriptScreen,
pub items: Vec<TranscriptItem>,
/// The tail, for a caller that goes on replaying it one event at a
/// time through `fold_event`/`TranscriptScreen::apply` -- the
/// streaming phase of either app's benchmark.
pub stream_tail: Vec<SeqEvent>,
}
/// Build the transcript screen over the fixture's opening page, without
/// claiming the window's root -- `transcript_ui::build_tree`'s own split,
/// for a caller (the Android bench) that puts the screen inside a shell
/// of its own.
pub fn build_screen<Rsc: HasEvents>(rsc: &mut Rsc) -> Result<(Opened, StrongWidget), String>
where
Rsc::State: FocusHost + OpenUrl,
{
let fixture = Fixture::parse();
let items = fixture.backlog_items()?;
let (screen, tree) = transcript_ui::build_tree(rsc, rows(&items));
Ok((
Opened {
screen,
items,
stream_tail: fixture.stream_tail,
},
tree,
))
}
/// [`build_screen`] with the screen as the window's root -- what the
/// headless harness and the desktop window open.
pub fn open<Rsc: HasEvents>(rsc: &mut Rsc, ui_state: &mut impl HasRoot) -> Result<Opened, String>
where
Rsc::State: FocusHost + OpenUrl,
{
let (opened, tree) = build_screen(rsc)?;
ui_state.set_root(tree);
Ok(opened)
}
#[cfg(test)]
mod tests {
use super::*;
/// The split is what both bench clients assume; a fixture that
/// stopped having a streaming tail would make the Android bench's
/// stream phase silently measure nothing.
#[test]
fn the_fixture_has_a_backlog_and_a_streaming_tail() {
let fixture = Fixture::parse();
assert_eq!(fixture.backlog.len(), BACKLOG_COUNT);
assert!(
fixture.stream_tail.len() >= 400,
"the stream phase replays 400 events; the fixture has {}",
fixture.stream_tail.len()
);
assert!(!fixture.backlog_items().expect("the page folds").is_empty());
}
}
@@ -0,0 +1,175 @@
//! Layer 1 of docs/RUST.md's "Three test layers": the real transcript
//! screen, over the real bench fixture, at the phone's size and density,
//! driven by `iris::harness` with no window, no compositor and no GPU.
//!
//! Every gesture here is a file under `touch/` -- see
//! `flick-120hz.touch` for why the *shape* of the delivery is the whole
//! point, and why the emulator cannot produce it (a `ui-trace` swipe is
//! many evenly-spaced events; a finger at 120Hz is five samples in
//! 20ms).
use iris::harness::{Harness, TouchScript};
use iris::prelude::*;
use transcript_fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
/// The screen open on the fixture, framed twice: once to draw, once for
/// `List::repair_anchor` to resolve the opening `snap_end` into a real
/// anchor, which is what every assertion about scroll position reads.
fn opened() -> (Harness, transcript_ui::TranscriptScreen) {
let mut h = Harness::new(phone_size(), PHONE_SCALE);
let opened = transcript_fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
h.frame(0);
h.frame(PHONE_FRAME_MS);
(h, opened.screen)
}
fn script(name: &str, text: &str) -> TouchScript {
TouchScript::parse(text).unwrap_or_else(|e| panic!("{name}: {e}"))
}
fn offset(h: &mut Harness, screen: &transcript_ui::TranscriptScreen) -> String {
(screen.list)(&mut h.rsc).anchor_position_display()
}
/// (a) and (b) together, because the second is only meaningful if the
/// first happened: the recorded flick must release with a real velocity
/// (`GestureOutcome::Released(Some(v))`, which is the only thing that
/// puts a value in `List::fling_velocity`), and the list must then
/// actually travel and stop on the spline's own schedule.
#[test]
fn a_recorded_flick_releases_with_a_velocity_and_flings_the_list() {
let (mut h, screen) = opened();
let before = offset(&mut h, &screen);
let flick = script("flick-120hz", include_str!("../touch/flick-120hz.touch"));
h.replay(&flick);
let velocity = (screen.list)(&mut h.rsc)
.fling_velocity()
.expect("the flick must release as a pan with a velocity, not a tap");
assert!(
velocity.abs() > 1_000.0,
"a 188px, 16ms flick is thousands of px/s; got {velocity}"
);
// Android's own spline says how long a fling at this speed runs. The
// list learns its density from the painter, so this is the same
// curve it is using.
let expected = FlingCalculator::new(PHONE_SCALE).duration(velocity);
let end = flick.end_ms() + expected.as_millis() as u64 * 2;
let mut settled_at = None;
let mut t = flick.end_ms();
while t <= end {
h.frame(t);
if settled_at.is_none() && !(screen.list)(&mut h.rsc).is_scrolling() {
settled_at = Some(t);
}
t += PHONE_FRAME_MS;
}
let after = offset(&mut h, &screen);
assert_ne!(
before, after,
"the fling ticks must have moved the list off where the flick left it"
);
let settled_at = settled_at.expect("the fling must stop on its own, not run forever");
let ran_for = settled_at - flick.end_ms();
assert!(
ran_for <= expected.as_millis() as u64 + PHONE_FRAME_MS * 2,
"the fling ran {ran_for}ms against the spline's own {}ms",
expected.as_millis()
);
}
/// The half the flick fix had no reason to touch: a tap must decide
/// `Tapped`, which means no velocity anywhere and nothing moved.
#[test]
fn a_tap_on_a_row_moves_nothing() {
let (mut h, screen) = opened();
let before = offset(&mut h, &screen);
h.replay(&script("tap", include_str!("../touch/tap.touch")));
assert_eq!(
(screen.list)(&mut h.rsc).fling_velocity(),
None,
"a tap must not fling"
);
// Frames it would have moved in, had anything been moving.
h.frames_until(100, 400, PHONE_FRAME_MS);
assert_eq!(before, offset(&mut h, &screen), "a tap must scroll nothing");
assert_eq!(
h.state.opened_urls,
Vec::<String>::new(),
"no link was under this tap"
);
}
/// A press held past `LONG_PRESS` and then dragged selects text rather
/// than panning -- the other branch of the same arbiter the flick goes
/// through.
#[test]
fn a_long_press_and_drag_selects_text() {
let (mut h, screen) = opened();
let before = offset(&mut h, &screen);
h.replay(&script(
"long-press",
include_str!("../touch/long-press.touch"),
));
let selected = screen
.selected_text(&mut h.rsc)
.expect("a long-press then drag must leave text selected");
assert!(
!selected.trim().is_empty(),
"the selection covered no characters: {selected:?}"
);
assert_eq!(
before,
offset(&mut h, &screen),
"a selection must not also pan the list"
);
}
/// The composer sits on whatever the platform says the bottom of usable
/// space is -- the keyboard's inset while it is open
/// (`Composer::set_bottom_inset`, the path Android's
/// `on_insets_changed` feeds). Checked here rather than on the emulator
/// because it is a layout fact, and the emulator costs minutes.
#[test]
fn the_composer_sits_above_a_simulated_ime_inset() {
let (mut h, screen) = opened();
let height = h.size().y;
let field_bottom = |h: &mut Harness| {
h.render
.window_region(&screen.composer.field, &h.rsc)
.expect("the composer field is on screen")
.bot_right
.y
};
let closed = field_bottom(&mut h);
assert!(
closed <= height,
"the composer is off the bottom of the window even with no keyboard: {closed} > {height}"
);
// A Gboard-sized keyboard on this surface. Any real number would do;
// what matters is that the bar clears it.
let ime = 1000.0;
screen.composer.set_bottom_inset(&mut h.rsc, ime);
h.frame(PHONE_FRAME_MS * 2);
let open = field_bottom(&mut h);
assert!(
open <= height - ime,
"the keyboard covers the composer: its bottom is at {open}, the IME starts at {}",
height - ime
);
assert!(
(closed - open - ime).abs() < 1.0,
"the composer moved {} for a {ime}px inset",
closed - open
);
}
@@ -0,0 +1,21 @@
# A finger flick the shape Iris's phone delivers one, from
# docs/bench/iris-phone-v2-2026-09-06.md and docs/IRIS_TODO.md's
# "From the phone, 2026-09-06, 22:16": at 120Hz a flick reaches the app
# as DOWN, one or two MOVEs and UP inside a few frames, with the
# intermediate positions batched inside those MOVEs as historical
# samples (~4ms apart, the touch digitiser's own rate) rather than
# arriving as separate events. Each line here is one such sample, which
# is exactly what `IrisViewPeer::on_touch_event` replays through the
# sensors one at a time -- so the whole gesture is 20ms and five
# samples, and the velocity has to come out of *those*.
#
# Downward (increasing y) on purpose: the screen opens pinned to the
# newest end, so a flick the other way has nothing left to scroll to and
# the fling clamps on its first tick -- a pass that would prove nothing.
# Coordinates are physical pixels on a 1080x2424 surface.
0 down 540 1000
4 move 540 1040
8 move 540 1086
12 move 540 1138
16 move 540 1196
20 up 540 1196
@@ -0,0 +1,11 @@
# A long-press then a drag across the text: held past LONG_PRESS
# (500ms) without moving, which is what starts a selection rather than a
# pan, then dragged sideways so the selection actually covers
# something. A press alone leaves a collapsed caret and no selected
# text (`Selection::begin`), which is why this file does not stop at the
# hold.
0 down 300 1000
520 move 300 1000
560 move 700 1000
600 move 900 1000
640 up 900 1000
+5
View File
@@ -0,0 +1,5 @@
# The case the flick had no reason to touch: a press and release in one
# place, well inside DRAG_SLOP and well under LONG_PRESS. It must be a
# tap -- no pan, no velocity, nothing moved.
0 down 540 1000
80 up 540 1000
+188 -40
View File
@@ -13,7 +13,8 @@
//! with `ui-trace record --do "tap 'Tools'"` on Android, to prove //! with `ui-trace record --do "tap 'Tools'"` on Android, to prove
//! hold-the-edge expand). //! hold-the-edge expand).
use client_core::transcript_fold::{TranscriptItem, TranscriptRow as FoldedRow}; use client_core::QuestionOption;
use client_core::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
use iris::prelude::*; use iris::prelude::*;
fn main() { fn main() {
@@ -43,6 +44,75 @@ fn msg(seq: u64, from_user: bool, text: &str) -> FoldedRow {
}) })
} }
/// One tool call. `result` is `None` for a call with no result yet and
/// `Some((output, failed))` for one that answered.
fn tool_call(id: &str, tool: &str, input: &str, result: Option<(&str, bool)>) -> TranscriptItem {
tool_call_in("run1", id, tool, input, result)
}
/// The same, in a named run. Two runs in one transcript must not share a
/// `run_id`: it is the row's identity in the list (`row::row_key`), and
/// two rows under one key is the duplicate-key fault AGENTS.md's
/// "Importing" section describes. Here it made two rows swap cached
/// heights and draw at each other's boxes.
fn tool_call_in(
run: &str,
id: &str,
tool: &str,
input: &str,
result: Option<(&str, bool)>,
) -> TranscriptItem {
TranscriptItem::ToolRun {
seq: 3,
id: id.into(),
run_id: run.into(),
tool: tool.into(),
input: input.into(),
output: result.map(|(out, _)| out.to_string()).unwrap_or_default(),
done: result.is_some(),
failed: result.is_some_and(|(_, failed)| failed),
asks: Vec::new(),
images: Vec::new(),
}
}
/// A call stopped on the reader: one unanswered permission question.
fn asking(id: &str, tool: &str, input: &str) -> TranscriptItem {
let mut call = tool_call_in("run2", id, tool, input, None);
if let TranscriptItem::ToolRun { asks, .. } = &mut call {
asks.push(QuestionCard {
seq: 9,
id: format!("{id}-q"),
prompt: "Allow this command?".into(),
header: None,
options: vec![
QuestionOption {
label: "Allow".into(),
description: None,
preview: None,
},
QuestionOption {
label: "Deny".into(),
description: None,
preview: None,
},
],
multi_select: false,
answers: Vec::new(),
});
}
call
}
/// Longer than the card's own cap, so the "Show all N lines" control is on
/// screen in the expanded shot.
fn long_output() -> String {
(0..200)
.map(|i| format!("test transcript_ui::case_{i} ... ok"))
.collect::<Vec<_>>()
.join("\n")
}
fn synthetic_rows() -> Vec<FoldedRow> { fn synthetic_rows() -> Vec<FoldedRow> {
vec![ vec![
msg( msg(
@@ -55,50 +125,82 @@ fn synthetic_rows() -> Vec<FoldedRow> {
false, false,
"# Sure\n\nHere's a [link to the repo](https://example.com/ai-app-2) and a fenced block:\n\n```rust\nfn main() {\n println!(\"hi\");\n}\n```", "# Sure\n\nHere's a [link to the repo](https://example.com/ai-app-2) and a fenced block:\n\n```rust\nfn main() {\n println!(\"hi\");\n}\n```",
), ),
// Every state a tool card has to draw, in one run (P1b): a call
// that worked, one the tool reported as failed, one whose result
// never arrived, and one still running. The last two look the same
// in the events -- an empty output and `done: false` -- and are
// told apart only by whether the session is still working, which
// is what `TranscriptScreen::set_session_working` says.
FoldedRow::Tools(vec![ FoldedRow::Tools(vec![
TranscriptItem::ToolRun { tool_call(
seq: 3, "t1",
id: "t1".into(), "Read",
run_id: "run1".into(), r#"{"file_path": "src/main.rs"}"#,
tool: "Read".into(), Some(("fn main() {}\n", false)),
input: "{\"file\": \"src/main.rs\"}".into(),
output: "fn main() {}\n".into(),
done: true,
asks: Vec::new(),
images: Vec::new(),
},
TranscriptItem::ToolRun {
seq: 4,
id: "t2".into(),
run_id: "run1".into(),
tool: "Edit".into(),
input: "{\"file\": \"src/main.rs\"}".into(),
output: "ok".into(),
done: true,
asks: Vec::new(),
images: Vec::new(),
},
TranscriptItem::ToolRun {
seq: 5,
id: "t3".into(),
run_id: "run1".into(),
tool: "Bash".into(),
input: "cargo build".into(),
output: "Compiling...\nFinished.".into(),
done: true,
asks: Vec::new(),
images: Vec::new(),
},
]),
msg(6, true, "Looks good, thanks!"),
msg(
7,
false,
"You're welcome. Let me know if you'd like anything else.",
), ),
tool_call(
"t2",
"Bash",
r#"{"command": "cargo build --release", "timeout": 480000, "description": "Build it"}"#,
Some((
"error: could not compile `iris`\nCaused by: linker not found",
true,
)),
),
tool_call("t3", "Grep", r#"{"pattern": "fn fold_event"}"#, None),
]),
// A lone call is a card too rather than a group of one -- and this
// one carries the kilobyte output a collapsed card must not lay
// out.
FoldedRow::Single(tool_call(
"t5",
"Bash",
r#"{"command": "cargo test -p transcript-ui -- --nocapture"}"#,
Some((&long_output(), false)),
)),
msg(6, true, "Looks good, thanks!"),
// Every block kind `client_core::markdown_blocks` names, in one
// row, so P1a's appearance can be looked at against the Compose
// app's without a server (docs/RUST.md's P1a box). The heading,
// paragraph, fence and table are the *same source* the bench
// fixture carries (`app/bench-fixture/generate.py`), so the two
// screenshots differ only in the renderer; the list and the quote
// are extra, because the fixture has neither.
msg(7, false, BLOCK_SAMPLER),
] ]
} }
/// One of each markdown block, for the P1a screenshot pair. See
/// [`synthetic_rows`].
const BLOCK_SAMPLER: &str = "\
## What changed
Iris **fold** render measure session window anchor context transcript \
iris measure iris scroll call transcript layout *cursor* context, and a \
[bench](https://example.com/bench) link.
```rust
fn fold_event(items: Vec<Item>, seq: u64) -> Vec<Item> {
// a comment worth keeping: this is the fold the app's own screen runs
let mut out = items;
out.push(Item::new(seq));
out
}
```
| column | value |
|---|---|
| a | measure place draw tool call token context window anchor |
- one bullet
- another, with `inline code`
- nested one level
1. first numbered
2. second numbered
> A quoted line, to show the bar and the indent.
";
impl DefaultAppState for Client { impl DefaultAppState for Client {
fn new( fn new(
mut ui_state: DefaultUiState, mut ui_state: DefaultUiState,
@@ -117,6 +219,52 @@ impl DefaultAppState for Client {
text: "clear".into(), text: "clear".into(),
}), }),
); );
// A second run at the live end, so the *running* state is on
// screen too. It cannot share a row with "no result": the two are
// the same events and are told apart only by whether the session
// is working, which is a property of the row rather than of the
// call (`TranscriptScreen::set_session_working`).
screen.push_row(
rsc,
&FoldedRow::Tools(vec![
tool_call_in(
"run2",
"t6",
"Read",
r#"{"file_path": "docs/RUST.md"}"#,
Some(("# Moving the app to Rust\n", false)),
),
tool_call_in(
"run2",
"t7",
"Bash",
r#"{"command": "cargo clippy --workspace --all-targets"}"#,
Some(("error: unused variable `x`", true)),
),
tool_call_in("run2", "t8", "Glob", r#"{"pattern": "**/*.rs"}"#, None),
// Waiting on a permission, so this card is drawn *open*
// whatever the reader last chose -- the command is the
// thing being decided, and a row saying only "Bash"
// cannot be decided on. It is also how the expanded card
// (input block, output block, timeout) gets into the
// screenshot without a finger.
asking(
"t9",
"Bash",
r#"{"command": "rm -rf target", "timeout": 120000, "description": "Clear the build"}"#,
),
]),
);
screen.set_session_working(rsc, true);
// The expanded picture has no other way to be looked at on a
// machine with no display and no finger -- see `run-headless.sh`
// and docs/RUST.md's P1b box.
if std::env::var_os("IRIS_TOOLS_EXPANDED").is_some() {
assert!(
screen.expand_tail_tools(rsc, true),
"the newest row must be the tool run this flag is about"
);
}
Self { ui_state, screen } Self { ui_state, screen }
} }
} }
+12 -1
View File
@@ -88,10 +88,21 @@ where
// height-capped field -- not a background rect and a field drawn as // height-capped field -- not a background rect and a field drawn as
// two independent siblings, which is what let the two disagree on // two independent siblings, which is what let the two disagree on
// where the bar actually was. // where the bar actually was.
// `.scrollable().masked()`: the finger pan (`Scroll::drag`) plus the
// clip that keeps six lines' worth of a longer message inside the
// bar. The mask is the caller's job rather than `Scroll`'s own,
// because `Painter::set_mask` allows exactly one mask per widget and
// a `Scroll` nested under another masked area would abort on the
// second -- `.masked()` is the one mechanism for clipping and this is
// one more use of it (tabs-ui's message area is the other).
// Without it the overflow paints *above* the bar, over the
// transcript: measured before this change at 58px of stray text for a
// 475px message in a 417px box.
let content = field let content = field
.scrollable()
.masked()
.pad(dp(FIELD_PAD_DP)) .pad(dp(FIELD_PAD_DP))
.max_height(dp(APPROX_LINE_HEIGHT_DP * MAX_LINES + FIELD_PAD_DP * 2.0)) .max_height(dp(APPROX_LINE_HEIGHT_DP * MAX_LINES + FIELD_PAD_DP * 2.0))
.scrollable()
.width(rest(1)) .width(rest(1))
.background(rect(UiColor::new(40, 40, 46, 255))) .background(rect(UiColor::new(40, 40, 46, 255)))
.add(rsc); .add(rsc);
+712 -26
View File
@@ -1,6 +1,6 @@
//! The transcript screen, in iris -- RUST.md's I5. Built the same way //! The transcript screen, in iris -- RUST.md's I5. Built the same way
//! `tabs-ui` is: its own crate, generic over `Rsc: HasEvents` + //! `tabs-ui` is: its own crate, generic over `Rsc: HasEvents` +
//! `Rsc::State: FocusHost`, so the winit example (`iris/examples/ //! `Rsc::State: FocusHost + OpenUrl`, so the winit example (`iris/examples/
//! transcript.rs`) and an eventual `iris-android-app`-style cdylib call the //! transcript.rs`) and an eventual `iris-android-app`-style cdylib call the
//! same [`build`]. See RUST.md's I5 box for the full account of what is //! same [`build`]. See RUST.md's I5 box for the full account of what is
//! and is not proved yet, and this doc for the shape. //! and is not proved yet, and this doc for the shape.
@@ -47,11 +47,12 @@ pub mod composer;
pub mod markdown; pub mod markdown;
pub mod row; pub mod row;
pub mod selection; pub mod selection;
pub mod tool;
use client_core::transcript_fold::TranscriptRow as FoldedRow; use client_core::transcript_fold::TranscriptRow as FoldedRow;
use iris::prelude::*; use iris::prelude::*;
use selection::Selection; use selection::Selection;
use std::{cell::RefCell, rc::Rc, time::Instant}; use std::{cell::RefCell, rc::Rc};
pub struct TranscriptScreen { pub struct TranscriptScreen {
/// The transcript's own `List` -- exposed so a caller can read /// The transcript's own `List` -- exposed so a caller can read
@@ -66,6 +67,17 @@ pub struct TranscriptScreen {
/// interior mutability, per `push_row`'s existing `&self`). Drained by /// interior mutability, per `push_row`'s existing `&self`). Drained by
/// [`Self::take_rebuilds`]. /// [`Self::take_rebuilds`].
rebuilds: std::cell::Cell<usize>, rebuilds: std::cell::Cell<usize>,
/// What the row at the live end of the list kept so the next event
/// can change part of it rather than all of it -- one markdown block
/// of a streaming message (`row::RowBlocks::apply_delta`), or one card
/// of a tool run whose result just arrived (`tool::ToolRow::
/// apply_calls`). `None` before anything has been pushed. Its removal
/// is every path that replaces or drops the tail row, below.
tail: RefCell<Option<(RowKey, row::TailRow)>>,
/// Whether the session is still working -- see
/// [`Self::set_session_working`], which is the only thing that writes
/// it. `Cell`, like `rebuilds`, so every method here stays `&self`.
session_working: std::cell::Cell<bool>,
} }
impl TranscriptScreen { impl TranscriptScreen {
@@ -75,10 +87,119 @@ impl TranscriptScreen {
/// newest content when it already was (I3). /// newest content when it already was (I3).
pub fn push_row<Rsc: HasEvents>(&self, rsc: &mut Rsc, row: &FoldedRow) pub fn push_row<Rsc: HasEvents>(&self, rsc: &mut Rsc, row: &FoldedRow)
where where
Rsc::State: FocusHost, Rsc::State: FocusHost + OpenUrl,
{ {
let (key, widget) = row::build_row(rsc, self.list, self.selection.clone(), row); let (key, widget, tail) = row::build_row(
rsc,
self.list,
self.selection.clone(),
row,
self.session_working.get(),
);
(self.list)(rsc).push_back(ListRow::new(key, widget)); (self.list)(rsc).push_back(ListRow::new(key, widget));
*self.tail.borrow_mut() = tail.map(|t| (key, t));
}
/// Whether the session this transcript belongs to is still doing
/// something (`client_core::transcript_fold::session_working`).
///
/// The one thing a tool card cannot read off its own call: a call with
/// no result is *running* while the session works and *never came
/// back* once it stops, and those are different things to tell a
/// reader. Only the newest row is affected -- every row behind it
/// belongs to a turn that has already ended -- so changing it re-draws
/// that row and nothing else.
pub fn set_session_working<Rsc: HasEvents>(&self, rsc: &mut Rsc, working: bool)
where
Rsc::State: FocusHost + OpenUrl,
{
if self.session_working.replace(working) == working {
return;
}
let mut tail = self.tail.borrow_mut();
if let Some((_, row::TailRow::Tools(tools))) = tail.as_mut() {
let calls = tools.calls();
tools.apply_calls(rsc, &calls, working);
}
}
/// How many tool cards the newest row is drawing, `0` when it is not a
/// tool row or its group is closed. Only the tests read it; nothing on
/// screen is decided by it.
#[cfg(test)]
fn tail_card_count(&self) -> usize {
match self.tail.borrow().as_ref() {
Some((_, row::TailRow::Tools(tools))) => tools.card_count(),
_ => 0,
}
}
/// Open or close the newest row's tool run, when it is one -- what a
/// caller with no finger needs (`run-headless.sh`'s screenshot on this
/// displayless machine, and the tests below). Answers whether there
/// was such a row to act on, so a caller that expected one can say so
/// rather than silently producing the collapsed picture.
pub fn expand_tail_tools<Rsc: HasEvents>(&self, rsc: &mut Rsc, expanded: bool) -> bool
where
Rsc::State: FocusHost + OpenUrl,
{
let tail = self.tail.borrow();
let Some((_, row::TailRow::Tools(tools))) = tail.as_ref() else {
return false;
};
tools.set_group_expanded(rsc, expanded);
true
}
/// The `ReplaceLast` fast path: update the tail row in place if this
/// really is a change to the same row, and say whether that worked.
/// `false` for anything the caller must rebuild instead.
///
/// Two kinds of row have such a path and they are asked the same
/// question: a message's blocks take a delta into the last block, and
/// a tool row's cards take an arriving result on one card. Which one
/// this is comes from what the row kept, not from a second decision
/// here.
fn apply_tail_delta<Rsc: HasEvents>(&self, rsc: &mut Rsc, key: RowKey, row: &FoldedRow) -> bool
where
Rsc::State: FocusHost + OpenUrl,
{
let mut tail = self.tail.borrow_mut();
let Some((tail_key, kept)) = tail.as_mut() else {
return false;
};
if *tail_key != key {
return false;
}
match (kept, row) {
(row::TailRow::Blocks(blocks), FoldedRow::Single(item)) => {
let (sender, markdown_src) = row::item_content(item);
// A tool call is drawn as a card, never as markdown, so a
// row that kept blocks and now holds one is a different
// row -- rebuild it.
if matches!(
item,
client_core::transcript_fold::TranscriptItem::ToolRun { .. }
) {
return false;
}
blocks.apply_delta(
rsc,
self.list,
self.selection.clone(),
key,
sender,
&markdown_src,
)
}
(row::TailRow::Tools(tools), FoldedRow::Tools(calls)) => {
tools.apply_calls(rsc, calls, self.session_working.get())
}
(row::TailRow::Tools(tools), FoldedRow::Single(item)) => {
tools.apply_calls(rsc, std::slice::from_ref(item), self.session_working.get())
}
(row::TailRow::Blocks(_), FoldedRow::Tools(_)) => false,
}
} }
/// Apply the effect of one more folded event without rebuilding the /// Apply the effect of one more folded event without rebuilding the
@@ -117,7 +238,7 @@ impl TranscriptScreen {
old: &[client_core::transcript_fold::TranscriptItem], old: &[client_core::transcript_fold::TranscriptItem],
new: &[client_core::transcript_fold::TranscriptItem], new: &[client_core::transcript_fold::TranscriptItem],
) where ) where
Rsc::State: FocusHost, Rsc::State: FocusHost + OpenUrl,
{ {
use client_core::transcript_fold::group_tool_runs; use client_core::transcript_fold::group_tool_runs;
@@ -134,26 +255,57 @@ impl TranscriptScreen {
} }
} }
RowDiff::ReplaceLast { common } => { RowDiff::ReplaceLast { common } => {
// Only the tail row's content changed -- rebuild that one // Only the tail row's content changed. First try the
// row and swap it in place, keeping every row before it // delta path: the row is a column of one widget per
// untouched. // markdown block, so a delta that lands in the last block
// is one `set_with_spans` and the earlier blocks keep
// their layouts (`row::RowBlocks::apply_delta`, and
// docs/DECISIONS.md for why the row is shaped that way).
let old_key = row::row_key(&old_rows[common].key()); let old_key = row::row_key(&old_rows[common].key());
let (new_key, widget) = let new_key = row::row_key(&new_rows[common].key());
row::build_row(rsc, self.list, self.selection.clone(), &new_rows[common]); if new_key == old_key && self.apply_tail_delta(rsc, new_key, &new_rows[common]) {
if new_key != old_key { for row in &new_rows[common + 1..] {
self.selection.borrow_mut().unregister(old_key); self.push_row(rsc, row);
} }
return;
}
// Otherwise rebuild that one row and swap it in place,
// keeping every row before it untouched. `unregister`
// unconditionally, not only when the key changed: a
// rebuild with *fewer* blocks under the same key would
// otherwise leave the extra blocks in `Selection`
// pointing at widgets the `drop` below frees (the shape
// docs/REVIEW-2026-09-06.md's finding 1 called out).
self.selection.borrow_mut().unregister(old_key);
let (new_key, widget, kept) = row::build_row(
rsc,
self.list,
self.selection.clone(),
&new_rows[common],
self.session_working.get(),
);
let evicted = (self.list)(rsc).replace_back(ListRow::new(new_key, widget)); let evicted = (self.list)(rsc).replace_back(ListRow::new(new_key, widget));
drop(evicted); // frees the old row's widget, same as a pop would drop(evicted); // frees the old row's widget, same as a pop would
*self.tail.borrow_mut() = kept.map(|t| (new_key, t));
for row in &new_rows[common + 1..] { for row in &new_rows[common + 1..] {
self.push_row(rsc, row); self.push_row(rsc, row);
} }
} }
RowDiff::Rebuild => { RowDiff::Rebuild => {
// A row before the tail changed (a regroup) -- nothing // A row before the tail changed (a regroup) -- nothing
// short of a full rebuild expresses that. // short of a full rebuild expresses that. `Selection`
// gets cleared the same way `List` does, right before the
// rows it was pointing at go with it -- `push_row` below
// re-`register`s whatever survives as it rebuilds each
// row (docs/REVIEW-2026-09-06.md finding 1: a key that
// `group_tool_runs` regrouped away used to stay in
// `Selection` pointing at a widget this `clear()` had
// just freed, panicking the next long-press anywhere).
self.rebuilds.set(self.rebuilds.get() + 1); self.rebuilds.set(self.rebuilds.get() + 1);
self.selection.borrow_mut().clear();
(self.list)(rsc).clear(); (self.list)(rsc).clear();
*self.tail.borrow_mut() = None;
for row in &new_rows { for row in &new_rows {
self.push_row(rsc, row); self.push_row(rsc, row);
} }
@@ -181,7 +333,7 @@ pub fn build<Rsc: HasEvents>(
rows: Vec<FoldedRow>, rows: Vec<FoldedRow>,
) -> TranscriptScreen ) -> TranscriptScreen
where where
Rsc::State: FocusHost, Rsc::State: FocusHost + OpenUrl,
{ {
let (screen, tree) = build_tree(rsc, rows); let (screen, tree) = build_tree(rsc, rows);
ui_state.set_root(tree); ui_state.set_root(tree);
@@ -200,14 +352,27 @@ pub fn build_tree<Rsc: HasEvents>(
rows: Vec<FoldedRow>, rows: Vec<FoldedRow>,
) -> (TranscriptScreen, StrongWidget) ) -> (TranscriptScreen, StrongWidget)
where where
Rsc::State: FocusHost, Rsc::State: FocusHost + OpenUrl,
{ {
let selection = Rc::new(RefCell::new(Selection::new())); let selection = Rc::new(RefCell::new(Selection::new()));
let list = List::new(Axis::Y).add(rsc); let list = List::new(Axis::Y).add(rsc);
// The last row's block widgets are kept for the same reason
// `push_row` keeps them: a reply that is *already* streaming when the
// screen is built takes its next delta through `apply`, and a `None`
// here would send that delta down the rebuild path instead -- the
// whole message re-shaped, which is exactly what the per-block column
// exists to avoid, and nothing on screen or in `take_rebuilds` would
// say so.
let mut tail = None;
for row in &rows { for row in &rows {
let (key, widget) = row::build_row(rsc, list, selection.clone(), row); // `false`: a row built here is history until the caller says the
// session is working (`TranscriptScreen::set_session_working`),
// and claiming a call is running because the screen happens to be
// opening is exactly the inferred-as-measured mistake.
let (key, widget, kept) = row::build_row(rsc, list, selection.clone(), row, false);
list(rsc).push_back(ListRow::new(key, widget)); list(rsc).push_back(ListRow::new(key, widget));
tail = kept.map(|t| (key, t));
} }
// Wheel/trackpad scrolling -- the same idiom `trait_fns.rs`'s // Wheel/trackpad scrolling -- the same idiom `trait_fns.rs`'s
@@ -234,22 +399,20 @@ where
list.on( list.on(
CursorSense::Pressing(CursorButton::Left) | CursorSense::Drop, CursorSense::Pressing(CursorButton::Left) | CursorSense::Drop,
move |ctx, rsc| { move |ctx, rsc| {
let pos = ctx.data.pos; // Which *block* the finger is over, resolved from its
let row = list(rsc).key_at(pos.y).and_then(|key| { // drawn box rather than from the row's extent -- a row is
let (top, bottom) = list(rsc).extent(key)?; // a column of one widget per markdown block now, and the
Some(( // block is what `Selection` selects (`SelKey`).
key, let row = selection
Vec2::new(pos.x, pos.y - top), .borrow()
Vec2::new(ctx.data.size.x, bottom - top), .locate(&*rsc, ctx.data.render, ctx.data.cursor.pos);
))
});
selection.borrow_mut().drag( selection.borrow_mut().drag(
rsc, rsc,
list, list,
row, row,
ctx.data.cursor.pos, ctx.data.cursor.pos,
ctx.data.sense, ctx.data.sense,
Instant::now(), ctx.data.cursor.time,
ctx.data.render, ctx.data.render,
); );
}, },
@@ -266,6 +429,8 @@ where
( (
TranscriptScreen { TranscriptScreen {
tail: RefCell::new(tail),
session_working: std::cell::Cell::new(false),
list, list,
composer, composer,
selection, selection,
@@ -348,6 +513,7 @@ mod diff_tests {
input: "x".to_string(), input: "x".to_string(),
output: String::new(), output: String::new(),
done: false, done: false,
failed: false,
asks: Vec::new(), asks: Vec::new(),
images: Vec::new(), images: Vec::new(),
} }
@@ -414,3 +580,523 @@ mod diff_tests {
assert_eq!(diff_rows(&old, &new), RowDiff::Rebuild); assert_eq!(diff_rows(&old, &new), RowDiff::Rebuild);
} }
} }
/// Exercises `TranscriptScreen::apply`'s `Rebuild` arm through a real
/// `Selection`, the gap docs/REVIEW-2026-09-06.md finding 8 named: the
/// pure `diff_rows` decision above and `selection.rs`'s own registration
/// tests each pass in isolation, and neither alone catches finding 1 (a
/// regrouped-away row's key surviving in `Selection` after `List::clear()`
/// has already freed its widget). This fails before `Selection::clear()`
/// existed and the `Rebuild` arm called it, with a panic from
/// `TextEditable::edit` resolving the freed slot.
#[cfg(test)]
mod apply_tests {
use super::*;
use client_core::transcript_fold::TranscriptItem;
struct TestFocus {
focus: Option<WeakWidget<TextEdit>>,
}
/// The headless stand-in for `iris::platform::OpenUrl`'s real
/// backends. Nothing in these tests taps a link -- the tap-vs-drag
/// rule that decides whether one is followed is `iris`'s own
/// (`sense_tests.rs`'s `a_press_released_without_moving_is_a_tap`),
/// and which link is under a byte offset is `markdown.rs`'s -- so
/// this only exists to satisfy the bound.
impl OpenUrl for TestFocus {
fn open_url(&mut self, _url: &str) {}
}
impl FocusHost for TestFocus {
fn recent_click(&mut self) -> bool {
false
}
fn set_focus(&mut self, id: Option<WeakWidget<TextEdit>>) {
self.focus = id;
}
fn focus_gained(&mut self, _region: Option<PixelRegion>) {}
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool {
self.focus == Some(id)
}
}
struct TestRsc {
ui: UiData,
events: EventManager<TestRsc>,
}
impl UiRsc for TestRsc {
fn ui(&self) -> &UiData {
&self.ui
}
fn ui_mut(&mut self) -> &mut UiData {
&mut self.ui
}
fn on_draw(&mut self, active: &ActiveData) {
self.events.draw(active);
}
fn on_undraw(&mut self, active: &ActiveData) {
self.events.undraw(active);
}
fn on_remove(&mut self, id: WidgetId) {
self.events.remove(id);
}
}
impl HasState for TestRsc {
type State = TestFocus;
}
impl HasEvents for TestRsc {
fn events(&self) -> &EventManager<Self> {
&self.events
}
fn events_mut(&mut self) -> &mut EventManager<Self> {
&mut self.events
}
}
fn user(seq: u64, text: &str) -> TranscriptItem {
TranscriptItem::UserMsg {
seq,
text: text.to_string(),
attachments: Vec::new(),
}
}
fn tool(seq: u64, run_id: &str) -> TranscriptItem {
TranscriptItem::ToolRun {
seq,
id: format!("id{seq}"),
run_id: run_id.to_string(),
tool: "grep".to_string(),
input: "x".to_string(),
output: String::new(),
done: false,
failed: false,
asks: Vec::new(),
images: Vec::new(),
}
}
fn assistant(seq: u64, text: &str) -> TranscriptItem {
TranscriptItem::AssistantMsg {
seq,
text: text.to_string(),
settled: false,
}
}
/// A reply of `paragraphs` paragraphs, the last one still growing.
fn reply(paragraphs: usize, tail: &str) -> String {
let mut out = String::new();
for i in 0..paragraphs {
out.push_str(&format!("Paragraph number {i} of a streamed reply.\n\n"));
}
out.push_str(tail);
out
}
/// `(Widget::draw` calls, text layouts) caused by one streamed delta
/// landing in the last paragraph of a reply that already has
/// `paragraphs` of them.
fn cost_of_one_delta(paragraphs: usize) -> (u64, u64) {
let mut rsc = TestRsc {
ui: UiData::default(),
events: EventManager::default(),
};
let old_items = vec![assistant(1, &reply(paragraphs, "and the last one is st"))];
let new_items = vec![assistant(
1,
&reply(paragraphs, "and the last one is still going."),
)];
let (screen, tree) = build_tree(
&mut rsc,
client_core::transcript_fold::group_tool_runs(&old_items),
);
let mut render = UiRenderState::new();
render.resize((1080.0, 20000.0));
render.update(&tree, &mut rsc);
render.take_counters();
screen.apply(&mut rsc, &old_items, &new_items);
render.update(&tree, &mut rsc);
assert_eq!(screen.take_rebuilds(), 0, "the delta path must be taken");
let (draws, _, _, shapes) = render.take_counters();
(draws, shapes)
}
/// The pass condition for docs/DECISIONS.md's per-block row: a delta
/// costs the **last block**, not the message. A 3,000-character reply
/// has a hundred paragraphs already laid out; redrawing one delta into it
/// must cost exactly what the same delta costs in a one-paragraph
/// reply, or the earlier blocks are being re-shaped.
///
/// Before the split this was one `TextEdit` for the whole message, so
/// the count was the same *number* of widgets but each redraw
/// re-shaped every paragraph through parley -- which a draw counter
/// cannot see. What it can see is that the count does not *grow* with
/// the message, which it now does not and could not before, since the
/// one widget's own layout was O(message).
#[test]
fn a_delta_into_a_long_reply_redraws_the_same_widgets_as_a_short_one() {
assert!(
reply(100, "").len() > 3_000,
"the long case must actually be a long message"
);
let (short_draws, short_shapes) = cost_of_one_delta(1);
let (long_draws, long_shapes) = cost_of_one_delta(100);
assert_eq!(
short_draws, long_draws,
"a delta into a 100-paragraph reply redrew {long_draws} widgets against \
{short_draws} for a one-paragraph reply -- the earlier blocks are not being kept"
);
// The half a draw counter cannot see, and the one the per-block
// row actually exists for: a redraw is free if the text engine
// hits its memo, and a re-shape is the expensive thing. One
// shape, whatever the message is worth -- the block the delta
// landed in. Before the split this was necessarily O(message),
// since the whole reply was one buffer.
assert_eq!(
(short_shapes, long_shapes),
(1, 1),
"a delta shaped {long_shapes} text layouts in a 100-paragraph reply and \
{short_shapes} in a one-paragraph one; it must be the last block and nothing else"
);
}
#[test]
fn a_row_dropped_by_a_regroup_does_not_outlive_itself_in_selection() {
use client_core::transcript_fold::group_tool_runs;
let mut rsc = TestRsc {
ui: UiData::default(),
events: EventManager::default(),
};
// Same regroup shape as diff_tests' regroup case, plus a trailing
// row (seq 4) that survives unchanged -- what a reader would tap
// on right after the regroup lands.
let old_items = vec![tool(1, "run-a"), user(2, "meanwhile"), user(4, "stable")];
let new_items = vec![tool(1, "run-a"), tool(3, "run-a"), user(4, "stable")];
assert_eq!(
diff_rows(&group_tool_runs(&old_items), &group_tool_runs(&new_items)),
RowDiff::Rebuild,
"test setup must actually exercise the Rebuild arm"
);
let (screen, _tree) = build_tree(&mut rsc, group_tool_runs(&old_items));
screen.apply(&mut rsc, &old_items, &new_items);
// The surviving row (seq 4) is what a reader's long-press would
// land on; `begin` deselects every *other* registered row first,
// which is exactly what used to resolve a stale `WeakWidget` left
// by the regrouped-away rows and panic.
let surviving_key = row::row_key(&client_core::transcript_fold::ItemKey::Seq(4));
screen.selection.borrow_mut().begin(
&mut rsc,
(surviving_key, 0),
Vec2::ZERO,
Vec2::new(10.0, 10.0),
);
}
/// The failure half of the per-block row, and the one
/// `a_row_dropped_by_a_regroup_...` cannot reach: the tail row is
/// rebuilt under the **same key** with *fewer* blocks than it had.
/// `Selection` is keyed by `(row, block)`, so the blocks that no
/// longer exist are left pointing at widgets `replace_back`'s drop
/// frees -- and `begin` resolves every registered handle on an
/// ordinary press, so the next tap anywhere in the transcript
/// panics. Nothing about the key changed, which is why the
/// `if new_key != old_key` guard this replaced could not see it.
#[test]
fn a_tail_rebuilt_with_fewer_blocks_leaves_none_of_them_in_selection() {
use client_core::transcript_fold::group_tool_runs;
let mut rsc = TestRsc {
ui: UiData::default(),
events: EventManager::default(),
};
// Three blocks, then one. The rewrite is of an *earlier* block
// (the heading), so `RowBlocks::apply_delta` refuses it and the
// rebuild path is the one taken -- assert that below.
let old_items = vec![user(1, "stable"), assistant(2, "# Head\n\npara\n\n- item")];
let new_items = vec![user(1, "stable"), assistant(2, "short")];
assert_eq!(
diff_rows(&group_tool_runs(&old_items), &group_tool_runs(&new_items)),
RowDiff::ReplaceLast { common: 1 },
"test setup must actually exercise the ReplaceLast arm"
);
let (screen, _tree) = build_tree(&mut rsc, group_tool_runs(&old_items));
let tail_key = row::row_key(&client_core::transcript_fold::ItemKey::Seq(2));
assert_eq!(
screen
.selection
.borrow()
.registered_blocks(tail_key)
.count(),
3,
"the fixture must start with more blocks than it ends with"
);
screen.apply(&mut rsc, &old_items, &new_items);
assert_eq!(
screen
.selection
.borrow()
.registered_blocks(tail_key)
.count(),
1,
"the blocks the rebuild dropped are still registered"
);
// What a reader does next: press the row that survived. `begin`
// resolves every registered handle, so a stale one panics here.
let surviving_key = row::row_key(&client_core::transcript_fold::ItemKey::Seq(1));
screen.selection.borrow_mut().begin(
&mut rsc,
(surviving_key, 0),
Vec2::ZERO,
Vec2::new(10.0, 10.0),
);
}
/// A tool call with `output` bytes of output, `done` or not.
fn call(id: &str, output: &str, done: bool) -> TranscriptItem {
TranscriptItem::ToolRun {
seq: 1,
id: id.to_string(),
run_id: "run".to_string(),
tool: "Bash".to_string(),
input: format!(r#"{{"command":"grep -rn {id} ."}}"#),
output: output.to_string(),
done,
failed: false,
asks: Vec::new(),
images: Vec::new(),
}
}
fn run_of(count: usize, output: &str, done: bool) -> Vec<TranscriptItem> {
(0..count)
.map(|i| call(&format!("t{i}"), output, done))
.collect()
}
/// A screen holding one tool run, with the group opened the way a tap
/// opens it, plus the counters drained -- so what a caller measures
/// next is only what it asked for.
fn open_run(
rsc: &mut TestRsc,
items: &[TranscriptItem],
) -> (TranscriptScreen, StrongWidget, UiRenderState) {
let (screen, tree) = build_tree(rsc, client_core::transcript_fold::group_tool_runs(items));
let mut render = UiRenderState::new();
render.resize((1080.0, 20000.0));
render.update(&tree, rsc);
assert!(
screen.expand_tail_tools(rsc, true),
"the fixture's only row must be the tool run"
);
render.update(&tree, rsc);
render.take_counters();
(screen, tree, render)
}
/// The text shapes it costs to *open* a group of three cards whose
/// calls carry `output` -- the cards themselves, since the collapsed
/// group before the expansion drew none.
fn shapes_to_open(output: &str) -> u64 {
let mut rsc = TestRsc {
ui: UiData::default(),
events: EventManager::default(),
};
let items = run_of(3, output, true);
let (screen, tree) = build_tree(
&mut rsc,
client_core::transcript_fold::group_tool_runs(&items),
);
let mut render = UiRenderState::new();
render.resize((1080.0, 20000.0));
render.update(&tree, &mut rsc);
render.take_counters();
assert!(
screen.expand_tail_tools(&mut rsc, true),
"the fixture's only row must be the tool run"
);
render.update(&tree, &mut rsc);
let (_, _, _, shapes) = render.take_counters();
shapes
}
/// **The O(last block) discipline, for tool cards** (RUST.md's P1b).
/// A collapsed card draws its summary line and nothing else, so the
/// kilobyte outputs the bench fixture carries cost nothing until
/// somebody opens one. Counted in *text shapes*, the number a draw
/// counter cannot stand in for: the widgets are the same either way,
/// and it is parley's work that would grow with the output.
///
/// The group is *opened* here, so all three cards are really drawn --
/// the cheap version of this test (a closed group, which draws no
/// cards at all) would pass without saying anything about a card.
#[test]
fn collapsed_cards_shape_only_their_summary_lines() {
let long: String = std::iter::repeat_n("a line of tool output\n", 4_000).collect();
assert!(long.len() > 80_000, "the long case must actually be long");
let short_shapes = shapes_to_open("ok\n");
let long_shapes = shapes_to_open(&long);
assert!(
short_shapes > 0,
"opening a group must shape something, or this compares two zeroes"
);
assert_eq!(
short_shapes, long_shapes,
"three collapsed cards shaped {long_shapes} text layouts over 80 kB of output \
against {short_shapes} over three bytes -- a collapsed card is laying out \
something it does not draw"
);
}
/// What one arriving result costs, in `Widget::draw` calls, in a run of
/// `count` calls -- with the group open, so every card is really on
/// screen and a rebuild of the wrong scope would show.
fn cost_of_one_result(count: usize) -> u64 {
let mut rsc = TestRsc {
ui: UiData::default(),
events: EventManager::default(),
};
let before = run_of(count, "", false);
let mut after = before.clone();
after[0] = call("t0", "the result", true);
let (screen, tree, mut render) = open_run(&mut rsc, &before);
screen.apply(&mut rsc, &before, &after);
render.update(&tree, &mut rsc);
assert_eq!(
screen.take_rebuilds(),
0,
"a result arriving must not rebuild the whole screen"
);
let (draws, _, _, _) = render.take_counters();
draws
}
/// **A result changes one card**, whatever else is in the run --
/// `RowBlocks::apply_delta`'s discipline applied to a group, which is
/// a column of cards (`tool::ToolRow::apply_calls`). Stated as a
/// comparison rather than a number, because the number is whatever a
/// card happens to be made of and would have to be edited every time
/// the card gains a widget; what must not change is that it does not
/// grow with the run.
#[test]
fn a_result_arriving_redraws_one_card_whatever_the_run_holds() {
let small = cost_of_one_result(3);
let large = cost_of_one_result(12);
assert!(
small > 0,
"a result must redraw *something*, or this compares two zeroes"
);
assert_eq!(
small, large,
"one result redrew {large} widgets in a twelve-call run against {small} in a \
three-call one -- the other cards are being rebuilt with it"
);
}
/// The group's own state: opening it draws the cards, closing it takes
/// them away again, and the reader's choice survives a result arriving
/// in the middle of it.
#[test]
fn a_group_opens_and_closes_and_keeps_its_state_across_a_result() {
let mut rsc = TestRsc {
ui: UiData::default(),
events: EventManager::default(),
};
let before = run_of(3, "", false);
let mut after = before.clone();
after[1] = call("t1", "done", true);
let (screen, tree) = build_tree(
&mut rsc,
client_core::transcript_fold::group_tool_runs(&before),
);
let mut render = UiRenderState::new();
render.resize((1080.0, 20000.0));
render.update(&tree, &mut rsc);
// Closed, a group is one line: no card is registered at all, which
// is what makes the kilobyte outputs free.
assert_eq!(screen.tail_card_count(), 0);
assert!(screen.expand_tail_tools(&mut rsc, true));
assert_eq!(screen.tail_card_count(), 3);
// A result arriving must not close what the reader opened -- the
// card is rebuilt, and being open is the reader's state rather
// than the event's.
screen.apply(&mut rsc, &before, &after);
render.update(&tree, &mut rsc);
assert_eq!(screen.take_rebuilds(), 0);
assert_eq!(
screen.tail_card_count(),
3,
"the group closed under a result"
);
assert!(screen.expand_tail_tools(&mut rsc, false));
assert_eq!(screen.tail_card_count(), 0);
}
/// A call that joins a run while it is the live row appends one card
/// rather than rebuilding the row -- the other half of `apply_calls`,
/// and the case a page join does *not* produce (that one goes through
/// `Rebuild`).
#[test]
fn a_call_joining_an_open_run_appends_one_card() {
let mut rsc = TestRsc {
ui: UiData::default(),
events: EventManager::default(),
};
let before = run_of(2, "ok", true);
let mut after = before.clone();
after.push(call("t2", "", false));
let (screen, tree, mut render) = open_run(&mut rsc, &before);
assert_eq!(screen.tail_card_count(), 2);
screen.apply(&mut rsc, &before, &after);
render.update(&tree, &mut rsc);
assert_eq!(
screen.take_rebuilds(),
0,
"an appended call is not a rebuild"
);
assert_eq!(screen.tail_card_count(), 3);
}
/// A tool row that becomes something else is a different row, not a
/// changed one. Without the guard in `apply_calls` a `UserMsg` would
/// reach the card builder, whose `debug_assert` is the last line of
/// defence rather than the first.
#[test]
fn a_tail_that_stops_being_tool_calls_falls_back_to_a_rebuild() {
let mut rsc = TestRsc {
ui: UiData::default(),
events: EventManager::default(),
};
let before = vec![user(1, "stable"), call("t0", "", false)];
let after = vec![user(1, "stable"), user(2, "not a tool call at all")];
let (screen, _tree) = build_tree(
&mut rsc,
client_core::transcript_fold::group_tool_runs(&before),
);
screen.apply(&mut rsc, &before, &after);
assert_eq!(
screen.take_rebuilds(),
0,
"this is a ReplaceLast, not a whole-screen rebuild"
);
// The row that replaced it is a message, so it keeps blocks rather
// than cards -- and nothing panicked on the way.
assert_eq!(screen.tail_card_count(), 0);
}
}
+649 -85
View File
@@ -1,14 +1,25 @@
//! Markdown -> one plain string plus a `Vec<SpanStyle>`, for I5's row //! One markdown **block** (`client_core::markdown_blocks::Block`) rendered
//! builder to hand to a single `TextEdit` (`row.rs`). This is the crate's //! for display: the plain text to draw, the [`SpanStyle`]s that style it,
//! answer to RUST.md's E2 finding against Masonry ("rich inline text -- //! the links inside it, and the [`BlockFrame`] the row builder puts around
//! block-level yes, inline no, and both for the same reason": `TextArea`'s //! it.
//! `StyleSet` is one style for the whole editor, //!
//! This is the crate's answer to RUST.md's E2 finding against Masonry
//! ("rich inline text -- block-level yes, inline no, and both for the same
//! reason": `TextArea`'s `StyleSet` is one style for the whole editor,
//! `masonry/src/widgets/text_area.rs:43-44`'s `// TODO: RichTextInput` //! `masonry/src/widgets/text_area.rs:43-44`'s `// TODO: RichTextInput`
//! beside it). iris's `SpanStyle` (`core/src/primitive/text.rs`, added for //! beside it). iris's `SpanStyle` (`core/src/primitive/text.rs`) is
//! this box) is per-range, so bold/italic/inline-code/links/headings inside //! per-range, so bold/italic/inline-code/links inside one wrapped
//! one wrapped paragraph render in their own style *and* the paragraph //! paragraph render in their own style *and* the paragraph still wraps and
//! still wraps and selects as one buffer -- there is no second widget per //! selects as one buffer.
//! span the way E2's block-level `Prose`-per-heading was. //!
//! **Three widget shapes, not one per markdown feature** ([`BlockFrame`]).
//! A heading, a paragraph and a list are all *text with spans*; a fence
//! and a table are *verbatim text on a dark surface that pans sideways*;
//! a quote is *text behind a coloured bar*. Everything else markdown can
//! say is expressed in the spans, which cost no widgets and no layout
//! nodes. `app/.../Markdown.kt`'s component table is the reference for the
//! sizes and colours; docs/DECISIONS.md's 2026-09-06 entry records where
//! this deliberately differs.
//! //!
//! **What this deliberately does not attempt**, each for a reason recorded //! **What this deliberately does not attempt**, each for a reason recorded
//! here rather than silently dropped (see IRIS_TODO.md's dated entries for //! here rather than silently dropped (see IRIS_TODO.md's dated entries for
@@ -16,52 +27,190 @@
//! - **No background chip behind inline code.** Drawing one needs the //! - **No background chip behind inline code.** Drawing one needs the
//! glyph run's own geometry (the way `TextEdit::draw`'s selection //! glyph run's own geometry (the way `TextEdit::draw`'s selection
//! highlight uses `selection.geometry(layout)`, //! highlight uses `selection.geometry(layout)`,
//! `iris/src/widget/text/edit.rs:99`), which is `TextEdit`-internal and //! `iris/src/widget/text/edit.rs:99`), which is `TextEdit`-internal.
//! not exposed to a caller building spans externally. `SpanStyle` gives //! `SpanStyle` gives the code range a monospace family and the
//! the code range a monospace family and a dimmer text colour instead -- //! palette's code colour instead -- visually distinct, just not
//! visually distinct, just not chip-shaped. //! chip-shaped.
//! - **A link is styled (colour + underline) but not tappable.** Following //! - **A list's indent is written in spaces**, not measured. Compose lays
//! it needs the same kind of per-range hit-testing a chip's background //! an item out as a marker column beside a text column, which keeps a
//! would (which byte range did the tap land in, then look up its URL), //! wrapped second line aligned under the first; here the marker is part
//! which is exactly the same missing primitive. //! of the same buffer, so a wrapped line returns to the left margin.
//! - **Tables render as plain paragraphs of their cell text**, no columns. //! Doing better needs per-line indent in `TextAttrs`, which nothing else
//! `pulldown_cmark::Tag::Table` is walked but not laid out -- a real grid //! wants yet.
//! needs its own widget, out of scope for a row builder.
//! - **A fenced code block's language is not syntax-highlighted.**
//! `client-core::highlight` exists and could feed per-token `SpanStyle`s,
//! but wiring it in is real work belonging to whoever needs it next
//! (IRIS_TODO.md).
//! //!
//! A heading's `SpanStyle::font_size` override does not also raise its //! A heading's `SpanStyle::font_size` override does not also raise its
//! `line_height` (a buffer has one, set from the *base* font size in //! `line_height` (a buffer has one, set from the *base* font size in
//! `TextAttrs`), so a heading's own line looks slightly tighter than a //! `TextAttrs`), so a heading's own line looks slightly tighter than a
//! paragraph's -- visible, not incorrect, and not fixed here since it needs //! paragraph's -- visible, not incorrect, and not fixed here since it
//! `SpanStyle` to carry line-height too, which nothing in this crate needed //! needs `SpanStyle` to carry line-height too.
//! badly enough yet to justify.
use client_core::highlight::{self, Kind, Language};
use client_core::markdown_blocks::{Block, BlockKind};
use iris::prelude::*; use iris::prelude::*;
use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd}; use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use std::ops::Range;
// `UiColor` is `Color<u8>` (`core/src/lib.rs`), not the 0..1 float triples // `UiColor` is `Color<u8>` (`core/src/lib.rs`), not the 0..1 float triples
// its brighter/darker helpers might suggest -- these are plain 0..255 RGB. // its brighter/darker helpers might suggest -- these are plain 0..255 RGB.
pub const CODE_COLOR: UiColor = UiColor::new(140, 217, 242, 255); // Catppuccin Mocha, the same values `app/.../Theme.kt` maps onto
pub const LINK_COLOR: UiColor = UiColor::new(140, 190, 255, 255); // Material's roles, so a block drawn here and the same block drawn by the
const STRIKETHROUGH_COLOR: UiColor = UiColor::new(150, 150, 150, 255); // Compose app are the same colour rather than nearly.
const fn mocha(hex: u32) -> UiColor {
UiColor::new(
((hex >> 16) & 0xff) as u8,
((hex >> 8) & 0xff) as u8,
(hex & 0xff) as u8,
255,
)
}
/// A block-level separator: two blocks never run into each other with no /// Body text: Mocha Text, the Compose app's `onSurface`.
/// gap, but an empty `out` (the very first block) gets no leading blank. pub const TEXT_COLOR: UiColor = mocha(0xCDD6F4);
/// Inline code, and a fence with no language to highlight it by.
pub const CODE_COLOR: UiColor = mocha(0xCDD6F4);
/// A link. "Blue is what a link is on every Catppuccin surface, and the
/// one colour to leave alone" (`Theme.kt`'s `linkColor`).
pub const LINK_COLOR: UiColor = mocha(0x89B4FA);
/// A list's bullets and numbers: structure rather than words, so the
/// items of a list can be counted without reading them (`listMarkerColor`).
pub const MARKER_COLOR: UiColor = mocha(0xB4BEFE);
/// What every verbatim thing in this app sits on -- Mocha Crust, one step
/// *below* the page rather than above it (`Theme.kt`'s `rawSurface`).
pub const VERBATIM_BACKGROUND: UiColor = mocha(0x11111B);
/// A table's fill: Surface 0, the Compose app's `surfaceVariant`.
pub const TABLE_BACKGROUND: UiColor = mocha(0x313244);
/// A quote's bar and its text: the bar carries the structure, and the
/// words step back one shade from body text so a quote reads as quoted
/// without being hard to read.
pub const QUOTE_BAR_COLOR: UiColor = mocha(0x585B70);
pub const QUOTE_TEXT_COLOR: UiColor = mocha(0xA6ADC8);
const STRIKETHROUGH_COLOR: UiColor = mocha(0x6C7086);
/// Catppuccin Mocha as the highlighter's palette -- the same mapping
/// `Theme.kt`'s `catppuccinSyntax()` uses, so a `kotlin` fence is the same
/// colours in both apps.
fn syntax_color(kind: Kind) -> UiColor {
match kind {
Kind::Keyword => mocha(0xCBA6F7),
Kind::String => mocha(0xA6E3A1),
Kind::Literal => mocha(0xFAB387),
Kind::Comment => mocha(0x6C7086),
Kind::Metadata => mocha(0xF9E2AF),
Kind::Punctuation => mocha(0xA6ADC8),
Kind::Mark => mocha(0x89DCEB),
}
}
/// What a row builder puts *around* a block's text widget. Three, not one
/// per markdown feature -- see the module doc.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BlockFrame {
/// Text and nothing else: a paragraph, a heading, a list, a rule.
Plain,
/// A dark rounded panel whose text does not wrap -- long lines pan
/// sideways, the way `CodeFence.kt`'s `horizontalScroll` does. Carries
/// its own fill, since a fence and a table are drawn on different
/// ones.
Verbatim { fill: UiColor },
/// A coloured bar down the left edge and an indent past it.
Quote,
}
/// The frame a block kind is drawn in. Pure, and the *only* place the
/// mapping is written: a new `BlockKind` shows up here as a compile error
/// rather than silently taking prose's appearance.
pub fn frame_of(kind: BlockKind) -> BlockFrame {
match kind {
BlockKind::Code => BlockFrame::Verbatim {
fill: VERBATIM_BACKGROUND,
},
BlockKind::Table => BlockFrame::Verbatim {
fill: TABLE_BACKGROUND,
},
BlockKind::Quote => BlockFrame::Quote,
BlockKind::Paragraph | BlockKind::Heading | BlockKind::List | BlockKind::Other => {
BlockFrame::Plain
}
}
}
/// A tappable range of a block's text and where it points.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Link {
/// Byte range into [`Rendered::text`].
pub range: Range<usize>,
pub url: String,
}
/// One block, ready to draw. Not `Debug`: `SpanStyle` is not, and adding
/// it there for this would be a change to iris for a test's benefit.
#[derive(Clone, Default)]
pub struct Rendered {
pub text: String,
pub spans: Vec<SpanStyle>,
pub links: Vec<Link>,
}
impl Rendered {
/// The link `byte` falls inside, if any -- what a tap resolves
/// through. Half-open, so the offset one past a link's last character
/// (where a tap just after it lands) is *not* in it.
pub fn link_at(&self, byte: usize) -> Option<&Link> {
self.links.iter().find(|l| l.range.contains(&byte))
}
}
/// The heading ladder, in points at a 16pt body: it starts near the body
/// text and descends, because these are headings inside a chat message
/// rather than the top of a document. The numbers are Material's
/// `headlineSmall`/`titleLarge`/`titleMedium`/`titleSmall`/`labelMedium`/
/// `labelSmall`, which is what `Markdown.kt`'s `markdownTypography` picks
/// -- kept as literals rather than derived from `base_size` so the two
/// apps agree exactly.
fn heading_size(level: HeadingLevel) -> f32 {
match level {
HeadingLevel::H1 => 24.0,
HeadingLevel::H2 => 22.0,
HeadingLevel::H3 => 16.0,
HeadingLevel::H4 => 14.0,
HeadingLevel::H5 => 12.0,
HeadingLevel::H6 => 11.0,
}
}
/// The bullet at each depth, cycling past the third: a disc, a ring, a
/// square -- the ladder a browser draws, so a nested list is told from its
/// parent by the glyph as well as by the indent. Same three
/// `MarkdownPieces.kt` uses.
const BULLETS: [&str; 3] = ["\u{2022} ", "\u{25e6} ", "\u{25aa} "];
/// A block-level separator inside one block's own text (a list item's
/// paragraphs, a quote's): two never run into each other with no gap, but
/// an empty `out` gets no leading blank.
fn ensure_blank_line(out: &mut String) { fn ensure_blank_line(out: &mut String) {
if !out.is_empty() && !out.ends_with("\n\n") { if !out.is_empty() && !out.ends_with("\n\n") {
while out.ends_with('\n') {
out.pop();
}
out.push_str("\n\n"); out.push_str("\n\n");
} }
} }
fn heading_size(level: HeadingLevel) -> f32 { fn ensure_line(out: &mut String) {
match level { if !out.is_empty() && !out.ends_with('\n') {
HeadingLevel::H1 => 28.0, out.push('\n');
HeadingLevel::H2 => 24.0, }
HeadingLevel::H3 => 21.0, }
_ => 19.0,
/// One top-level block, rendered. `base_size` is the row's ordinary
/// paragraph font size; a heading overrides it per span.
pub fn render_block(block: &Block, base_size: f32) -> Rendered {
match block.kind {
// A table is the one block markdown states as a grid and iris has
// no grid widget for. Rendered as padded monospace instead --
// see [`table_text`].
BlockKind::Table => table_text(&block.source),
_ => render_markdown(&block.source, base_size),
} }
} }
@@ -69,19 +218,27 @@ fn heading_size(level: HeadingLevel) -> f32 {
/// style it. `base_size` is the row's ordinary paragraph font size, needed /// style it. `base_size` is the row's ordinary paragraph font size, needed
/// only so a heading's override is relative to it rather than a hardcoded /// only so a heading's override is relative to it rather than a hardcoded
/// absolute the caller cannot retune. /// absolute the caller cannot retune.
pub fn render_markdown(src: &str, base_size: f32) -> (String, Vec<SpanStyle>) { pub fn render_markdown(src: &str, base_size: f32) -> Rendered {
let _ = base_size; // headings use fixed sizes today; kept for callers that may want relative sizing later let _ = base_size; // headings use the fixed Material ladder; see `heading_size`
let mut out = String::new(); let mut out = String::new();
let mut spans = Vec::new(); let mut spans = Vec::new();
let mut links = Vec::new();
// Stack of start byte offsets for whatever inline/block styling is // Stack of start byte offsets for whatever inline/block styling is
// currently open -- pulldown-cmark's `Start`/`End` events are always // currently open -- pulldown-cmark's `Start`/`End` events are always
// balanced and each `End` already names its own kind (`TagEnd`), so a // balanced and each `End` already names its own kind (`TagEnd`), so a
// plain offset stack (rather than a tree, or repeating the kind here // plain offset stack (rather than a tree, or repeating the kind here
// too) is enough. // too) is enough. A link's destination rides along beside its offset,
let mut open: Vec<usize> = Vec::new(); // since `TagEnd::Link` does not carry it.
let mut list_depth: u32 = 0; let mut open: Vec<(usize, Option<String>)> = Vec::new();
// One entry per open list: `Some(next number)` for an ordered list,
// `None` for a bulleted one. Depth is this vector's length, which is
// what picks the bullet glyph.
let mut lists: Vec<Option<u64>> = Vec::new();
// The language of the fence currently open, so `TagEnd::CodeBlock` can
// highlight what was collected between the two.
let mut fence_language: Option<Language> = None;
let parser = Parser::new_ext(src, Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES); let parser = Parser::new_ext(src, options());
for event in parser { for event in parser {
match event { match event {
Event::Start(tag) => match tag { Event::Start(tag) => match tag {
@@ -89,16 +246,35 @@ pub fn render_markdown(src: &str, base_size: f32) -> (String, Vec<SpanStyle>) {
| Tag::Emphasis | Tag::Emphasis
| Tag::Strong | Tag::Strong
| Tag::Strikethrough | Tag::Strikethrough
| Tag::Link { .. } => open.push(out.len()), | Tag::Image { .. } => open.push((out.len(), None)),
Tag::CodeBlock(_) => { Tag::Link { dest_url, .. } => open.push((out.len(), Some(dest_url.to_string()))),
Tag::CodeBlock(kind) => {
fence_language = match &kind {
CodeBlockKind::Fenced(info) => {
// Only the first word: "rust,ignore" and
// "console session" are both written.
highlight::fence_language(info.split_whitespace().next())
}
CodeBlockKind::Indented => None,
};
ensure_blank_line(&mut out); ensure_blank_line(&mut out);
open.push(out.len()); open.push((out.len(), None));
} }
Tag::Item => { Tag::Item => {
out.push_str(&" ".repeat(list_depth.saturating_sub(1) as usize)); ensure_line(&mut out);
out.push_str("\u{2022} "); let depth = lists.len().max(1);
out.push_str(&" ".repeat(depth - 1));
let start = out.len();
match lists.last_mut() {
Some(Some(n)) => {
out.push_str(&format!("{n}. "));
*n += 1;
} }
Tag::List(_) => list_depth += 1, _ => out.push_str(BULLETS[(depth - 1) % BULLETS.len()]),
}
spans.push(SpanStyle::new(start..out.len()).color(MARKER_COLOR));
}
Tag::List(first) => lists.push(first),
Tag::Paragraph | Tag::BlockQuote(_) => ensure_blank_line(&mut out), Tag::Paragraph | Tag::BlockQuote(_) => ensure_blank_line(&mut out),
_ => {} _ => {}
}, },
@@ -113,11 +289,19 @@ pub fn render_markdown(src: &str, base_size: f32) -> (String, Vec<SpanStyle>) {
| TagEnd::Strong | TagEnd::Strong
| TagEnd::Strikethrough | TagEnd::Strikethrough
| TagEnd::Link | TagEnd::Link
| TagEnd::Image
| TagEnd::CodeBlock), | TagEnd::CodeBlock),
) => { ) => {
let Some(start) = open.pop() else { let Some((start, dest)) = open.pop() else {
continue; continue;
}; };
if matches!(tag_end, TagEnd::CodeBlock) {
// A fence's trailing newline is the fence marker's, not
// the code's -- kept and it draws an empty last line.
while out.ends_with('\n') {
out.pop();
}
}
let range = start..out.len(); let range = start..out.len();
if range.is_empty() { if range.is_empty() {
continue; continue;
@@ -131,15 +315,27 @@ pub fn render_markdown(src: &str, base_size: f32) -> (String, Vec<SpanStyle>) {
TagEnd::Strikethrough => { TagEnd::Strikethrough => {
spans.push(SpanStyle::new(range).color(STRIKETHROUGH_COLOR)); spans.push(SpanStyle::new(range).color(STRIKETHROUGH_COLOR));
} }
TagEnd::Link => { // An image draws as its alt text until the port has a
spans.push(SpanStyle::new(range).color(LINK_COLOR).underline()); // transcript image widget (IRIS_TODO's "scaled
// thumbnail"); marked as a link so it is at least
// followable rather than silently inert.
TagEnd::Link | TagEnd::Image => {
spans.push(SpanStyle::new(range.clone()).color(LINK_COLOR).underline());
if let Some(url) = dest {
links.push(Link { range, url });
}
} }
TagEnd::CodeBlock => { TagEnd::CodeBlock => {
spans.push( spans.push(
SpanStyle::new(range) SpanStyle::new(range.clone())
.family(Family::Monospace) .family(Family::Monospace)
.color(CODE_COLOR), .color(CODE_COLOR),
); );
// After the monospace span, so the per-token
// colours win where they overlap it.
if let Some(language) = fence_language.take() {
highlight_into(&mut spans, &out, range, language);
}
} }
_ => unreachable!("filtered by the outer match arm"), _ => unreachable!("filtered by the outer match arm"),
} }
@@ -160,64 +356,432 @@ pub fn render_markdown(src: &str, base_size: f32) -> (String, Vec<SpanStyle>) {
Event::SoftBreak => out.push(' '), Event::SoftBreak => out.push(' '),
Event::HardBreak => out.push('\n'), Event::HardBreak => out.push('\n'),
Event::Rule => { Event::Rule => {
if !out.ends_with('\n') { ensure_line(&mut out);
out.push('\n');
}
out.push_str("\u{2500}\u{2500}\u{2500}\n"); out.push_str("\u{2500}\u{2500}\u{2500}\n");
} }
Event::End(TagEnd::List(_)) => list_depth = list_depth.saturating_sub(1), Event::TaskListMarker(done) => {
let start = out.len();
out.push_str(if done { "[x] " } else { "[ ] " });
spans.push(SpanStyle::new(start..out.len()).color(MARKER_COLOR));
}
Event::End(TagEnd::List(_)) => {
lists.pop();
}
_ => {} _ => {}
} }
} }
(out, spans) while out.ends_with('\n') {
out.pop();
}
// A span left pointing past the text a later trim shortened would draw
// against nothing; markdown that ends inside an open emphasis is
// ordinary mid-stream input, not a defect.
spans.retain(|s| s.range.end <= out.len());
links.retain(|l| l.range.end <= out.len());
Rendered {
text: out,
spans,
links,
}
}
/// The same option set `client_core::markdown_blocks` splits with, so a
/// block boundary there and the styling here cannot disagree about what
/// the source means.
fn options() -> Options {
Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS
}
/// `client_core::highlight`'s spans for the code at `range` inside `text`,
/// appended to `spans`.
///
/// The highlighter indexes **chars** and `SpanStyle` indexes **bytes**
/// (`highlight`'s module doc), so the offsets are walked once rather than
/// converted per span -- a fence is scanned on every delta that lands in
/// it, and it is the only block a delta re-renders.
pub(crate) fn highlight_into(
spans: &mut Vec<SpanStyle>,
text: &str,
range: Range<usize>,
language: Language,
) {
let code = &text[range.clone()];
// char index -> byte offset within `code`, plus the end, so a span's
// `end` is always in range.
let bytes: Vec<usize> = code
.char_indices()
.map(|(i, _)| i)
.chain(std::iter::once(code.len()))
.collect();
for span in highlight::spans_of(code, language) {
let (Some(&start), Some(&end)) = (bytes.get(span.start), bytes.get(span.end)) else {
debug_assert!(
false,
"highlight span {}..{} outside {} chars of code",
span.start,
span.end,
bytes.len() - 1
);
continue;
};
spans.push(
SpanStyle::new(range.start + start..range.start + end)
.family(Family::Monospace)
.color(syntax_color(span.kind)),
);
}
}
/// The widest a table column is allowed to get before its cells wrap
/// inside it, in characters. Chosen the way `Markdown.kt`'s 136dp
/// `tableCellWidth` was -- what fits three columns across a phone -- but
/// counted in monospace characters, which is the unit a padded table has:
/// three 28-character columns plus separators is about 90 characters,
/// which is what a 16pt mono face gives on a 1080px phone before the
/// sideways pan starts.
const TABLE_MAX_COL: usize = 28;
/// A GFM table as **padded monospace columns**, with the header bold and a
/// rule under it.
///
/// iris has no grid widget, and building one for the one block kind that
/// needs it would be a widget per markdown feature -- what this crate's
/// module doc says it will not do. A monospace face makes character counts
/// and pixel widths the same thing, so padding each cell to its column's
/// width *is* alignment, the column widths are measured from the cells,
/// and the block reuses `BlockFrame::Verbatim`'s sideways pan for a table
/// too wide to fit. docs/DECISIONS.md, 2026-09-06, has what this trades.
pub fn table_text(src: &str) -> Rendered {
let rows = table_cells(src);
if rows.is_empty() {
return Rendered::default();
}
let columns = rows.iter().map(Vec::len).max().unwrap_or(0);
// Each cell wrapped to the cap first, so a column's width is the
// widest *line* it will actually draw rather than the longest cell.
let wrapped: Vec<Vec<Vec<String>>> = rows
.iter()
.map(|row| row.iter().map(|c| wrap_cell(c, TABLE_MAX_COL)).collect())
.collect();
let widths: Vec<usize> = (0..columns)
.map(|c| {
wrapped
.iter()
.filter_map(|row| row.get(c))
.flat_map(|lines| lines.iter())
.map(|l| l.chars().count())
.max()
.unwrap_or(0)
})
.collect();
let mut out = String::new();
let mut spans = Vec::new();
for (r, row) in wrapped.iter().enumerate() {
let height = row.iter().map(Vec::len).max().unwrap_or(1);
let start = out.len();
for line in 0..height {
if !out.is_empty() {
out.push('\n');
}
for (c, width) in widths.iter().enumerate() {
if c > 0 {
out.push_str(" ");
}
let text = row.get(c).and_then(|l| l.get(line)).map(String::as_str);
let text = text.unwrap_or("");
out.push_str(text);
// The last column is not padded: trailing spaces widen
// the block's measured width for nothing.
if c + 1 < widths.len() {
for _ in text.chars().count()..*width {
out.push(' ');
}
}
}
}
if r == 0 {
spans.push(SpanStyle::new(start..out.len()).bold());
out.push('\n');
let rule: usize = widths.iter().sum::<usize>() + 2 * widths.len().saturating_sub(1);
let rule_start = out.len();
out.extend(std::iter::repeat_n('\u{2500}', rule));
spans.push(SpanStyle::new(rule_start..out.len()).color(QUOTE_BAR_COLOR));
}
}
Rendered {
text: out,
spans,
links: Vec::new(),
}
}
/// The cells of a GFM table, row by row, as their plain text.
fn table_cells(src: &str) -> Vec<Vec<String>> {
let mut rows: Vec<Vec<String>> = Vec::new();
let mut cell = String::new();
let mut in_cell = false;
for event in Parser::new_ext(src, options()) {
match event {
Event::Start(Tag::TableHead) | Event::Start(Tag::TableRow) => rows.push(Vec::new()),
Event::Start(Tag::TableCell) => {
cell.clear();
in_cell = true;
}
Event::End(TagEnd::TableCell) => {
in_cell = false;
if let Some(row) = rows.last_mut() {
row.push(cell.trim().to_string());
}
}
Event::Text(text) | Event::Code(text) if in_cell => cell.push_str(&text),
Event::SoftBreak | Event::HardBreak if in_cell => cell.push(' '),
_ => {}
}
}
rows.retain(|r| !r.is_empty());
rows
}
/// `text` broken onto lines of at most `width` characters, at spaces where
/// there are any. A word longer than the column is left over-long rather
/// than cut mid-word: the column then widens for it, which is visible and
/// correct, where cutting would silently lose characters.
fn wrap_cell(text: &str, width: usize) -> Vec<String> {
let mut lines = Vec::new();
let mut line = String::new();
for word in text.split_whitespace() {
let extra = if line.is_empty() { 0 } else { 1 };
if !line.is_empty() && line.chars().count() + extra + word.chars().count() > width {
lines.push(std::mem::take(&mut line));
}
if !line.is_empty() {
line.push(' ');
}
line.push_str(word);
}
lines.push(line);
lines
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use client_core::markdown_blocks::split_blocks;
fn block(src: &str) -> Rendered {
let blocks = split_blocks(src);
assert_eq!(blocks.len(), 1, "test wants exactly one block: {blocks:?}");
render_block(&blocks[0], 16.0)
}
#[test] #[test]
fn plain_paragraph_has_no_spans() { fn plain_paragraph_has_no_spans() {
let (text, spans) = render_markdown("just some words", 16.0); let r = render_markdown("just some words", 16.0);
assert_eq!(text, "just some words"); assert_eq!(r.text, "just some words");
assert!(spans.is_empty()); assert!(r.spans.is_empty());
} }
#[test] #[test]
fn bold_and_italic_produce_spans_over_the_right_range() { fn bold_and_italic_produce_spans_over_the_right_range() {
let (text, spans) = render_markdown("a **bold** and *italic* word", 16.0); let r = render_markdown("a **bold** and *italic* word", 16.0);
assert_eq!(text, "a bold and italic word"); assert_eq!(r.text, "a bold and italic word");
let bold = spans.iter().find(|s| s.bold && !s.italic).unwrap(); let bold = r.spans.iter().find(|s| s.bold && !s.italic).unwrap();
assert_eq!(&text[bold.range.clone()], "bold"); assert_eq!(&r.text[bold.range.clone()], "bold");
let italic = spans.iter().find(|s| s.italic).unwrap(); let italic = r.spans.iter().find(|s| s.italic).unwrap();
assert_eq!(&text[italic.range.clone()], "italic"); assert_eq!(&r.text[italic.range.clone()], "italic");
} }
#[test] #[test]
fn heading_gets_a_bigger_font_size_span() { fn heading_gets_a_bigger_font_size_span() {
let (text, spans) = render_markdown("# A Title\n\nbody text", 16.0); let r = render_markdown("# A Title", 16.0);
assert!(text.starts_with("A Title")); assert!(r.text.starts_with("A Title"));
let heading = spans.iter().find(|s| s.font_size.is_some()).unwrap(); let heading = r.spans.iter().find(|s| s.font_size.is_some()).unwrap();
assert_eq!(&text[heading.range.clone()], "A Title"); assert_eq!(&r.text[heading.range.clone()], "A Title");
assert_eq!(heading.font_size, Some(28.0)); assert_eq!(heading.font_size, Some(24.0));
}
/// Every level draws at its own size, so two levels of nesting are
/// never the same -- `Markdown.kt`'s reason for the ladder.
#[test]
fn every_heading_level_is_a_different_size() {
let mut sizes = Vec::new();
for level in 1..=6 {
let src = format!("{} h", "#".repeat(level));
let r = render_markdown(&src, 16.0);
sizes.push(r.spans.iter().find_map(|s| s.font_size).unwrap());
}
let mut sorted = sizes.clone();
sorted.sort_by(|a, b| b.partial_cmp(a).unwrap());
sorted.dedup();
assert_eq!(sizes, sorted, "the ladder must descend with no repeats");
} }
#[test] #[test]
fn link_is_styled_and_keeps_its_visible_text() { fn a_link_keeps_its_text_and_its_url_and_can_be_hit() {
let (text, spans) = render_markdown("see [the docs](https://example.com) for more", 16.0); let r = render_markdown("see [the docs](https://example.com) for more", 16.0);
assert!(text.contains("the docs")); assert!(r.text.contains("the docs"));
assert!( assert!(
!text.contains("example.com"), !r.text.contains("example.com"),
"the URL should not leak into the visible text" "the URL should not leak into the visible text"
); );
let link = spans.iter().find(|s| s.underline).unwrap(); let link = r.spans.iter().find(|s| s.underline).unwrap();
assert_eq!(&text[link.range.clone()], "the docs"); assert_eq!(&r.text[link.range.clone()], "the docs");
let at = r.text.find("docs").unwrap();
assert_eq!(r.link_at(at).unwrap().url, "https://example.com");
assert!(r.link_at(0).is_none(), "the word 'see' is not the link");
let past = r.text.find("for").unwrap();
assert!(r.link_at(past).is_none());
} }
#[test] #[test]
fn fenced_code_block_is_monospaced() { fn fenced_code_block_is_monospaced_and_highlighted_by_its_language() {
let (text, spans) = render_markdown("before\n\n```\nlet x = 1;\n```\n\nafter", 16.0); let r = block("```rust\nlet x = 1; // note\n```");
let code = spans.iter().find(|s| s.family.is_some()).unwrap(); assert_eq!(r.text, "let x = 1; // note");
assert!(text[code.range.clone()].contains("let x = 1;")); let keyword = r
.spans
.iter()
.find(|s| s.color == Some(syntax_color(Kind::Keyword)))
.expect("a rust fence colours its keywords");
assert_eq!(&r.text[keyword.range.clone()], "let");
let comment = r
.spans
.iter()
.find(|s| s.color == Some(syntax_color(Kind::Comment)))
.unwrap();
assert_eq!(&r.text[comment.range.clone()], "// note");
assert!(r.spans.iter().all(|s| s.range.end <= r.text.len()));
}
/// The half the change had no reason to touch: a fence in a language
/// the highlighter has no rules for must be plain rather than
/// coloured by the nearest language's (`CodeFence.kt`'s
/// `fenceLanguage` doc).
#[test]
fn a_fence_in_an_unknown_language_is_monospace_and_uncoloured() {
let r = block("```brainfuck\nlet x = 1;\n```");
assert_eq!(r.text, "let x = 1;");
assert_eq!(r.spans.len(), 1);
// `Family` is not `Debug`, so this is `assert!` rather than
// `assert_eq!`.
assert!(r.spans[0].family == Some(Family::Monospace));
assert_eq!(r.spans[0].color, Some(CODE_COLOR));
}
/// Multi-byte characters are where a char-indexed highlighter and a
/// byte-indexed span list disagree if the conversion is missing.
#[test]
fn highlight_spans_are_byte_offsets_even_with_multibyte_code() {
let r = block("```rust\nlet s = \"café ☕\"; // é\n```");
for span in &r.spans {
assert!(
r.text.is_char_boundary(span.range.start)
&& r.text.is_char_boundary(span.range.end),
"span {:?} is not on a char boundary of {:?}",
span.range,
r.text
);
}
let string = r
.spans
.iter()
.find(|s| s.color == Some(syntax_color(Kind::String)))
.unwrap();
assert_eq!(&r.text[string.range.clone()], "\"café ☕\"");
}
#[test]
fn an_unterminated_fence_still_renders_what_arrived() {
let r = block("```rust\nlet x = 1;");
assert_eq!(r.text, "let x = 1;");
assert!(
r.spans
.iter()
.any(|s| s.color == Some(syntax_color(Kind::Keyword)))
);
}
#[test]
fn a_bulleted_list_gets_a_marker_per_item_and_indents_nesting() {
let r = block("- one\n- two\n - deep");
assert_eq!(r.text, "\u{2022} one\n\u{2022} two\n \u{25e6} deep");
let markers: Vec<_> = r
.spans
.iter()
.filter(|s| s.color == Some(MARKER_COLOR))
.map(|s| r.text[s.range.clone()].to_string())
.collect();
assert_eq!(markers, ["\u{2022} ", "\u{2022} ", "\u{25e6} "]);
}
#[test]
fn a_numbered_list_counts_from_the_number_it_was_written_with() {
let r = block("3. three\n4. four");
assert_eq!(r.text, "3. three\n4. four");
let markers: Vec<_> = r
.spans
.iter()
.filter(|s| s.color == Some(MARKER_COLOR))
.map(|s| r.text[s.range.clone()].to_string())
.collect();
assert_eq!(markers, ["3. ", "4. "]);
}
#[test]
fn a_quote_is_its_text_and_takes_the_quote_frame() {
let blocks = split_blocks("> quoted words\n> still quoted");
assert_eq!(frame_of(blocks[0].kind), BlockFrame::Quote);
let r = render_block(&blocks[0], 16.0);
assert_eq!(r.text, "quoted words still quoted");
}
#[test]
fn each_block_kind_maps_to_the_frame_it_is_drawn_in() {
use BlockKind::*;
assert_eq!(frame_of(Paragraph), BlockFrame::Plain);
assert_eq!(frame_of(Heading), BlockFrame::Plain);
assert_eq!(frame_of(List), BlockFrame::Plain);
assert_eq!(frame_of(Other), BlockFrame::Plain);
assert_eq!(frame_of(Quote), BlockFrame::Quote);
assert!(matches!(frame_of(Code), BlockFrame::Verbatim { .. }));
assert!(matches!(frame_of(Table), BlockFrame::Verbatim { .. }));
assert_ne!(
frame_of(Code),
frame_of(Table),
"a fence and a table sit on different fills"
);
}
#[test]
fn a_table_pads_its_columns_to_the_widest_cell() {
let r = block("| a | bb |\n|---|---|\n| cccc | d |");
let lines: Vec<&str> = r.text.lines().collect();
assert_eq!(lines[0], "a bb");
assert_eq!(lines[1], "\u{2500}".repeat(8));
assert_eq!(lines[2], "cccc d");
let bold = r.spans.iter().find(|s| s.bold).unwrap();
assert_eq!(&r.text[bold.range.clone()], "a bb");
}
/// The fixture's own table shape: a long cell wraps inside its column
/// instead of making the row one enormous line.
#[test]
fn a_long_table_cell_wraps_inside_its_column() {
let long = "one two three four five six seven eight nine ten eleven twelve";
let r = block(&format!("| k | v |\n|---|---|\n| a | {long} |"));
for line in r.text.lines() {
assert!(
line.chars().count() <= TABLE_MAX_COL + 1 + 2 + 1,
"line too wide: {line:?}"
);
}
assert!(r.text.contains("twelve"));
}
#[test]
fn a_task_list_marks_its_boxes() {
let r = block("- [x] done\n- [ ] not");
assert!(r.text.contains("[x] done"));
assert!(r.text.contains("[ ] not"));
} }
} }
+336 -138
View File
@@ -1,11 +1,18 @@
//! One `iris::widget::list::ListRow` per folded transcript row //! One `iris::widget::list::ListRow` per folded transcript row
//! (`client_core::transcript_fold::TranscriptRow`). Each row's whole text //! (`client_core::transcript_fold::TranscriptRow`). A row is a **column of
//! -- headings, paragraphs, inline styling -- goes through `markdown` into //! one `TextEdit` per top-level markdown block** (paragraph, heading,
//! **one** `TextEdit`, which is what makes it one thing `Selection` //! fence, list, table -- `client_core::markdown_blocks`), each rendered
//! (`selection.rs`) can select and what lets it wrap and scroll as a //! with `markdown`'s inline spans, so that RUST.md's "hard to get back"
//! single buffer, matching RUST.md's "hard to get back" behaviour 2 (rich //! behaviour 2 (rich inline text) still holds within a block and
//! inline text) and half of behaviour 1 (selectable within a row; across //! behaviour 1 (selection) runs across blocks and rows alike through
//! rows is `selection.rs`'s job). //! `selection.rs`.
//!
//! It was one `TextEdit` for the whole message until 2026-09-06, which
//! meant a streamed delta re-shaped every paragraph of a long reply
//! through parley again -- the stream phase was the one place iris trailed
//! Compose on Iris's phone. [`RowBlocks::apply_delta`] is the other half
//! of the fix; docs/DECISIONS.md's entry has what the alternative shapes
//! were and why this one.
//! //!
//! A `TranscriptRow::Tools` (a run of adjacent tool calls, grouped by //! A `TranscriptRow::Tools` (a run of adjacent tool calls, grouped by
//! `client_core::transcript_fold::group_tool_runs`) is the row that proves //! `client_core::transcript_fold::group_tool_runs`) is the row that proves
@@ -16,11 +23,19 @@
//! collapsed summary and the full detail -- the same two-step contract //! collapsed summary and the full detail -- the same two-step contract
//! `list.rs`'s module doc describes for `AGENTS.md`'s `holdTopEdge`. //! `list.rs`'s module doc describes for `AGENTS.md`'s `holdTopEdge`.
use crate::markdown::render_markdown; use crate::markdown::{BlockFrame, Link, frame_of, render_block};
use crate::selection::Selection; use crate::selection::{SelKey, Selection};
use crate::tool::ToolRow;
use client_core::markdown_blocks::{Block, BlockKind, common_prefix, split_blocks};
use client_core::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow}; use client_core::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
use iris::prelude::*; use iris::prelude::*;
use std::{cell::RefCell, rc::Rc, time::Instant}; use std::{cell::RefCell, rc::Rc};
/// The gap drawn between two markdown blocks of one message. A block used
/// to be separated by the blank line `markdown::render_markdown` put in
/// the single buffer; now that each block is its own widget, that spacing
/// has to be the column's.
const BLOCK_GAP_DP: f32 = 8.0;
/// The paragraph size every row's `TextEdit` is built at; markdown headings /// The paragraph size every row's `TextEdit` is built at; markdown headings
/// inside a row scale relative to a fixed set of sizes rather than this one /// inside a row scale relative to a fixed set of sizes rather than this one
@@ -51,7 +66,7 @@ pub fn row_key(key: &client_core::transcript_fold::ItemKey) -> RowKey {
/// The sender label shown above a row's text, and the markdown source to /// The sender label shown above a row's text, and the markdown source to
/// render below it. `None` for a system-style note that has no sender. /// render below it. `None` for a system-style note that has no sender.
fn item_content(item: &TranscriptItem) -> (Option<&str>, String) { pub(crate) fn item_content(item: &TranscriptItem) -> (Option<&str>, String) {
match item { match item {
TranscriptItem::UserMsg { text, .. } => (Some("You"), text.clone()), TranscriptItem::UserMsg { text, .. } => (Some("You"), text.clone()),
TranscriptItem::AssistantMsg { text, .. } => (Some("Claude"), text.clone()), TranscriptItem::AssistantMsg { text, .. } => (Some("Claude"), text.clone()),
@@ -107,37 +122,103 @@ fn tool_call_markdown(tool: &str, input: &str, output: &str) -> String {
out out
} }
/// Build one `TextEdit` from a sender label plus markdown source, register /// The per-block text widgets of one row, kept by `TranscriptScreen` for
/// it with `selection` under `key`, and wire the pointer handlers that /// the row a reply is streaming into, so a delta can replace the block it
/// drive `Selection::drag` -- shared by every row variant below, since a /// lands in instead of re-shaping the whole message
/// selectable row is always "one TextEdit plus this wiring" regardless of /// (docs/DECISIONS.md, 2026-09-06). Nothing else needs it: a row that is
/// what folded it. `list` is threaded through so that same drag can pan /// not the tail never changes.
/// the list instead of selecting, per `Selection::drag`'s own doc. pub struct RowBlocks {
fn build_text_row<Rsc: HasEvents>( /// What each field was built from, in order -- compared against a
/// fresh split to decide what may be kept. See
/// `client_core::markdown_blocks`' module doc for why this is a
/// comparison and not an assumption.
blocks: Vec<Block>,
fields: Vec<WeakWidget<TextEdit>>,
/// Each block's links, shared with its own tap handler so a delta
/// replaces what the handler reads instead of re-registering it.
/// One entry per field, which `apply_delta` asserts.
links: Vec<Rc<RefCell<Vec<Link>>>>,
column: WeakWidget<Span>,
/// The sender label the row was built with. A delta that changes it is
/// not a delta into the same message, so it falls back to a rebuild.
sender: Option<String>,
}
/// Split for display: never empty, so a row with nothing in it yet is
/// still one (empty) text widget rather than no widget at all -- an empty
/// column reports a zero size and the row would vanish from the list.
fn display_blocks(markdown_src: &str) -> Vec<Block> {
let blocks = split_blocks(markdown_src);
if blocks.is_empty() {
vec![Block {
kind: BlockKind::Paragraph,
source: markdown_src.to_string(),
}]
} else {
blocks
}
}
/// The room a fence or a table's text gets inside its panel, and the
/// gap between a quote's bar and its words. `CodeFence.kt` charges the
/// renderer's `codeBlock` padding inside the tinted box and 8dp above and
/// below it; the vertical half is `BLOCK_GAP_DP`'s job here, since the
/// column already separates blocks.
const FRAME_PAD_DP: f32 = 10.0;
/// The bar down a quote's left edge.
const QUOTE_BAR_DP: f32 = 3.0;
/// A verbatim panel's corner, matching the renderer's own rounded fence.
const FRAME_RADIUS_DP: f32 = 8.0;
/// One block's own `TextEdit`, registered with `selection` under
/// `(row, block)` and wired to `Selection::drag` -- the block is the
/// selection unit (`selection::SelKey`) -- plus whatever
/// [`BlockFrame`] its kind is drawn in.
///
/// Returns the field (which `apply_delta` writes into), the widget the
/// column actually holds (the field, or the field inside its frame), and
/// the block's links, shared with the tap handler so a delta can replace
/// them without rebuilding the handler.
fn build_block<Rsc: HasEvents>(
rsc: &mut Rsc, rsc: &mut Rsc,
list: WeakWidget<List>, list: WeakWidget<List>,
selection: Rc<RefCell<Selection>>, selection: Rc<RefCell<Selection>>,
key: RowKey, key: SelKey,
sender: Option<&str>, block: &Block,
markdown_src: &str, ) -> (WeakWidget<TextEdit>, StrongWidget, Rc<RefCell<Vec<Link>>>)
) -> StrongWidget
where where
Rsc::State: FocusHost, Rsc::State: FocusHost + OpenUrl,
{ {
let (text, spans) = render_markdown(markdown_src, BASE_SIZE); let frame = frame_of(block.kind);
let field = wtext(text) let rendered = render_block(block, BASE_SIZE);
.spans(spans) let links = Rc::new(RefCell::new(rendered.links));
let verbatim = matches!(frame, BlockFrame::Verbatim { .. });
let field = wtext(rendered.text)
.spans(rendered.spans)
.editable(EditMode::MultiLine) .editable(EditMode::MultiLine)
.text_align(Align::LEFT) .text_align(Align::LEFT)
.wrap(true) // A fence and a table say what they mean by where their
// characters sit, so they pan sideways rather than wrap
// (`CodeFence.kt`'s `horizontalScroll`) -- and a table is padded
// in *characters*, which only lines up in a monospace face.
.wrap(!verbatim)
.family(if verbatim {
Family::Monospace
} else {
Family::SansSerif
})
.size(BASE_SIZE) .size(BASE_SIZE)
.color(UiColor::WHITE) .color(match frame {
BlockFrame::Quote => crate::markdown::QUOTE_TEXT_COLOR,
_ => crate::markdown::TEXT_COLOR,
})
.add(rsc); .add(rsc);
selection.borrow_mut().register(key, field); selection.borrow_mut().register(key, field);
let tap_links = links.clone();
field field
// `| CursorSense::unclick()` on top of the usual click-or-drag set // `| CursorSense::unclick()` on top of the usual click-or-drag set
// -- this row's own registration only ever needs to see a // -- this block's own registration only ever needs to see a
// gesture's *first* frame (`PressStart`, or a `Pressing` that // gesture's *first* frame (`PressStart`, or a `Pressing` that
// missed it -- `DragGesture::handle`'s idle-recovery branch); once // missed it -- `DragGesture::handle`'s idle-recovery branch); once
// it commits, `DragGesture` takes pointer capture on `list`'s own // it commits, `DragGesture` takes pointer capture on `list`'s own
@@ -149,19 +230,104 @@ where
.on( .on(
CursorSense::click_or_drag() | CursorSense::unclick(), CursorSense::click_or_drag() | CursorSense::unclick(),
move |ctx, rsc| { move |ctx, rsc| {
selection.borrow_mut().drag( let (pos, size, cursor) = (ctx.data.pos, ctx.data.size, ctx.data.cursor.pos);
let outcome = selection.borrow_mut().drag(
rsc, rsc,
list, list,
Some((key, ctx.data.pos, ctx.data.size)), Some((key, pos, size)),
ctx.data.cursor.pos, cursor,
ctx.data.sense, ctx.data.sense,
Instant::now(), ctx.data.cursor.time,
ctx.data.render, ctx.data.render,
); );
// A *tap*, decided by the same `DragArbiter` the pan and
// the selection are: a gesture that panned the list past
// this link, or held long enough to select, must not also
// follow it (`GestureOutcome::Tapped`'s doc).
if outcome == GestureOutcome::Tapped {
let byte = field.edit(rsc).byte_at(cursor, size);
let url = tap_links
.borrow()
.iter()
.find(|l| l.range.contains(&byte))
.map(|l| l.url.clone());
if let Some(url) = url {
log::info!("iris link: opening {url}");
<Rsc::State as OpenUrl>::open_url(ctx.state, &url);
}
}
}, },
) )
.add(rsc); .add(rsc);
// The column holds the *framed* widget; the field is what
// `apply_delta` writes into and what `Selection` resolves. Keeping
// the two apart is what lets a fence gain a background without the
// delta path knowing anything about frames.
let framed = match frame {
BlockFrame::Plain => field.width(rest(1)).add_strong(rsc).any(),
BlockFrame::Verbatim { fill } => field
.scrollable_on(Axis::X)
.masked()
.pad(dp(FRAME_PAD_DP))
.background(rect(fill).radius(dp(FRAME_RADIUS_DP)))
.width(rest(1))
.add_strong(rsc)
.any(),
// A `Stack` (through `background`) rather than a two-child
// `Span(Dir::RIGHT)`: the bar is drawn behind text padded past
// it, which is the same picture with one widget fewer and
// without `Span`'s provisional full-region pass. That pass is
// also what first surfaced the `mov`-then-`reposition` assert
// docs/RUST.md's P1a box records as still open, so the shape
// with fewer passes is the one to prefer here.
BlockFrame::Quote => field
.width(rest(1))
.pad(Padding {
left: dp(QUOTE_BAR_DP + FRAME_PAD_DP),
..Padding::ZERO
})
.background(rect(crate::markdown::QUOTE_BAR_COLOR).width(dp(QUOTE_BAR_DP)))
.width(rest(1))
.add_strong(rsc)
.any(),
};
(field, framed, links)
}
/// Build a row from a sender label plus markdown source: a column of one
/// `TextEdit` per top-level markdown block, under the sender's own label.
///
/// One widget per block rather than one per message is what makes a
/// streamed delta cost the last block instead of the whole reply -- see
/// [`RowBlocks::apply_delta`] for the other half, and
/// `client_core::markdown_blocks` for the split. Selection still runs
/// across the whole transcript; the unit it steps in is a block now rather
/// than a row (`selection::SelKey`).
fn build_text_row<Rsc: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<List>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
sender: Option<&str>,
markdown_src: &str,
) -> (StrongWidget, RowBlocks)
where
Rsc::State: FocusHost + OpenUrl,
{
let blocks = display_blocks(markdown_src);
let mut column = Span::empty(Dir::DOWN).gap(dp(BLOCK_GAP_DP));
let mut fields = Vec::with_capacity(blocks.len());
let mut links = Vec::with_capacity(blocks.len());
for (i, block) in blocks.iter().enumerate() {
let (field, framed, block_links) =
build_block(rsc, list, selection.clone(), (key, i as u32), block);
fields.push(field);
links.push(block_links);
column.push(framed);
}
let column = column.add(rsc);
// `.add` (weak), not `.add_strong` -- `header` is about to be embedded // `.add` (weak), not `.add_strong` -- `header` is about to be embedded
// as a child of the `.span(Dir::DOWN)` below, whose own composition is // as a child of the `.span(Dir::DOWN)` below, whose own composition is
// what performs the *one* real strong registration each child gets. // what performs the *one* real strong registration each child gets.
@@ -178,12 +344,108 @@ where
None => Span::empty(Dir::DOWN).add(rsc), None => Span::empty(Dir::DOWN).add(rsc),
}; };
(header, field.width(rest(1))) let widget = (header, column.width(rest(1)))
.span(Dir::DOWN) .span(Dir::DOWN)
.gap(dp(4)) .gap(dp(4))
.pad(dp(10)) .pad(dp(10))
.add_strong(rsc) .add_strong(rsc)
.any() .any();
(
widget,
RowBlocks {
blocks,
fields,
links,
column,
sender: sender.map(str::to_string),
},
)
}
impl RowBlocks {
/// Bring this row up to date with `markdown_src` **without** re-laying
/// out the blocks that did not change, and say whether that was
/// possible. `false` means the caller must rebuild the row the
/// ordinary way: an earlier block was rewritten (markdown allows it --
/// a trailing `---` turns the paragraph above into a heading), the
/// sender changed, or the message got shorter.
///
/// This is the whole point of the per-block column: a delta arriving
/// in a 3,000-character reply touches one `set_with_spans` on the last
/// block, so parley re-shapes that block and nothing else.
pub fn apply_delta<Rsc: HasEvents>(
&mut self,
rsc: &mut Rsc,
list: WeakWidget<List>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
sender: Option<&str>,
markdown_src: &str,
) -> bool
where
Rsc::State: FocusHost + OpenUrl,
{
if self.sender.as_deref() != sender {
return false;
}
let new_blocks = display_blocks(markdown_src);
let common = common_prefix(&self.blocks, &new_blocks);
// Everything already drawn must either be kept whole (`common ==
// len`, a pure append) or be kept except for the last block, which
// is the one a delta lands in. Anything else means an already
// laid-out block is no longer what it was.
if new_blocks.len() < self.blocks.len() || common + 1 < self.blocks.len() {
return false;
}
// A block's *frame* is built around its widget once and never
// rewritten, so a block whose kind changed under the delta (the
// paragraph that a `|---|` line turns into a table) cannot take
// this path -- it would keep prose's appearance with a table's
// text in it. Only the last block can differ at all, by the check
// above.
if new_blocks.len() == self.blocks.len()
&& common < self.blocks.len()
&& new_blocks[common].kind != self.blocks[common].kind
{
return false;
}
debug_assert!(
self.fields.len() == self.blocks.len() && self.links.len() == self.blocks.len(),
"one field and one link list per block: {} fields, {} links, {} blocks",
self.fields.len(),
self.links.len(),
self.blocks.len()
);
for (i, block) in new_blocks.iter().enumerate().skip(common) {
match (self.fields.get(i), self.links.get(i)) {
(Some(field), Some(links)) => {
let rendered = render_block(block, BASE_SIZE);
field
.edit(rsc)
.set_with_spans(&rendered.text, rendered.spans);
// Replaced together with the text: a link range left
// over from the previous delta points into a string
// that no longer exists.
*links.borrow_mut() = rendered.links;
}
_ => {
let (field, framed, links) =
build_block(rsc, list, selection.clone(), (key, i as u32), block);
self.fields.push(field);
self.links.push(links);
// `get_mut` marks the column dirty, which is what gets
// the new block drawn; its removal half is the row's
// own, since the column owns the child strongly.
if let Some(column) = rsc.ui_mut().widgets.get_mut(&self.column) {
column.push(framed);
}
}
}
}
self.blocks = new_blocks;
true
}
} }
fn build_single<Rsc: HasEvents>( fn build_single<Rsc: HasEvents>(
@@ -192,106 +454,28 @@ fn build_single<Rsc: HasEvents>(
selection: Rc<RefCell<Selection>>, selection: Rc<RefCell<Selection>>,
key: RowKey, key: RowKey,
item: &TranscriptItem, item: &TranscriptItem,
) -> StrongWidget ) -> (StrongWidget, RowBlocks)
where where
Rsc::State: FocusHost, Rsc::State: FocusHost + OpenUrl,
{ {
let (sender, markdown_src) = item_content(item); let (sender, markdown_src) = item_content(item);
build_text_row(rsc, list, selection, key, sender, &markdown_src) build_text_row(rsc, list, selection, key, sender, &markdown_src)
} }
/// A run of adjacent tool calls: collapsed to a one-line summary by /// What a row keeps so the next event can change part of it instead of
/// default, expanding in place to every call's own tool/input/output on /// all of it -- one variant per kind of row that has such a path.
/// tap -- see the module doc for the hold-the-edge contract this wires ///
/// against `list`. /// Two mechanisms would have been two answers to the same question ("what
fn build_tools<Rsc: HasEvents>( /// can this row do cheaply?"), so the caller holds one of these for its
rsc: &mut Rsc, /// tail row and asks it, rather than holding a `RowBlocks` and a
list: WeakWidget<List>, /// `ToolRow` and choosing between them at each call site.
selection: Rc<RefCell<Selection>>, pub enum TailRow {
key: RowKey, /// A message: a column of one text widget per markdown block, so a
calls: Vec<TranscriptItem>, /// streamed delta costs the last block.
) -> StrongWidget Blocks(RowBlocks),
where /// A tool call or a run of them: a column of cards, so an arriving
Rsc::State: FocusHost, /// result costs one card.
{ Tools(ToolRow),
let expanded = Rc::new(RefCell::new(false));
// `.add_strong` (not `.add`) because nothing else in the tree holds a
// strong reference to this `WidgetPtr` the way a container's own
// `add_strong`-on-its-children does for an ordinary child -- this row
// *is* the top of its own subtree, so it has to own itself.
let ptr_strong = WidgetPtr::new().add_strong(rsc);
let ptr = ptr_strong.weak();
let summary_text = format!("\u{25b8} {} tool calls", calls.len());
let full_text = calls
.iter()
.map(|c| match c {
TranscriptItem::ToolRun {
tool,
input,
output,
..
} => tool_call_markdown(tool, input, output),
other => item_content(other).1,
})
.collect::<Vec<_>>()
.join("\n\n");
fn build_content<Rsc: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<List>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
expanded: bool,
summary: &str,
full: &str,
) -> StrongWidget
where
Rsc::State: FocusHost,
{
let text = if expanded { full } else { summary };
build_text_row(rsc, list, selection, key, Some("Tools"), text)
}
let content = build_content(
rsc,
list,
selection.clone(),
key,
false,
&summary_text,
&full_text,
);
ptr(rsc).set(content);
ptr.on(CursorSense::click(), move |ctx, rsc| {
// `List::note_tap` wants a viewport-relative position, but the
// click event only knows where inside *this row* it landed
// (`ctx.data.pos`) -- `List::extent` (last frame's on-screen box
// for this row's key) is what turns the two into the position
// `list.rs`'s hold-the-edge layout pass resolves against, per the
// module doc's contract.
let (top, _bottom) = list(rsc).extent(key).unwrap_or((0.0, 0.0));
list(rsc).note_tap(top + ctx.data.pos.y);
let was_expanded = *expanded.borrow();
*expanded.borrow_mut() = !was_expanded;
let content = build_content(
rsc,
list,
selection.clone(),
key,
!was_expanded,
&summary_text,
&full_text,
);
// The old content's `StrongWidget` is freed when this drops --
// the removal half of the row this click just replaced.
let _old = ptr(rsc).replace(content);
})
.add(rsc);
ptr_strong.any()
} }
pub fn build_row<Rsc: HasEvents>( pub fn build_row<Rsc: HasEvents>(
@@ -299,18 +483,32 @@ pub fn build_row<Rsc: HasEvents>(
list: WeakWidget<List>, list: WeakWidget<List>,
selection: Rc<RefCell<Selection>>, selection: Rc<RefCell<Selection>>,
row: &FoldedRow, row: &FoldedRow,
) -> (RowKey, StrongWidget) working: bool,
) -> (RowKey, StrongWidget, Option<TailRow>)
where where
Rsc::State: FocusHost, Rsc::State: FocusHost + OpenUrl,
{ {
match row { // A lone tool call is a card too, not a message with markdown in it:
FoldedRow::Single(item) => { // `group_tool_runs` leaves one call as a `Single` because "Called 1
let key = row_key(&item.key()); // tool" hides a card to say the same thing in more words, and the
(key, build_single(rsc, list, selection, key, item)) // *card* is what both cases draw (`ToolRows.kt`).
let calls = match row {
FoldedRow::Single(item @ TranscriptItem::ToolRun { .. }) => {
Some(std::slice::from_ref(item))
} }
FoldedRow::Tools(calls) => { FoldedRow::Tools(calls) => Some(calls.as_slice()),
FoldedRow::Single(_) => None,
};
if let Some(calls) = calls {
let key = row_key(&calls[0].key()); let key = row_key(&calls[0].key());
(key, build_tools(rsc, list, selection, key, calls.clone())) let (widget, tools) =
} crate::tool::build_tool_row(rsc, list, selection, key, calls.to_vec(), working);
return (key, widget, Some(TailRow::Tools(tools)));
} }
let FoldedRow::Single(item) = row else {
unreachable!("every Tools row took the branch above");
};
let key = row_key(&item.key());
let (widget, blocks) = build_single(rsc, list, selection, key, item);
(key, widget, Some(TailRow::Blocks(blocks)))
} }
+114 -24
View File
@@ -33,9 +33,17 @@
use iris::prelude::*; use iris::prelude::*;
use std::{collections::BTreeMap, time::Instant}; use std::{collections::BTreeMap, time::Instant};
/// What this selects between: a row's `RowKey` and the index of one
/// markdown **block** inside it. A row is a column of one text widget per
/// block since 2026-09-06 (`client_core::markdown_blocks`, and
/// docs/DECISIONS.md for why), so the block, not the row, is the unit --
/// `(row, block)` compares lexicographically, which is reading order for
/// both levels, so every range query below is unchanged.
pub type SelKey = (RowKey, u32);
pub struct Selection { pub struct Selection {
rows: BTreeMap<RowKey, WeakWidget<TextEdit>>, rows: BTreeMap<SelKey, WeakWidget<TextEdit>>,
anchor: Option<(RowKey, Vec2)>, anchor: Option<(SelKey, Vec2)>,
/// One gesture shared by every row's drag handler -- RUST.md's I5 /// One gesture shared by every row's drag handler -- RUST.md's I5
/// gesture conflict (a row's own `click_or_drag()` and a list-level /// gesture conflict (a row's own `click_or_drag()` and a list-level
/// pan wanting the same touch gesture). See `drag` below, and /// pan wanting the same touch gesture). See `drag` below, and
@@ -63,16 +71,38 @@ impl Selection {
} }
/// A row's selectable text became visible/known. Every addition here /// A row's selectable text became visible/known. Every addition here
/// needs its removal (`unregister`) -- called when `List` evicts the /// needs its removal (`unregister`, or `clear` for all of them at
/// row (`pop_front`/`pop_back`), so this map never outgrows however /// once) -- called when `List` evicts the row (`pop_front`/
/// many rows are actually loaded. /// `pop_back`/`clear`), so this map never outgrows however many rows
pub fn register(&mut self, key: RowKey, text: WeakWidget<TextEdit>) { /// are actually loaded. `List::place` guards the twin of this same
/// class of bug on the list's own side (`list.rs`'s `slot_exists`
/// assertion) -- a derived handle that silently outlives what it
/// points to; the next caller adding a third row-keyed side table
/// should read both.
pub fn register(&mut self, key: SelKey, text: WeakWidget<TextEdit>) {
self.rows.insert(key, text); self.rows.insert(key, text);
} }
pub fn unregister(&mut self, key: RowKey) { /// Drops every registration at once -- the same shape `List::clear()`
self.rows.remove(&key); /// clears the list, and what `TranscriptScreen::apply`'s `Rebuild` arm
if self.anchor.map(|(k, _)| k) == Some(key) { /// calls right before it, since a full rebuild drops every row's old
/// widget and `push_row` re-`register`s each surviving key's new one
/// as it goes (review docs/REVIEW-2026-09-06.md finding 1: the
/// `Rebuild` arm used to call only `List::clear()`, leaving any key
/// dropped by the regroup -- present in the old rows, absent from the
/// new ones -- pointing at a widget the list had just freed, so the
/// next long-press anywhere panicked in `begin`'s deselect loop).
pub fn clear(&mut self) {
self.rows.clear();
self.anchor = None;
}
/// Forgets every block of one row -- a row is registered block by
/// block, so its removal has to take all of them, and taking only the
/// first is how a freed widget would be left behind in this map.
pub fn unregister(&mut self, row: RowKey) {
self.rows.retain(|&(k, _), _| k != row);
if self.anchor.map(|((k, _), _)| k) == Some(row) {
self.anchor = None; self.anchor = None;
} }
} }
@@ -82,8 +112,8 @@ impl Selection {
/// gives `key`'s row a collapsed caret at `pos` -- a plain click that /// gives `key`'s row a collapsed caret at `pos` -- a plain click that
/// never turns into a drag leaves exactly this and nothing else /// never turns into a drag leaves exactly this and nothing else
/// selected. /// selected.
pub fn begin(&mut self, ui: &mut impl UiRsc, key: RowKey, pos: Vec2, size: Vec2) { pub fn begin(&mut self, ui: &mut impl UiRsc, key: SelKey, pos: Vec2, size: Vec2) {
let rows: Vec<RowKey> = self.rows.keys().copied().collect(); let rows: Vec<SelKey> = self.rows.keys().copied().collect();
for k in rows { for k in rows {
if k != key if k != key
&& let Some(w) = self.rows.get(&k) && let Some(w) = self.rows.get(&k)
@@ -99,7 +129,7 @@ impl Selection {
/// The drag continues, now over `key`'s row at `pos`. See the module /// The drag continues, now over `key`'s row at `pos`. See the module
/// doc for the anchor-row shortcut. /// doc for the anchor-row shortcut.
pub fn extend(&mut self, ui: &mut impl UiRsc, key: RowKey, pos: Vec2, size: Vec2) { pub fn extend(&mut self, ui: &mut impl UiRsc, key: SelKey, pos: Vec2, size: Vec2) {
let Some((anchor_key, _anchor_pos)) = self.anchor else { let Some((anchor_key, _anchor_pos)) = self.anchor else {
return; return;
}; };
@@ -114,7 +144,7 @@ impl Selection {
} else { } else {
(key, anchor_key) (key, anchor_key)
}; };
let in_range: Vec<RowKey> = self.rows.range(lo..=hi).map(|(&k, _)| k).collect(); let in_range: Vec<SelKey> = self.rows.range(lo..=hi).map(|(&k, _)| k).collect();
for k in &in_range { for k in &in_range {
let Some(w) = self.rows.get(k).copied() else { let Some(w) = self.rows.get(k).copied() else {
continue; continue;
@@ -130,7 +160,7 @@ impl Selection {
w.edit(ui).select_all(); w.edit(ui).select_all();
} }
} }
let outside: Vec<RowKey> = self let outside: Vec<SelKey> = self
.rows .rows
.keys() .keys()
.copied() .copied()
@@ -143,6 +173,47 @@ impl Selection {
} }
} }
/// The block indices currently registered for `row`, in order. For a
/// test asserting that a row's removal or rebuild took every one of
/// its blocks with it -- the contract `unregister` states and the one
/// a caller can get wrong silently, since a stale handle only shows
/// up as a panic on some later, unrelated press.
#[cfg(test)]
pub fn registered_blocks(&self, row: RowKey) -> impl Iterator<Item = u32> + '_ {
self.rows
.keys()
.filter(move |(k, _)| *k == row)
.map(|&(_, b)| b)
}
/// Which registered block is under `pos_window`, with the position
/// and size that block's own `TextEdit` wants (block-local, the way
/// `begin`/`extend` are given them by a block's own pointer handler).
///
/// For the pointer-captured half of a drag, where the event no longer
/// reaches the widget under the finger and the list-level handler has
/// to say where the finger is. It asks the render state for each
/// block's drawn box rather than doing the arithmetic from the row's
/// extent -- the box is what a hit test resolves against anyway, and
/// it means this and a block's own handler cannot disagree about
/// where a block is. O(blocks loaded), on one frame of a drag.
pub fn locate(
&self,
ui: &impl UiRsc,
render: &UiRenderState,
pos_window: Vec2,
) -> Option<(SelKey, Vec2, Vec2)> {
for (&key, w) in &self.rows {
let Some(px) = render.window_region(w, ui) else {
continue;
};
if px.contains(pos_window) {
return Some((key, pos_window - px.top_left, px.size()));
}
}
None
}
/// Whether any row currently has a non-empty selection -- what a fresh /// Whether any row currently has a non-empty selection -- what a fresh
/// press consults so `drag` knows whether an early horizontal move is /// press consults so `drag` knows whether an early horizontal move is
/// "start dragging the selection handle" rather than an ordinary tap. /// "start dragging the selection handle" rather than an ordinary tap.
@@ -174,16 +245,20 @@ impl Selection {
/// than the last one. `render` is `CursorData`'s own field -- what /// than the last one. `render` is `CursorData`'s own field -- what
/// `DragGesture` needs to take pointer capture. /// `DragGesture` needs to take pointer capture.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
/// Returns what the gesture decided this frame, so a caller with its
/// own meaning for a *tap* -- a row's link handler -- reads it from
/// the one arbiter that already knows, rather than timing a second
/// one beside it (which would disagree the moment either changed).
pub fn drag( pub fn drag(
&mut self, &mut self,
ui: &mut impl UiRsc, ui: &mut impl UiRsc,
list: WeakWidget<List>, list: WeakWidget<List>,
row: Option<(RowKey, Vec2, Vec2)>, row: Option<(SelKey, Vec2, Vec2)>,
pos_window: Vec2, pos_window: Vec2,
sense: CursorSense, sense: CursorSense,
now: Instant, now: Instant,
render: &UiRenderState, render: &UiRenderState,
) { ) -> GestureOutcome {
if matches!(sense, CursorSense::PressStart(_)) { if matches!(sense, CursorSense::PressStart(_)) {
// A fresh touch-down cancels any fling still coasting from // A fresh touch-down cancels any fling still coasting from
// the previous gesture -- `List::fling`'s own doc, and // the previous gesture -- `List::fling`'s own doc, and
@@ -219,9 +294,20 @@ impl Selection {
// happened to end with the finger still moving, and never a // happened to end with the finger still moving, and never a
// tap/long-press that never left `Undecided` -- exactly what // tap/long-press that never left `Undecided` -- exactly what
// `DragGesture`'s `Some(v)` already encodes. // `DragGesture`'s `Some(v)` already encodes.
GestureOutcome::Released(Some(v)) => list(ui).fling(-v), GestureOutcome::Released(Some(v)) => {
GestureOutcome::Released(None) => {} list(ui).fling(-v);
// The half that actually makes it move -- see
// `List::fling`'s doc. Without it the velocity is
// computed, stored, and never advanced by anything.
let id = list.id();
ui.ui_mut().animate(id);
} }
// A tap is nobody's business here -- `row.rs` reads it from
// the returned outcome and follows a link if one was under
// the finger.
GestureOutcome::Released(None) | GestureOutcome::Tapped => {}
}
outcome
} }
/// The concatenated selected text, in row order, `None` if nothing is /// The concatenated selected text, in row order, `None` if nothing is
@@ -321,7 +407,7 @@ mod tests {
let list = rsc.ui.widgets.add_strong(List::new(Axis::Y)).weak(); let list = rsc.ui.widgets.add_strong(List::new(Axis::Y)).weak();
let mut sel = Selection::new(); let mut sel = Selection::new();
sel.register(1, field); sel.register((1, 0), field);
assert!(sel.gesture.is_idle()); assert!(sel.gesture.is_idle());
let render = UiRenderState::new(); let render = UiRenderState::new();
@@ -332,7 +418,7 @@ mod tests {
sel.drag( sel.drag(
&mut rsc, &mut rsc,
list, list,
Some((1, Vec2::ZERO, size)), Some(((1, 0), Vec2::ZERO, size)),
Vec2::new(540.0, 700.0), Vec2::new(540.0, 700.0),
CursorSense::Pressing(CursorButton::Left), CursorSense::Pressing(CursorButton::Left),
now, now,
@@ -346,7 +432,7 @@ mod tests {
} }
#[test] #[test]
fn unregister_forgets_the_row_and_clears_a_matching_anchor() { fn unregister_forgets_every_block_of_the_row_and_clears_a_matching_anchor() {
let mut rsc = TestRsc { let mut rsc = TestRsc {
ui: UiData::default(), ui: UiData::default(),
}; };
@@ -360,9 +446,13 @@ mod tests {
.weak(); .weak();
let mut sel = Selection::new(); let mut sel = Selection::new();
sel.register(5, field); // Two blocks of the same row, which is what `unregister` has to
sel.anchor = Some((5, Vec2::ZERO)); // take together -- removing only the first is how a freed widget
assert_eq!(sel.rows.len(), 1); // gets left in this map.
sel.register((5, 0), field);
sel.register((5, 1), field);
sel.anchor = Some(((5, 1), Vec2::ZERO));
assert_eq!(sel.rows.len(), 2);
sel.unregister(5); sel.unregister(5);
assert!(sel.rows.is_empty()); assert!(sel.rows.is_empty());
+897
View File
@@ -0,0 +1,897 @@
//! Tool-call cards and the runs they are grouped into -- the port of
//! `ToolRows.kt`/`ToolInput.kt` (RUST.md's P1b).
//!
//! One card per call. Closed, it is a single line: the tool's name and
//! what the call is for ([`client_core::tool_summary::parse_tool_input`]'s
//! `title`). The command itself is not on it, because a wrapped command
//! turns one row into four and a run of them into a wall. Open, it shows
//! the description, the input and the output.
//!
//! **A collapsed card lays out its summary line and nothing else.** Not an
//! optimisation -- the discipline this crate is built to. The bench
//! fixture carries tool outputs of tens of kilobytes, and a collapsed card
//! that built a text widget for one would pay parley for text nobody can
//! see. `collapsed_cards_shape_only_their_summary_lines` in `lib.rs` holds
//! it, counting `UiRenderState`'s text-shape counter the same way
//! `a_delta_into_a_long_reply_...` counts it for a streamed delta.
//!
//! **Two or more adjacent calls are one group** -- decided in
//! `client_core::transcript_fold::group_tool_runs`/`adopt_run` and never
//! re-derived here. A group is a header, a column of cards on its own
//! surface, and a bar at its foot: it closes from either end, because a
//! long group's header scrolls off while its last call is still on screen,
//! and the reader who wants it shut is looking at the bottom.
//!
//! **A result arriving replaces one card.** [`ToolRow::apply_calls`] is
//! the group's half of `RowBlocks::apply_delta`'s discipline: a group is a
//! column of cards, and a `ToolEnd` changes exactly one of them.
//!
//! **Every tap here is a tap** -- `GestureOutcome::Tapped` out of the one
//! `DragArbiter` `Selection` already owns, never a second detector. A
//! finger that panned the list past a card must not also open it; that
//! rule is written once, in the gesture machine, and this file only reads
//! its answer.
use crate::markdown::{TEXT_COLOR, VERBATIM_BACKGROUND, highlight_into};
use crate::selection::Selection;
use client_core::tool_summary::{ToolInput, parse_tool_input};
use client_core::transcript_fold::{ToolState, TranscriptItem};
use iris::prelude::*;
use std::{cell::Cell, cell::RefCell, collections::HashMap, rc::Rc};
/// A card's own fill: Surface 0, what Material's filled `Card` resolves to
/// under `Theme.kt`'s scheme. One step *above* the page, so a card reads
/// as an object on it.
const CARD_FILL: UiColor = UiColor::new(0x31, 0x32, 0x44, 255);
/// The surface a group's cards sit on: Mantle, one step *below* the page.
/// That surface is the single cue saying these calls belong together, and
/// it goes below rather than above because the cards are already above --
/// two steps in the same direction render as one flat block.
const GROUP_FILL: UiColor = UiColor::new(0x18, 0x18, 0x25, 255);
/// A tool's name, and any of the call's own words.
const NAME_COLOR: UiColor = TEXT_COLOR;
/// The summary line and the leftover input fields: Subtext 0, the Compose
/// app's `onSurfaceVariant` -- structure about the call rather than the
/// call's own words.
const MUTED_COLOR: UiColor = UiColor::new(0xA6, 0xAD, 0xC8, 255);
/// Waiting on a person -- Peach, `Theme.kt`'s `awaitingColor`. The same
/// colour a question card takes, because it is the same fact.
const AWAITING_COLOR: UiColor = UiColor::new(0xFA, 0xB3, 0x87, 255);
/// The call itself failed -- Red, the scheme's `error`/`failedColor`.
const FAILED_COLOR: UiColor = UiColor::new(0xF3, 0x8B, 0xA8, 255);
/// **Nobody found out** -- Yellow, `Theme.kt`'s `warningColor`. Its own
/// colour *and* its own word: the expensive confusion is between this and
/// a call that finished having printed nothing, those two share an empty
/// output, and a difference in kind cannot be carried by colour alone.
const UNKNOWN_COLOR: UiColor = UiColor::new(0xF9, 0xE2, 0xAF, 255);
/// A tool's name (Material `titleSmall`).
const NAME_SIZE: f32 = 14.0;
/// The summary line, and the input and output text (`bodySmall`).
const BODY_SIZE: f32 = 12.0;
/// The state word, the "Output" heading and the group's own count
/// (`labelSmall`).
const LABEL_SIZE: f32 = 11.0;
/// The room inside a card, and so the height a bar of one line of text
/// comes to (`ToolRows.kt`'s `GROUP_INSET_LARGE`).
const CARD_PAD_DP: f32 = 12.0;
/// A card's corner: `shapes.medium`, the same as every other card in the
/// app.
const CARD_RADIUS_DP: f32 = 12.0;
/// The gap between the parts of a card's header line, and between the
/// stacked parts of an open card.
const GAP_DP: f32 = 8.0;
/// Smaller than a card's radius, and deliberately: a verbatim block sits
/// *inside* one, and a rounded rectangle drawn at the same radius as the
/// one behind it reads as a misprint (`RawBlock.kt`).
const RAW_RADIUS_DP: f32 = 4.0;
/// The room inside a verbatim block.
const RAW_PAD_DP: f32 = 8.0;
/// How much of a tool's output an open card draws before it offers the
/// rest behind a tap.
///
/// **A divergence from Compose, on purpose.** `ToolCard` draws the whole
/// output however long, and gets away with it because a Compose `Text`
/// inside a `LazyColumn` is laid out lazily; here the output is one text
/// widget, and shaping a hundred kilobytes of it through parley is the
/// cost `docs/EXPLORER.md`'s `EDIT_LIMIT` was measured against. Lines
/// *and* bytes because the two run out at different times -- a diff is
/// many short lines, a minified file is one enormous one.
const OUTPUT_LINES: usize = 80;
const OUTPUT_BYTES: usize = 4096;
/// A cap of nothing would draw an empty panel and a "Show all" for every
/// output there is, which reads as a rendering fault rather than as a cap.
/// Checked at compile time, since both are constants.
const _: () = assert!(OUTPUT_LINES > 0 && OUTPUT_BYTES > 0);
/// The mark that says a card opens, always drawn from the **monospace**
/// face.
///
/// Not a style choice: `NotoSans-Regular.ttf`, which every other string
/// here is set in, has no glyph at U+25B8/U+25BE/U+25B4 at all, while
/// `NotoSansMono-Regular.ttf` does -- read out of both bundled `cmap`s on
/// 2026-09-06. A missing glyph is the failure nobody who wrote the code
/// ever sees, so the face that has the glyph is named at the one place the
/// character is written. IRIS_TODO's "a drawn chevron" has the real fix,
/// which needs a line primitive iris does not have.
const CLOSED_MARK: &str = "\u{25b8}";
const OPEN_MARK: &str = "\u{25be}";
const UP_MARK: &str = "\u{25b4}";
/// Which cards the reader has opened, and which have had their whole
/// output asked for.
///
/// Outside the widget tree on purpose: a card is rebuilt when its result
/// arrives, and being open is the reader's state rather than the event's
/// -- held in the widget, it would silently close the moment the tool
/// answered. Keyed by the call's own id, which survives a regroup. Its
/// path out is [`ToolRow::apply_calls`], which drops the entry for any
/// call no longer in the row.
#[derive(Default)]
struct ToolRowState {
group_expanded: bool,
open: HashMap<String, bool>,
whole_output: HashMap<String, bool>,
}
/// Everything a handler needs to redraw part of this row, in one `Rc` so
/// that a handler registered once keeps working against calls that arrive
/// later. The rebuild functions read `calls` fresh rather than capturing a
/// call, which is what lets [`ToolRow::apply_calls`] replace a card's
/// content without re-registering its gesture.
struct Shared {
calls: RefCell<Vec<TranscriptItem>>,
state: RefCell<ToolRowState>,
/// One `WidgetPtr` per call, in order -- what makes a result cost one
/// card. Empty while the group is collapsed, because a collapsed group
/// draws no cards at all. Its path out is [`build_content`], which
/// clears it before building whatever replaces them.
cards: RefCell<Vec<WeakWidget<WidgetPtr>>>,
/// The whole row's content, swapped when the group opens or closes.
/// Filled in immediately after construction -- the `WidgetPtr` cannot
/// exist before the `Rc` every handler inside it captures.
content: RefCell<Option<WeakWidget<WidgetPtr>>>,
list: WeakWidget<List>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
/// Whether a call in this row could still be running -- the caller's
/// `session_working`, and `false` for every row behind the newest,
/// whose turn has already ended. The one input to [`ToolState`] that
/// is not a property of the call itself, and what separates "still
/// going" from "nobody found out".
working: Cell<bool>,
}
/// One transcript row's worth of tool calls, kept by the caller for the
/// row a result can still land in -- the tool-call counterpart of
/// [`crate::row::RowBlocks`], and the reason a `ToolEnd` costs one card
/// rather than a row.
pub struct ToolRow {
shared: Rc<Shared>,
}
/// Register `f` as this widget's **tap**, panning the list instead when
/// the finger moves.
///
/// The one gesture entry point in this file. `Selection::drag` with no row
/// is the same call `row.rs` makes with one: it drives the shared
/// `DragArbiter`, so a drag starting on a card scrolls (and flings) the
/// transcript exactly as one starting on a paragraph does, and only a
/// press that committed to nothing comes back as `Tapped`. A bare
/// `CursorSense::click()` here would be a second, disagreeing detector --
/// it fires at the end of a pan too, so every scroll that began on a card
/// would also toggle it.
fn on_tap<Rsc: HasEvents>(
rsc: &mut Rsc,
ptr: WeakWidget<WidgetPtr>,
shared: &Rc<Shared>,
f: impl Fn(&mut Rsc) + 'static,
) where
Rsc::State: FocusHost + OpenUrl,
{
let (list, selection) = (shared.list, shared.selection.clone());
ptr.on(
CursorSense::click_or_drag() | CursorSense::unclick(),
move |ctx, rsc| {
let outcome = selection.borrow_mut().drag(
rsc,
list,
None,
ctx.data.cursor.pos,
ctx.data.sense,
ctx.data.cursor.time,
ctx.data.render,
);
if outcome == GestureOutcome::Tapped {
f(rsc);
}
},
)
.add(rsc);
}
/// Hold the edge the reader is looking at while this row changes height.
///
/// `List::note_tap` wants a viewport-relative position and this row only
/// knows its own box, so `List::extent` (last frame's on-screen box for
/// this key) turns the two into the position `list.rs`'s hold-the-edge
/// pass resolves against -- the two-step contract that module's doc
/// describes for `AGENTS.md`'s `holdTopEdge`.
fn note_tap(rsc: &mut impl UiRsc, shared: &Shared) {
// Only when the list actually has an extent for this row. `None`
// means the row has not been drawn yet -- which happens the moment
// something opens a group before the first frame
// (`TranscriptScreen::expand_tail_tools`, the headless screenshot) --
// and standing in `0.0` for it tells the layout pass to hold an edge
// at the top of the viewport that nothing was ever at. The whole list
// then places itself against that invented anchor: rows drawn at each
// other's cached heights, tool cards as empty bars with their text a
// group's height below them (`docs/bench/p1b-2026-09-06/`'s first
// attempt). Nothing to hold is not the same as an edge at zero.
if let Some((top, _bottom)) = (shared.list)(rsc).extent(shared.key) {
(shared.list)(rsc).note_tap(top);
}
}
fn text<Rsc>(content: impl Into<String>, size: f32, color: UiColor) -> TextBuilder<Rsc> {
wtext(content)
.size(size)
.color(color)
.text_align(Align::LEFT)
}
/// A verbatim block: monospace on the surface everything verbatim in this
/// app sits on, not wrapped, panning sideways on a finger.
///
/// Not wrapped for `ToolInput.kt`'s reason -- a wrapped command hides
/// where its arguments end, and the long one is the one being read
/// closely. A long line is **clipped** here rather than pannable, which a
/// markdown fence (`row.rs`'s `BlockFrame::Verbatim`) is not: adding
/// `.scrollable_on(Axis::X)` to this non-editable `Text` made it draw
/// nothing at all -- an empty panel where the command should be, seen on
/// 2026-09-06 in `docs/bench/p1b-2026-09-06/` and bisected to that one
/// call (the fence, which does the same thing to a `TextEdit`, is fine).
/// Recorded in docs/IRIS_TODO.md; when it is fixed, the pan belongs here
/// too, because the long command is the one being read closely.
fn raw_block<Rsc: HasEvents>(rsc: &mut Rsc, body: TextBuilder<Rsc>) -> StrongWidget
where
Rsc::State: FocusHost,
{
let field = body
.family(Family::Monospace)
.size(BODY_SIZE)
.wrap(false)
.add(rsc);
field
.masked()
.pad(dp(RAW_PAD_DP))
.background(rect(VERBATIM_BACKGROUND).radius(dp(RAW_RADIUS_DP)))
.width(rest(1))
.add_strong(rsc)
.any()
}
/// The word a card shows for what became of the call, and the colour it is
/// in. `None` for a call that simply worked -- the ordinary outcome says
/// nothing, the way it says nothing in Compose.
///
/// Colour by consequence: the same red wherever something failed, the same
/// peach wherever the turn is stopped on a person, yellow where the answer
/// is that nobody knows.
fn state_mark(state: ToolState) -> Option<(&'static str, UiColor)> {
match state {
// A spinner would say the machine is working; while this call
// waits on an answer the machine is doing nothing at all, so the
// card says whose move it is instead (`ToolRows.kt`).
ToolState::Deciding => Some(("your turn", AWAITING_COLOR)),
ToolState::Running => Some(("running", MUTED_COLOR)),
ToolState::Failed => Some(("failed", FAILED_COLOR)),
ToolState::NoResult => Some(("no result", UNKNOWN_COLOR)),
ToolState::Succeeded => None,
}
}
/// What a screen reader is given for one card, and what a `ui-trace`
/// script taps by: the tool, what the call is for, and how it went when
/// that is anything but "fine" -- the same three things the Compose card's
/// own text says, in the order it says them.
fn card_label(tool: &str, parsed: &ToolInput, state: ToolState) -> String {
let mut name = tool.to_string();
if let Some(title) = parsed.title() {
name.push_str(": ");
name.push_str(title);
}
if let Some((word, _)) = state_mark(state) {
name.push_str(" (");
name.push_str(word);
name.push(')');
}
name
}
/// The heading a group carries, closed or open. Compose's exact wording,
/// because it is also the name every `ui-trace` script taps it by.
fn group_label(count: usize) -> String {
format!("Called {count} tools")
}
/// `output` cut to what an open card draws, with the line count it was cut
/// from; `None` when the whole of it fits.
///
/// Cut at the **head**, keeping the beginning: a tool's output is read
/// from the top, and the line saying what went wrong is nearly always the
/// first. (A path is identified by its other end; this is not a path.)
fn capped(output: &str) -> Option<(&str, usize)> {
let by_lines = output
.char_indices()
.filter(|(_, c)| *c == '\n')
.nth(OUTPUT_LINES - 1)
.map(|(i, _)| i);
let by_bytes = (output.len() > OUTPUT_BYTES).then(|| {
let mut end = OUTPUT_BYTES;
while !output.is_char_boundary(end) {
end -= 1;
}
end
});
let cut = match (by_lines, by_bytes) {
(Some(a), Some(b)) => a.min(b),
(a, b) => a.or(b)?,
};
Some((&output[..cut], output.lines().count()))
}
/// The tool's output, or the reason there is none to show.
///
/// The empty cases are drawn rather than left blank: "it printed nothing"
/// and "nothing ever came back" are the pair [`ToolState`] exists to keep
/// apart, and a card that drew neither would show the same thing for both.
fn output_block<Rsc: HasEvents>(
rsc: &mut Rsc,
shared: &Rc<Shared>,
index: usize,
id: &str,
output: &str,
call_state: ToolState,
) -> StrongWidget
where
Rsc::State: FocusHost + OpenUrl,
{
if output.is_empty() {
let (words, colour) = match call_state {
ToolState::Succeeded => ("No output", MUTED_COLOR),
ToolState::Failed => ("Failed, with no output", FAILED_COLOR),
ToolState::NoResult => ("No result ever arrived", UNKNOWN_COLOR),
ToolState::Running | ToolState::Deciding => ("No output yet", MUTED_COLOR),
};
return text(words, LABEL_SIZE, colour).add_strong(rsc).any();
}
let whole = shared
.state
.borrow()
.whole_output
.get(id)
.copied()
.unwrap_or(false);
let shown = if whole { None } else { capped(output) };
let mut column = Span::empty(Dir::DOWN).gap(dp(2));
column.push(text("Output", LABEL_SIZE, NAME_COLOR).add_strong(rsc).any());
// What the tool printed, in the face it was written for: this is
// column-aligned far more often than it is prose, and a proportional
// font destroys the alignment that carried the meaning.
let body = text(
shown.map_or(output, |(head, _)| head).to_string(),
BODY_SIZE,
NAME_COLOR,
);
column.push(raw_block(rsc, body));
if let Some((_, lines)) = shown {
let label = format!("Show all {lines} lines");
let more_strong = WidgetPtr::new().add_strong(rsc);
let more = more_strong.weak();
let words = text(label.clone(), LABEL_SIZE, MUTED_COLOR)
.label(label)
.add_strong(rsc);
more(rsc).set(words);
let shared_for_tap = shared.clone();
let id = id.to_string();
on_tap(rsc, more, shared, move |rsc| {
note_tap(rsc, &shared_for_tap);
shared_for_tap
.state
.borrow_mut()
.whole_output
.insert(id.clone(), true);
redraw_card(rsc, &shared_for_tap, index);
});
column.push(more_strong.any());
}
column.width(rest(1)).add_strong(rsc).any()
}
/// One tool call's card content.
///
/// Collapsed, this is one `Span` of at most four short strings -- no
/// input, no output, nothing whose size is the call's size.
fn build_card<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>, index: usize) -> StrongWidget
where
Rsc::State: FocusHost + OpenUrl,
{
let call = shared.calls.borrow()[index].clone();
let TranscriptItem::ToolRun {
id,
tool,
input,
output,
..
} = &call
else {
debug_assert!(false, "a tool row holds only tool calls, not {call:?}");
return Span::empty(Dir::DOWN).add_strong(rsc).any();
};
let parsed = parse_tool_input(tool, input);
let call_state = ToolState::of(&call, shared.working.get()).expect("matched ToolRun above");
// A call waiting on permission is shown open whatever the reader last
// chose: the command is the thing being decided, and a row saying only
// "Bash" cannot be decided on (`ToolRows.kt`).
let open = shared.state.borrow().open.get(id).copied().unwrap_or(false)
|| call_state == ToolState::Deciding;
let mut header = Span::empty(Dir::RIGHT).gap(dp(GAP_DP));
header.push(
text(
if open { OPEN_MARK } else { CLOSED_MARK },
BODY_SIZE,
MUTED_COLOR,
)
.family(Family::Monospace)
.add_strong(rsc)
.any(),
);
header.push(
text(tool.clone(), NAME_SIZE, NAME_COLOR)
.add_strong(rsc)
.any(),
);
match (open, parsed.title()) {
// Open, the summary is redundant -- the input below is the same
// thing in full -- and the space goes to the timeout instead, at
// the far end, since it is a limit on the call rather than part of
// what the call does.
(true, _) | (false, None) => {
header.push(Span::empty(Dir::RIGHT).width(rest(1)).add_strong(rsc).any())
}
// One line, clipped rather than shrunk or wrapped: a wrapped
// command turns one row into four and a run of them into a wall.
(false, Some(title)) => header.push(
text(title.to_string(), BODY_SIZE, MUTED_COLOR)
.wrap(false)
.masked()
.width(rest(1))
.add_strong(rsc)
.any(),
),
}
if open && let Some(timeout) = &parsed.timeout {
header.push(
text(format!("timeout {timeout}"), LABEL_SIZE, MUTED_COLOR)
.add_strong(rsc)
.any(),
);
}
if let Some((word, colour)) = state_mark(call_state) {
header.push(text(word, LABEL_SIZE, colour).add_strong(rsc).any());
}
let mut column = Span::empty(Dir::DOWN).gap(dp(GAP_DP / 2.0));
column.push(header.width(rest(1)).add_strong(rsc).any());
if open {
if let Some(description) = &parsed.description {
// The tool's own prose about what it is doing, so it belongs
// with the reader's text rather than inside the machine's --
// above the input block rather than in it (`ToolInput.kt`).
column.push(
text(description.clone(), BODY_SIZE, MUTED_COLOR)
.width(rest(1))
.add_strong(rsc)
.any(),
);
}
if let Some(subject) = &parsed.subject {
let spans = match parsed.language {
Some(language) => {
let mut spans = Vec::new();
highlight_into(&mut spans, subject, 0..subject.len(), language);
spans
}
// An unknown language is drawn plain rather than coloured
// by the nearest one -- P1a's rule for a fence, and the
// same reason: a wrong highlight is read as a fact.
None => Vec::new(),
};
let body = text(subject.clone(), BODY_SIZE, NAME_COLOR).spans(spans);
column.push(raw_block(rsc, body));
}
if !parsed.rest.is_empty() {
// Never dropped: a field left out would be claiming the tool
// had no other input when it might (`ToolInput.kt`).
let body = text(parsed.rest.join("\n"), BODY_SIZE, MUTED_COLOR);
column.push(raw_block(rsc, body));
}
column.push(output_block(rsc, shared, index, id, output, call_state));
}
column
.width(rest(1))
.pad(dp(CARD_PAD_DP))
.background(rect(CARD_FILL).radius(dp(CARD_RADIUS_DP)))
.width(rest(1))
.label(card_label(tool, &parsed, call_state))
.add_strong(rsc)
.any()
}
/// Rebuild card `index` in place. The removal half is the returned
/// `StrongWidget` being dropped, which frees the content this replaced.
fn redraw_card<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>, index: usize)
where
Rsc::State: FocusHost + OpenUrl,
{
let Some(ptr) = shared.card_ptr(index) else {
// Reached only if a handler outlives the card it was registered
// on, which `apply_calls` is written to prevent.
debug_assert!(false, "card {index} has no widget to redraw");
return;
};
let content = build_card(rsc, shared, index);
let _old = ptr(rsc).replace(content);
}
/// A card and the tap that opens it. The gesture is registered **once**,
/// on a `WidgetPtr` whose content is replaced as often as needed -- which
/// is why every rebuild reads the call out of [`Shared`] rather than
/// capturing one.
fn build_card_ptr<Rsc: HasEvents>(
rsc: &mut Rsc,
shared: &Rc<Shared>,
index: usize,
) -> (StrongWidget, WeakWidget<WidgetPtr>)
where
Rsc::State: FocusHost + OpenUrl,
{
// The strong handle is the card's one real registration and goes to
// whatever container holds it; the weak one is what the gesture and
// every later redraw address it by.
let strong = WidgetPtr::new().add_strong(rsc);
let ptr = strong.weak();
shared.cards.borrow_mut().push(ptr);
debug_assert_eq!(
shared.cards.borrow().len(),
index + 1,
"a card's index is its position, and both are the call's"
);
let content = build_card(rsc, shared, index);
ptr(rsc).set(content);
let for_tap = shared.clone();
on_tap(rsc, ptr, shared, move |rsc| {
note_tap(rsc, &for_tap);
let Some(id) = for_tap.call_id(index) else {
debug_assert!(false, "tapped card {index} is no longer in the row");
return;
};
let was = for_tap
.state
.borrow()
.open
.get(&id)
.copied()
.unwrap_or(false);
for_tap.state.borrow_mut().open.insert(id, !was);
redraw_card(rsc, &for_tap, index);
});
(strong.any(), ptr)
}
/// A bar the height of one line of `LABEL_SIZE` text, carrying `mark`
/// centred -- the group's collapse control at its foot.
///
/// Given the same content as the heading above rather than a height that
/// looks close, so the surface the calls sit on is the same thickness at
/// both ends (`ToolRows.kt`'s `groupBarHeight`, which derives the number
/// from the type for the same reason).
fn collapse_bar<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>) -> StrongWidget
where
Rsc::State: FocusHost + OpenUrl,
{
let strong = WidgetPtr::new().add_strong(rsc);
let ptr = strong.weak();
let mark = text(UP_MARK, BODY_SIZE, MUTED_COLOR)
.family(Family::Monospace)
.center()
.width(rest(1))
.pad(dp(CARD_PAD_DP))
// Anything shown only as a mark still needs a name: this is what
// a screen reader reads and what a `ui-trace` script taps.
.label("Collapse these tool calls")
.add_strong(rsc);
ptr(rsc).set(mark);
let for_tap = shared.clone();
on_tap(rsc, ptr, shared, move |rsc| toggle_group(rsc, &for_tap));
strong.any()
}
/// The row's whole content: a lone card, a closed group's one line, or an
/// open group's header, cards and foot.
///
/// Rebuilt whole when the group opens or closes, because that is a change
/// of what the row *is* rather than of one card in it. Everything a single
/// card's tap does goes through [`redraw_card`] instead.
fn build_content<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>) -> StrongWidget
where
Rsc::State: FocusHost + OpenUrl,
{
shared.cards.borrow_mut().clear();
let count = shared.calls.borrow().len();
debug_assert!(count > 0, "a tool row with no calls has nothing to draw");
// One call is left alone: "Called 1 tool" hides a card to say the same
// thing in more words, and the run this grouping exists for is the
// burst of five greps nobody wants to scroll past (`ToolRows.kt`).
if count == 1 {
return build_card_ptr(rsc, shared, 0).0;
}
if !shared.state.borrow().group_expanded {
let heading = group_label(count);
return text(heading.clone(), NAME_SIZE, NAME_COLOR)
.pad(dp(CARD_PAD_DP))
.width(rest(1))
.background(rect(CARD_FILL).radius(dp(CARD_RADIUS_DP)))
.width(rest(1))
.label(heading)
.add_strong(rsc)
.any();
}
let heading = group_label(count);
let mut group = Span::empty(Dir::DOWN);
group.push(
text(heading.clone(), NAME_SIZE, NAME_COLOR)
.pad(dp(CARD_PAD_DP))
.width(rest(1))
.label(heading)
.add_strong(rsc)
.any(),
);
// The cards go straight into the group's own `Span`, not into a
// second one inside it. **A `Span` of `Pad`ded children inside another
// `Span` places those children a slot out of step** -- each card's
// content drew one card's height below its own box, so the cards read
// as empty bars with somebody else's summary in them. Bisected on
// 2026-09-06 against `iris/run-headless.sh transcript` with
// `IRIS_TOOLS_EXPANDED=1`: removing the inner `Span` fixes it and
// removing the cards' own `Pad` fixes it, while the card background,
// the `Sized` wrappers and the per-card `WidgetPtr` all make no
// difference. It is a framework defect rather than this file's --
// docs/RUST.md's P1b box and docs/IRIS_TODO.md carry the repro -- and
// one `Span` is the shape that works today. What it costs is the 4dp
// inset the Compose group holds its cards off its edge by; the cards'
// own padding stands in for it.
for index in 0..count {
group.push(build_card_ptr(rsc, shared, index).0);
}
// Shutting it from here anchors the other end: the reader is at the
// bottom of a long group, and what they are looking at is what follows
// it (`ToolRows.kt`'s `CollapseBar`).
group.push(collapse_bar(rsc, shared));
group
.width(rest(1))
.background(rect(GROUP_FILL).radius(dp(CARD_RADIUS_DP)))
.width(rest(1))
.add_strong(rsc)
.any()
}
fn toggle_group<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>)
where
Rsc::State: FocusHost + OpenUrl,
{
note_tap(rsc, shared);
let was = shared.state.borrow().group_expanded;
shared.state.borrow_mut().group_expanded = !was;
let content = build_content(rsc, shared);
shared.set_content(rsc, content);
}
impl Shared {
/// Swap the row's whole content. The old `StrongWidget` is freed as it
/// drops here, which is the removal half of what replaced it.
fn set_content(&self, rsc: &mut impl UiRsc, content: StrongWidget) {
let Some(ptr) = *self.content.borrow() else {
debug_assert!(
false,
"the row's content pointer is set before anything can tap it"
);
return;
};
let _old = ptr(rsc).replace(content);
}
fn call_id(&self, index: usize) -> Option<String> {
match self.calls.borrow().get(index) {
Some(TranscriptItem::ToolRun { id, .. }) => Some(id.clone()),
_ => None,
}
}
fn card_ptr(&self, index: usize) -> Option<WeakWidget<WidgetPtr>> {
self.cards.borrow().get(index).copied()
}
}
/// Build a tool row: one card, or a run of them under one heading.
///
/// `working` is the caller's `session_working` **for this row** -- true
/// only for the newest row of a session that is still doing something.
/// Every row behind it belongs to a turn that has ended, so a call in one
/// with no result never came back rather than still running.
pub fn build_tool_row<Rsc: HasEvents>(
rsc: &mut Rsc,
list: WeakWidget<List>,
selection: Rc<RefCell<Selection>>,
key: RowKey,
calls: Vec<TranscriptItem>,
working: bool,
) -> (StrongWidget, ToolRow)
where
Rsc::State: FocusHost + OpenUrl,
{
let shared = Rc::new(Shared {
calls: RefCell::new(calls),
state: RefCell::new(ToolRowState::default()),
cards: RefCell::new(Vec::new()),
content: RefCell::new(None),
list,
selection,
key,
working: Cell::new(working),
});
// `.add_strong`, not `.add`: this row *is* the top of its own subtree,
// so nothing else holds it and it has to own itself (`row.rs`).
let content_strong = WidgetPtr::new().add_strong(rsc);
let content = content_strong.weak();
*shared.content.borrow_mut() = Some(content);
let inner = build_content(rsc, &shared);
content(rsc).set(inner);
(content_strong.any(), ToolRow { shared })
}
impl ToolRow {
/// The calls this row is currently drawing -- what a caller passes
/// back to [`Self::apply_calls`] when something other than the calls
/// themselves changed (the session's status).
pub fn calls(&self) -> Vec<TranscriptItem> {
self.shared.calls.borrow().clone()
}
/// How many cards this row currently draws -- zero for a closed
/// group, which is the whole reason its calls' outputs cost nothing.
/// Only the tests ask; nothing on screen is decided by it.
#[cfg(test)]
pub(crate) fn card_count(&self) -> usize {
self.shared.cards.borrow().len()
}
/// Open or close this row's group without a tap.
///
/// Exists because the expanded appearance is otherwise unreachable
/// from anything that cannot press the screen -- a headless
/// screenshot on this displayless machine, and a test. Same path a tap
/// takes, including `List::note_tap`, so what it produces is what a
/// reader would have got.
pub fn set_group_expanded<Rsc: HasEvents>(&self, rsc: &mut Rsc, expanded: bool)
where
Rsc::State: FocusHost + OpenUrl,
{
if self.shared.state.borrow().group_expanded != expanded {
toggle_group(rsc, &self.shared);
}
}
/// Bring this row up to date with `calls` **without** rebuilding the
/// cards that did not change, and say whether that was possible.
/// `false` means the caller must rebuild the row the ordinary way.
///
/// This is what the per-card `WidgetPtr` exists for: a `ToolEnd`
/// changes one call, so it costs one card, whatever else is in the
/// run. The same rule `RowBlocks::apply_delta` follows for the blocks
/// of a message.
///
/// Refused when a call *left* the row or the calls were reordered: a
/// card's index is its call's position, and every registered handler
/// closed over that index. A run only ever grows at its end while it
/// is the live row, so the refused cases are the ones a page join
/// produces -- and those go through `Rebuild` already.
pub fn apply_calls<Rsc: HasEvents>(
&mut self,
rsc: &mut Rsc,
calls: &[TranscriptItem],
working: bool,
) -> bool
where
Rsc::State: FocusHost + OpenUrl,
{
// A row that was tool calls and now holds something else is a
// different row, not a changed one -- and nothing here could draw
// a message anyway.
if calls.is_empty()
|| !calls
.iter()
.all(|c| matches!(c, TranscriptItem::ToolRun { .. }))
{
return false;
}
let old = self.shared.calls.borrow().clone();
if calls.len() < old.len() {
return false;
}
// Whether the group is drawn as one card or as a stack changes at
// exactly one call, and that is a different row, not a changed
// one.
if (old.len() == 1) != (calls.len() == 1) {
return false;
}
let changed: Vec<usize> = (0..old.len()).filter(|&i| old[i] != calls[i]).collect();
self.shared.working.set(working);
*self.shared.calls.borrow_mut() = calls.to_vec();
// The path out for the reader's own state: a call that is no
// longer in this row keeps no entry in `open`/`whole_output`.
let ids: std::collections::HashSet<String> = calls
.iter()
.filter_map(|c| match c {
TranscriptItem::ToolRun { id, .. } => Some(id.clone()),
_ => None,
})
.collect();
{
let mut state = self.shared.state.borrow_mut();
state.open.retain(|id, _| ids.contains(id));
state.whole_output.retain(|id, _| ids.contains(id));
}
// A collapsed group draws no cards, so a changed call is worth
// nothing on screen -- unless the *count* changed, which is the
// whole of what its one line says.
if self.shared.cards.borrow().is_empty() {
if calls.len() != old.len() {
let content = build_content(rsc, &self.shared);
self.shared.set_content(rsc, content);
}
return true;
}
debug_assert_eq!(
self.shared.cards.borrow().len(),
old.len(),
"an open row draws exactly one card per call"
);
for index in changed {
redraw_card(rsc, &self.shared, index);
}
// A call *joining* the run rebuilds the row's content rather than
// appending one card: the group's `Span` holds its collapse bar
// after the cards, and `Span::push` would put the new card behind
// it. That is still O(this row) -- every other row is untouched --
// and it is much rarer than a result arriving, which is the case
// the per-card `WidgetPtr` above exists for.
if calls.len() > old.len() {
let content = build_content(rsc, &self.shared);
self.shared.set_content(rsc, content);
}
true
}
}
+40 -1
View File
@@ -700,6 +700,7 @@ impl Translator {
events.push(Event::ToolEnd { events.push(Event::ToolEnd {
id: about.clone(), id: about.clone(),
output: texts.join("\n"), output: texts.join("\n"),
is_error: crate::session::import::tool_result_is_error(block),
}); });
// Deliberately does *not* finish a subagent `about` might name: // Deliberately does *not* finish a subagent `about` might name:
// the Task tool runs in the background by default, so this // the Task tool runs in the background by default, so this
@@ -1015,7 +1016,44 @@ mod tests {
}, },
Event::ToolEnd { Event::ToolEnd {
id: "toolu_01".to_string(), id: "toolu_01".to_string(),
output: "probe-ok".to_string() output: "probe-ok".to_string(),
is_error: false,
},
]
);
}
/// The other half of the test above, and the one it cannot stand in
/// for: a call the tool itself reported as failed. Both lines are
/// `tool_result`s and both carry output, so nothing but `is_error`
/// tells them apart -- which is why dropping the field made a broken
/// call draw exactly like one that worked.
#[test]
fn a_failed_tool_result_says_so() {
let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines(
&mut translator,
&[
r#"{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_02","type":"tool_result","content":"No such file or directory","is_error":true}]},"parent_tool_use_id":null}"#,
// No `is_error` at all: every transcript written before
// the field was read looks like this, and it means the
// call was not reported to have failed.
r#"{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_03","type":"tool_result","content":"fine"}]},"parent_tool_use_id":null}"#,
],
);
assert_eq!(
events,
vec![
Event::ToolEnd {
id: "toolu_02".to_string(),
output: "No such file or directory".to_string(),
is_error: true,
},
Event::ToolEnd {
id: "toolu_03".to_string(),
output: "fine".to_string(),
is_error: false,
}, },
] ]
); );
@@ -1459,6 +1497,7 @@ mod tests {
Event::ToolEnd { Event::ToolEnd {
id: "toolu_05".to_string(), id: "toolu_05".to_string(),
output: "took a screenshot".to_string(), output: "took a screenshot".to_string(),
is_error: false,
} }
); );
} }
+21 -1
View File
@@ -134,6 +134,7 @@ impl EchoDriver {
self.emit(Event::ToolEnd { self.emit(Event::ToolEnd {
id, id,
output: format!("{label} step {index} finished"), output: format!("{label} step {index} finished"),
is_error: false,
}); });
} }
} }
@@ -645,6 +646,7 @@ impl EchoDriver {
send(Event::ToolEnd { send(Event::ToolEnd {
id, id,
output: format!("call {i} finished"), output: format!("call {i} finished"),
is_error: false,
}); });
} }
finish(); finish();
@@ -698,6 +700,7 @@ impl EchoDriver {
send(Event::ToolEnd { send(Event::ToolEnd {
id, id,
output: format!("ran: {command}"), output: format!("ran: {command}"),
is_error: false,
}); });
} }
@@ -717,6 +720,7 @@ impl EchoDriver {
send(Event::ToolEnd { send(Event::ToolEnd {
id, id,
output: format!("echoed: {input}"), output: format!("echoed: {input}"),
is_error: false,
}); });
} }
@@ -817,6 +821,7 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
send(Event::ToolEnd { send(Event::ToolEnd {
id, id,
output: format!("beat {beat}: forty-two lines of nothing in particular"), output: format!("beat {beat}: forty-two lines of nothing in particular"),
is_error: false,
}); });
} }
// A run of three, which the app folds into one collapsed group -- the // A run of three, which the app folds into one collapsed group -- the
@@ -829,9 +834,20 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
tool: if i % 2 == 0 { "Bash" } else { "Grep" }.to_string(), tool: if i % 2 == 0 { "Bash" } else { "Grep" }.to_string(),
input: serde_json::json!({ "command": format!("grep -rn 'beat {beat}' /tmp") }), input: serde_json::json!({ "command": format!("grep -rn 'beat {beat}' /tmp") }),
}); });
// The middle one fails, so this fixture carries a run in
// which the three calls are not all in the same state --
// the case a card drawing every finished call the same way
// looks correct on. `is_error` is the CLI's own field
// (`import::tool_result_is_error`), and a driver that
// never sets it makes the failed appearance unreachable
// from the sandbox.
send(Event::ToolEnd { send(Event::ToolEnd {
id, id,
output: format!("beat {beat}, call {i} of 3"), output: match i == 2 {
true => format!("beat {beat}, call {i} of 3: No such file or directory"),
false => format!("beat {beat}, call {i} of 3"),
},
is_error: i == 2,
}); });
} }
} }
@@ -856,6 +872,7 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
send(Event::ToolEnd { send(Event::ToolEnd {
id, id,
output: format!("beat {beat}: captured"), output: format!("beat {beat}: captured"),
is_error: false,
}); });
} }
// Somebody else's voice, which is its own row shape. // Somebody else's voice, which is its own row shape.
@@ -904,6 +921,7 @@ async fn run_helper(id: String, sink: EventSink, subagents: Arc<Subagents>) {
Event::ToolEnd { Event::ToolEnd {
id: tool_id, id: tool_id,
output: "helper done".to_string(), output: "helper done".to_string(),
is_error: false,
}, },
); );
let target = Duration::from_secs(3); let target = Duration::from_secs(3);
@@ -915,6 +933,7 @@ async fn run_helper(id: String, sink: EventSink, subagents: Arc<Subagents>) {
let _ = sink.send(Event::ToolEnd { let _ = sink.send(Event::ToolEnd {
id, id,
output: "subagent finished".to_string(), output: "subagent finished".to_string(),
is_error: false,
}); });
} }
@@ -1069,6 +1088,7 @@ impl Driver for EchoDriver {
self.emit(Event::ToolEnd { self.emit(Event::ToolEnd {
id: call, id: call,
output: format!("answered: {answer}"), output: format!("answered: {answer}"),
is_error: false,
}); });
// The work carries on where it left off, which is what makes the // The work carries on where it left off, which is what makes the
// asked-here row a boundary with a group on each side rather than // asked-here row a boundary with a group on each side rather than
+17
View File
@@ -363,6 +363,22 @@ fn is_hidden(record: &Value) -> bool {
|| record.get("isMeta").and_then(Value::as_bool) == Some(true) || record.get("isMeta").and_then(Value::as_bool) == Some(true)
} }
/// Whether a `tool_result` block says the call itself failed.
///
/// One reader for the field rather than one per caller: the live
/// translator (`translate.rs`) and this replay of the CLI's own file look
/// at the same block shape, and a call drawn as failed in one and as
/// succeeded in the other would be the same conversation disagreeing with
/// itself. Absent means "not reported to have failed" -- which is what the
/// CLI writes for a call that went fine, and also what every transcript
/// written before this field was read says.
pub(crate) fn tool_result_is_error(block: &Value) -> bool {
block
.get("is_error")
.and_then(Value::as_bool)
.unwrap_or(false)
}
fn text_of(content: &Value) -> String { fn text_of(content: &Value) -> String {
match content { match content {
Value::String(text) => text.clone(), Value::String(text) => text.clone(),
@@ -549,6 +565,7 @@ fn push_user(events: &mut Vec<Event>, content: &Value, session_dir: &std::path::
events.push(Event::ToolEnd { events.push(Event::ToolEnd {
id: id.to_string(), id: id.to_string(),
output: text_of(block.get("content").unwrap_or(&Value::Null)), output: text_of(block.get("content").unwrap_or(&Value::Null)),
is_error: tool_result_is_error(block),
}); });
} }
} }
+1
View File
@@ -744,6 +744,7 @@ mod tests {
Event::ToolEnd { Event::ToolEnd {
id: "t1".into(), id: "t1".into(),
output: "done".into(), output: "done".into(),
is_error: false,
}, },
Event::Image { Event::Image {
image: "img1".into(), image: "img1".into(),