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>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-06 17:17:42 -04:00
1 parent d73db97629
commit 167862ca1b
14 files changed
+567 -39

No files matched your search

+25
View File
@@ -5,6 +5,31 @@ they can be judged and reversed later. Detail lives in RUST.md (and IRIS.md
for iris API changes); this file is only the summary. Newest first. Items
marked **DEFERRED** are ones the agent chose not to decide alone.
## 2026-09-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
+44
View File
@@ -8,6 +8,50 @@ 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
it helps judge the change without the session that made it. Newest first.
## 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
+26 -13
View File
@@ -201,19 +201,32 @@ agent takes them without colliding with that pass's `bench_client.rs`/
a capped/scrollable height, bottom padding tied to the IME/nav-bar
inset) -- structurally in place and unit-tested, but its own visual
correctness cannot be screenshotted until text actually renders.
- [~] **The composer has no touch-drag scroll for overflowing text.**
**The mechanism is in, the composer is not, 2026-09-06.** `Scroll::drag`
takes its pan from the same `sense::DragGesture` `List` uses and
`WidgetLike::scrollable()` registers it beside the wheel handler, so
every scroll area in the codebase now pans on a finger (no fling -- see
IRIS.md). A vertical drag inside a focused field no longer extends a
selection, matching Android's `EditText`. But the composer field is
**not** wrapped in `.scrollable()` -- the note above was describing
intent, not the code -- and wrapping it was tried and reverted: `Scroll`
measures against the window rather than its own offered box, so inside
the `MaxSize` that caps the field at six lines it pans itself out of the
bar entirely (emulator, 474 characters, the bar collapsed to its
padding). docs/RUST.md's plan box has the numbers and the next step.
- [x] **The composer has no touch-drag scroll for overflowing text.**
**Done 2026-09-06.** `field.scrollable().masked()` in
`transcript-ui/src/composer.rs`: a finger drag inside the bar pans the
message, the bar stays capped at six lines, and a vertical drag in the
focused field no longer extends a selection (Android `EditText`'s own
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.
**Still open, and pre-existing:** the bar's own grey background is not
drawn on this build (the `Stack{StackSize::Child(1)}` behind the field),
so the message reads as white text over the transcript. Present in the
build *before* this change too, so it is not the scroll area's doing.
## From the phone, 2026-09-06, 11:39 (build delivered 02:07, commit 543f6d9)
+77
View File
@@ -43,6 +43,83 @@ gated on her verdict**, so this pass works the P0 defects and the pure
prerequisites in this order. Each item is ticked here by the agent that
closes it.
### Task A, closed 2026-09-06: the composer scrolls on a finger
`iris/transcript-ui/src/composer.rs` is `field.scrollable().masked()` now.
Verified on this checkout's emulator -- the evidence and the numbers are
in docs/IRIS_TODO.md's ticked "composer has no touch-drag scroll" item.
**The premise the task was given under was wrong, and that is worth
recording**: `Scroll` did *not* measure against the window. Its
`used.within_len(container).to_abs(output_size)` came to exactly
`abs + rel * container_px` -- the right number by a route that reads as if
the window were the container, which is what cost a session. It is
`painter.px_size()` and `to_abs(container_len)` now: same arithmetic,
stated the way the invariant is. `Scroll` also still reports its
**content's** size upward, deliberately -- reporting the container makes
the answer a function of itself (the bar is sized *from* that report, so
it collapses to nothing and never recovers; measured in the headless
harness before the shape was settled).
What actually broke the composer was three separate defects, each now
carrying a headless regression test in `iris/src/layout_tests.rs` that was
confirmed to fail without its fix:
1. **A `MaxSize` reported its cap as an unresolved `dp`.**
`Span::draw` places a child from the `abs`/`rel` of the length it
reported, so `dp(168)` was worth **zero** and the bar got a slot of
nothing the instant its content passed six lines; the `Scroll` inside
then measured its container at **-63px** (the padding subtracted from
nothing) and panned the whole message out of view. Emulator log, before
the fix: `container=-63 content=415.8 amt=478.8`. Fixed by
`Len::fold_dp` (new), used by `MaxSize` and `Sized` on the way out, and
guarded for every widget by a `debug_assert!` in
`UiRenderState::draw_inner` that a reported `Size` carries no `dp`.
Test: `a_dp_cap_is_reported_in_pixels_so_a_span_can_place_it`.
2. **A `Masked` allocated a fresh mask slot on every draw.**
`draw_inner`'s unchanged-region fast path means its descendants are
mostly *not* redrawn with it, so they kept clipping against the slot
they were first drawn under -- measured on the composer's tree at
**four live mask entries, none of them the widget's current box**, and
the field drew nothing at all. The slot is allocated once and rewritten
in place now (`ActiveData::own_mask`, `Painter::set_mask`), with its
path out in `remove`'s `undraw` branch. Test:
`a_masked_widget_keeps_one_mask_slot_that_is_always_its_own_region`.
3. **A panned widget's own hit box moved twice.** `mov` updates
`active.region` *and* accumulates the same delta on the widget's move
slot, and `resolved_region` added both -- so after a finger pan the
composer's field was untappable, while its descendants were fine (which
is why `hit_testing_follows_a_scrolled_widget`, which checks a
descendant, never saw it). `ActiveData::move_applied` records the part
of the slot's delta `region` already accounts for. Test:
`a_panned_widgets_own_hit_box_moves_exactly_once` (fails at exactly
2x the pan without it).
Still open, and **pre-existing** (present in the build before this change,
so not the scroll area's doing): the composer bar's grey background is not
drawn on the `transcript-screen bench` build, so the message reads as
white text over the transcript. `Stack{StackSize::Child(1)}` is the thing
to look at.
Rig fix on the way past: `iris/android-app/run-bench.sh` polled logcat for
`"iris bench report:"`, which `copy_report` also logs at startup
("nothing to copy -- run the benchmark first"), so it returned instantly
and printed a report that had never been run. It polls for the report's
own first line now.
### Bench, before Task B (emulator, 2026-09-06)
`iris/android-app/build-apk.sh debug --abi x86_64 --features
"transcript-screen bench force-gles"` + `run-bench.sh`, this checkout's
AVD. Emulator absolutes transfer nothing; the before/after ratio on the
same emulator does.
stream: 202 frames over 21.0s
late: 197 (97.5%)
total p50 61.5ms p90 211.7ms p99 342.6ms
worst 403.6ms
- [x] **Merge the `DragGesture` work** -- done 2026-09-06 (merge commit
`f802de9`, `git merge --no-ff worktree-agent-a754368325fa06839`,
clean, no conflicts across the 8 files `e12c708` touched). Targets