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

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

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

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

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

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

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

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

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

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

**A `DragGesture` ignores a `Cancel` when it is the one holding the
capture.** A cancel goes to every pressed widget that did not capture,
and one gesture is routinely driven by several of those -- a transcript
row's text block feeds `Selection`'s shared gesture, which captures
under the *list's* id, so the block is a "loser" on the very frame its
own pan committed. Acting on that released the pan the frame it started
(`catch_a_fling.rs` fails without the guard). With it, a row's block can
register the whole `drag_senses()` set, `Cancel` included, which is what
the doc on that set has always said a widget driving a gesture must do.

**A row whose measurement disagrees with the box it was offered is drawn
again at its true box, this frame** (`List::place`, both placements).
A row is offered its *cached* height and a `.background(rect(..))` fills
whatever box it is handed, so on the frame a row changed height its text
laid out at the new height and its background painted at the old one --
"collapsing and opening an edit card draws the card background a frame
late, so it looks closed even when there's text". The bottom-anchored
half used a `reposition`, which writes an offset and never a size, so it
could not fix it either.

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 15:15:29 -04:00
17 changed files with 1432 additions and 193 deletions

No files matched your search

+32
View File
@@ -5,6 +5,38 @@ 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-08 evening (the fling is shared; a cancel is not a release)
From Iris's four-item phone report; RUST.md's "2026-09-08 (evening)" box
has the reasoning and the tests, IRIS.md the summary.
- **A `Flinger` that does not know which way the content moves.** Every
scroll area flings now, on either axis, as Iris asked -- and the
physics is one type shared by `List` and `Scroll` rather than a copy
each. The choice worth reviewing is the seam: `Flinger` owns the curve
and the clock, and the *caller* owns the sign convention and where the
content ends. Rejected: teaching `Flinger` a direction, which would
have to be told to it -- and being told is the same thing as not
knowing, with an extra field to get wrong.
- **A cancel is a first-class end to a gesture, not an early release.**
`CursorState::cancelled` is new state on the pointer sample, set by
Android's `ACTION_CANCEL` and the harness's `TouchAction::Cancel`.
Rejected: mapping a cancel to `PressEnd` and having each widget decide
what to suppress, which is what shipped and is why leaving the app
flung the transcript.
- **A `DragGesture` ignores a `Cancel` it caused.** One gesture is
driven by several widgets, so the widget that was pressed can be a
"loser" on the frame its own gesture won. The test is whether the
gesture's own capture id is the holder. This is what makes it safe for
every widget driving a gesture to register the whole `drag_senses()`
set, which is now the rule without exception.
- **`List::place` draws a resized row twice in one frame.** The old
comment accepted a one-frame lag by analogy with `Scroll`'s content
length. That analogy was wrong: a stale *length* only misplaces the
next thing, while a stale *box* is drawn, because a background fills
whatever box it is handed. The extra draw is bounded to frames where a
row's height actually changed.
## 2026-09-08 (iris ships an icon font, and the drawn mark is deleted) ## 2026-09-08 (iris ships an icon font, and the drawn mark is deleted)
- **Directed by Iris.** Her question on seeing `widget::mark`: "why does - **Directed by Iris.** Her question on seeing `widget::mark`: "why does
+63
View File
@@ -12,6 +12,69 @@ things still stay out.
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-08: what a cancel means, what a row's box is, and one fling for every scroll area
Iris's second 2026-09-08 report, from the bench on her phone. Four items,
and each turned out to be a rule stated in one place and missing from its
siblings rather than a special case.
**A gesture the *platform* takes away is a cancel, not a release**
(`CursorState::cancelled`). Android's `ACTION_CANCEL` used to take the
same arm as `ACTION_UP`, so the system's own swipe up from the bottom
edge to leave the app arrived as a flick released at speed: the
transcript flung while the app was in the background, and came back
somewhere else. A cancelled sample now hands `CursorSense::Cancel` to
the capture holder *and* every widget still tracking the press, clears
both, and derives nothing else from that sample -- no tap, no selection,
no fling. That is the same sense a widget already gets when it loses a
capture race; what is new is that the platform can raise it, and that
the *winner* hears it too when the platform is the one cancelling.
**A `DragGesture` ignores a cancel when it is the one holding the
capture.** A cancel goes to every pressed widget that did not capture,
and one gesture is routinely driven by several of those: a transcript
row's text block feeds the shared gesture that captures under the
*list's* id, so the block is a "loser" on the very frame its own pan
committed. `Cancel` means "somebody else won", so the question is
whether the holder is us -- and now it is asked. With that, a row's
block registers the whole `drag_senses()` set, which is what the doc on
that set has always said a widget driving a gesture must do; it was the
one place that did not, and it is why panning a code fence sideways and
then tapping made the transcript jump.
**A row is drawn at the box its own height implies, in the frame that
height changes** (`List::place`). A row is offered its *cached* height
so that an unchanged row takes `draw_inner`'s cheap path; a
`.background(rect(..))` fills whatever box it is handed. So on the frame
a row changed height its text laid out at the new height and its
background painted at the old one -- collapsing or opening a tool card
looked closed while its text was there, then open while it was not. When
the measurement disagrees with the offer, the row is now drawn again at
its true box. The bottom-anchored half had a `reposition` for this,
which writes an offset and never a size, so it could not fix it either:
the same rule, applied to one member of a set of two.
**Every scroll area flings, on either axis** (`iris::sense::Flinger`).
The fling was `List`'s alone -- the curve, the clock, the incremental
delta, Compose's two release thresholds -- and a `Scroll` dropped its
released velocity on the floor, with a comment explaining that the areas
it wrapped were only a screenful. That stopped being true the moment a
code fence became one. `Flinger` is that machinery as a type both use;
what it deliberately does not know is which way a positive delta moves
the content or where the content ends, because a `List` and a `Scroll`
answer those oppositely. The caller applies `tick`'s delta in its own
convention and calls `stop` at its own wall. `Scroll::drag` now answers
whether it started a fling, which is what `scroll_area` needs to call
`UiData::animate` -- the same split `List::fling` already documented,
for the same reason: only the caller can reach the frame loop.
**Removed, not worked around**: `tool.rs` no longer flattens its two
`Span`s into one, so a tool group holds its cards 4dp off its own edge
again. The defect that shape was avoiding -- "a `Span` of `Pad`ded
children inside another `Span` places those children a slot out of step"
-- is not reproducible on 2026-09-08, checked both with a headless
render and with a new layer-1 test.
## 2026-09-08: a gesture can be cancelled, and the pointer belongs to the input handler ## 2026-09-08: a gesture can be cancelled, and the pointer belongs to the input handler
Two changes to how a drag ends, from defects on Iris's phone (a code Two changes to how a drag ends, from defects on Iris's phone (a code
+71 -15
View File
@@ -785,22 +785,35 @@ Iris's report, verbatim, with a screenshot. Phone: Mali-G715 (Vulkan),
## Found by P1b (2026-09-06), all with a headless repro ## Found by P1b (2026-09-06), all with a headless repro
Each was found by looking at `iris/run-headless.sh transcript -- -p 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` rather than at a diff. docs/RUST.md's P1b box has the
`transcript-ui/src/tool.rs` rather than fixed here. docs/RUST.md's P1b box fuller account.
has the fuller account.
- [ ] **A `Span` of `Pad`ded children inside another `Span` places those **No entry here is worked around any more** (Iris, 2026-09-08: "All of
those should be fixed. There should never be workaround code. Do the
same for those; fix them if they're trivial, diagnose and report if
not."). Two are fixed and ticked; the two that are left are missing
*capabilities* rather than defects being dodged, and each carries its
diagnosis and what building it actually costs.
- [x] **A `Span` of `Pad`ded children inside another `Span` places those
children a slot out of step.** Each child drew its content one sibling's children a slot out of step.** Each child drew its content one sibling's
height below its own box. Repro: `IRIS_TOOLS_EXPANDED=1 height below its own box. Repro was: `IRIS_TOOLS_EXPANDED=1
iris/run-headless.sh transcript --shot /tmp/x.png -- -p transcript-ui` 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) with `tool.rs`'s group built as `Span(DOWN)[header, Pad(Span(DOWN)
[cards]), bar]` instead of the single `Span` it uses now. Bisected: [cards]), bar]` instead of the single `Span` it used. Bisected at the
removing the inner `Span` fixes it, and so does removing the children's time: removing the inner `Span` fixed it, and so did removing the
own `Pad`; the background `Stack`, the `Sized` wrappers and the children's own `Pad`; the background `Stack`, the `Sized` wrappers and
`WidgetPtr` per child make no difference. **Not** the `mov`-vs- the `WidgetPtr` per child made no difference. **Not** the `mov`-vs-
`reposition` fault f5b8893 fixed -- it survives that commit. The `reposition` fault f5b8893 fixed -- it survived that commit.
workaround costs the group the 4dp inset its Compose counterpart holds **Not reproducible on 2026-09-08.** Both spans are nested again and the
its cards off the edge by, so this is worth fixing. group has its 4dp inset back; that same headless render puts every
card's content in its own box, and `iris`'s
`a_span_of_padded_children_inside_a_span_draws_each_where_its_box_is`
(`layout_tests.rs`, the same shape inside a `List`, which is the
context the real one is in) pins it at layer 1. Something between
09-06 and 09-08 fixed it -- most likely the nested-mask pass or the
`mov` work after f5b8893. Left ticked with the original symptom
recorded rather than deleted, in case it comes back.
- [x] **`scrollable_on(Axis::X)` on a non-editable `Text` draws nothing.** - [x] **`scrollable_on(Axis::X)` on a non-editable `Text` draws nothing.**
The panel is drawn and the text inside it is not. A markdown fence does The 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 the same to a `TextEdit` and is fine, so it is the widget kind rather
@@ -819,6 +832,32 @@ has the fuller account.
nothing on screen says it was cut. Whichever end is cut has to be a 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 choice when this lands: a path is identified by its tail, a command by
its head. its head.
**Diagnosed 2026-09-08, and it is not trivial.** parley has no
ellipsis of its own (checked: nothing in the vendored crates), so iris
would build it, and the shape that looks easy is the one that breaks
something. The easy half really is easy: shape at
`max_advance = width - ellipsis_advance` with wrapping on, take line
0's `text_range()`, and re-shape `text[..end].trim_end() + "…"` with
wrapping off -- parley's own line breaker finds the cut, so nothing
here counts glyph advances by hand. The hard half is that
`TextBuffer` has exactly one string and everything addresses it by
byte offset: the inline spans that carry a fence's colours and a
link's range, `TextEditCtx::byte_at` (which turns a tap into a byte to
match a link against), `Selection`'s `select`/`selected_text`, and
`RowBlocks::apply_delta`. Truncating the buffer moves every one of
those. So the real work is giving `TextBuffer` a **displayed** string
distinct from its source, with one mapping from display byte to source
byte that all of those go through -- worth doing, and not a
by-the-way. Doing it only for text that is neither editable nor
selectable would avoid all of that and is exactly the kind of
exemption that comes back later.
It also wants an API change while it is open: `TextAttrs::wrap: bool`
cannot say three states. Something like `Overflow::{Wrap, Clip,
Ellipsis(End)}` replaces it, with `End::{Head, Tail}` making
UI_RULES's "choose which end to truncate" a thing a caller must
answer rather than a default nobody reads.
- [x] **A chevron the platform cannot fail to have.** **Done - [x] **A chevron the platform cannot fail to have.** **Done
2026-09-08**, twice. First as `iris::widget::mark(dir, dp, colour)`, 2026-09-08**, twice. First as `iris::widget::mark(dir, dp, colour)`,
which rasterised an antialiased triangle into the ordinary texture path which rasterised an antialiased triangle into the ordinary texture path
@@ -841,9 +880,26 @@ has the fuller account.
- [ ] **A tool card's text is not selectable.** `Selection` is keyed - [ ] **A tool card's text is not selectable.** `Selection` is keyed
`(RowKey, block index)` and a card has no markdown blocks, so nothing in `(RowKey, block index)` and a card has no markdown blocks, so nothing in
a card registers. Compose's `SelectionContainer` covers tool output, a card registers. Compose's `SelectionContainer` covers tool output,
which is the text people most want to copy. Needs a key for "the nth which is the text people most want to copy.
text of this row" that a card can mint without colliding with a
message's blocks. **Diagnosed 2026-09-08: mechanical, but more than a sitting.** There
is no key collision to design around, which was the open question:
a `TranscriptRow::Tools` has *only* cards and no markdown blocks at
all, so a card is free to number its own texts from 0 in reading
order. What it costs is the registration lifecycle rather than the
key. Each card's `TextEdit`s have to `Selection::register` as they are
built and `unregister` when they are not -- and a card is rebuilt from
several directions (`redraw_card` when a result arrives,
`Shared::set_content` when the group is toggled or a call joins the
run, and the per-card `WidgetPtr` swap), each of which frees widgets
the map would otherwise still point at. That is the exact shape of the
crash `Selection::clear`'s doc records from
docs/REVIEW-2026-09-06.md: a handle in that map outliving the widget
panics on the *next* long press, somewhere else entirely. So the work
is a per-card base index with a stride (and a `debug_assert` that a
card stays inside it), one register/unregister path that every rebuild
route goes through, and a test per route that a rebuilt card leaves no
stale handle behind.
## Build (for the port) ## Build (for the port)
+40
View File
@@ -1074,3 +1074,43 @@ places the code is narrower than the design above, each deliberate:
`iris_core::SHAPE_SHADER` *by name* and runs them in a compute pass, `iris_core::SHAPE_SHADER` *by name* and runs them in a compute pass,
so the thing under test is the shader itself rather than a copy of it so the thing under test is the shader itself rather than a copy of it
that would be edited alongside. that would be edited alongside.
## What a widget's *offered* box may and may not be (2026-09-08)
Two rules that were each true in one place and missing from a sibling,
found together by Iris's 2026-09-08 phone report.
**Padding works in whatever container it is placed in, and is an inset or
an outset depending on how tight that container's region is.** Iris's
own words, 2026-09-08: "padding should work no matter what container a
widget is placed in, and acts as both inset and outset depending on how
tight the parent region is." `Pad` offers its child the region it was
handed, inset on each side, and reports `used + padding` — so given a
generous box it insets the child inside it, and given a box already the
size of the content it reports a larger size and the parent grows. What
this rules out is any container that offers a padded child a box and then
ignores what it reported, and any caller that reshapes its tree to avoid
a `Pad` (which `transcript-ui/src/tool.rs` did until 2026-09-08, at the
cost of a tool group's 4dp inset).
**A widget offered a box it does not fit is drawn again at the box its
own reported size implies, in the same frame.** Not next frame. The
temptation to defer is real — `List::place` offers a row its *cached*
height precisely so that an unchanged row hits `draw_inner`'s cheap
skip-or-move path, and `Scroll` sizes its child region from last frame's
content length for the same reason. But a `Rect` fills whatever region it
is given (`Size::REST`, and `rect.rs`'s `is_size_independent` doc says
why it must), and `.background(rect(..))` is the ordinary way to style
anything — so a one-frame-stale box is a background drawn at the wrong
size while the text inside it is already right. On screen that is a tool
card that looks closed while its text is there and open while it is not.
A `reposition` is not the fix and cannot be: it writes an offset, never a
size.
The cost is bounded and worth stating, because it is what makes the rule
safe to apply everywhere: the second draw happens only on the frame a
widget's own size actually changes, which is a frame that was already
redrawing it. A widget whose reported size is a function of the box it
was *offered* would disagree every frame and redraw every frame — which
is why `List` requires content-sized rows, and has since long before
this.
+147 -1
View File
@@ -34,7 +34,7 @@ the emulator, not by Mesa" and "the present mode was not the cause" are
worth as much as the successes, because they are what stops the next worth as much as the successes, because they are what stops the next
session spending an afternoon on them again. session spending an afternoon on them again.
## Where things stand (2026-09-06) ## Where things stand (2026-09-06; see the 2026-09-08 evening box at the end)
Written on picking the branch up after a `/clear`, so the next session can Written on picking the branch up after a `/clear`, so the next session can
resume from here. P0 is delivered and Iris's phone report v2 is in resume from here. P0 is delivered and Iris's phone report v2 is in
@@ -8188,3 +8188,149 @@ marks in `run-headless.sh phone --phone` (before and after a tap that
opens the card), and the collapse bar's up mark in `IRIS_TOOLS_EXPANDED=1 opens the card), and the collapse bar's up mark in `IRIS_TOOLS_EXPANDED=1
run-headless.sh transcript`. All three draw, at the size and alignment the run-headless.sh transcript`. All three draw, at the size and alignment the
drawn triangle had. drawn triangle had.
## Iris's phone report, 2026-09-08 (evening: four defects, all fixed)
Her words, verbatim, from the iris bench on her phone:
> - if I scroll in a horizontal area and then tap in a vertical area, it
> seems to snap. Leaving and reopening the app also randomly moved the
> vertical scroll. This is ridiculous and it sounds like the code is
> pretty bad / connected.
> - Flinging doesn't work in horizontal scroll areas. Flinging should be
> enabled by default in all scroll areas on android to match composes
> behavior.
> - Collapsing and opening an edit card draws the card background a frame
> late, so it looks closed even when there's text, and then looks open
> even when the text is collapsed. This is also ridiculous and suggests
> the framework is being used in a bad way.
Plus, on reading the workaround list in `IRIS_TODO.md`: "All of those
should be fixed. There should never be workaround code. Do the same for
those; fix them if they're trivial, diagnose and report if not." And two
notes on the *shape* of a fix: "make sure all fixes you do work under all
circumstances and just make the code more correct rather than tape on
edge cases", with padding as the example -- "padding should work no
matter what container a widget is placed in, and acts as both inset and
outset depending on how tight the parent region is" (that one is written
up in docs/LAYOUT.md, since it is a rule about the layout rather than
about this pass).
Commits `fc82d9d` and `02b277e`. Every fix below has a layer-1 repro that
was **confirmed to fail with the change backed out**, which is what says
the test is about the defect and not about the code.
### The two "snaps" were two different faults with one shape
Both are a press that ended without the thing tracking it being told, so
the *next* press was measured from an origin belonging to a finger long
gone. `DragArbiter::update`'s `Undecided` crosses its slop instantly at
that distance and pans in one step.
**Leaving and reopening the app** was `MotionAction::Cancel` sharing an
arm with `MotionAction::Up` in `android/view.rs`. The system's own swipe
up from the bottom edge to go home is delivered to the app as moves and
then `ACTION_CANCEL`, so iris read it as a flick released at speed and
flung the transcript while the app was in the background. `CursorState::
cancelled` is the fix: a cancelled sample delivers `CursorSense::Cancel`
to the capture holder **and** every widget still tracking the press,
clears both, and derives nothing else from that sample. `TouchAction::
Cancel` in the harness sets the same flag, which is what makes it
testable from a `.touch` file (`touch/flick-cancelled.touch`).
Tests: `gesture_cancel.rs`'s `a_cancelled_flick_does_not_fling` (fails
with `Some(-15249.9)` when a cancel is a release) and
`a_press_ended_by_a_cancel_leaves_no_origin_for_the_next_one`.
**Panning a code fence and then tapping** was the `Cancel` this file's
earlier entry added, arriving nowhere. A cancel is delivered to the
widget that was **pressed**, not to whoever holds the capture -- and the
widget the press landed on is the fence's own text block, which drives
`Selection`'s shared `DragGesture` under the *list's* id. `row.rs`
registered `click_or_drag() | unclick()`, so it never heard it. It
registers `drag_senses()` now, which is what that set's own doc has
always said a widget driving a gesture must do.
That change alone would have broken panning, and this is the part worth
keeping: when the shared gesture *wins* a capture, the block is still a
"loser" by `run_sensors`' accounting, so it would be handed a `Cancel`
on the very frame its own pan committed and would release it.
`DragGesture::handle` now ignores a `Cancel` when `pointer.holder()` is
its own `id` -- "somebody else won" is what the sense means, so whether
the winner is us is the question to ask. Confirmed load-bearing:
`catch_a_fling.rs`'s `a_press_on_a_flinging_list_pins_the_content_to_the_
finger` fails without it.
Test: `gesture_cancel.rs`'s
`panning_a_code_fence_then_tapping_elsewhere_moves_nothing`, which
pushes a real fence into the real screen so the pan has something to
capture it. **A trap worth not re-finding**: the first version of that
test tapped at a fixed y and "failed" by 544px with every fix in place --
it had landed on a tool group's header and toggled it. The tap has to be
on ordinary text, so the test pushes a paragraph of its own to aim at.
### The card background was a frame late because a row is offered its cached height
`List::place` offers an already-measured row a box sized to the height it
cached, so an unchanged row hits `draw_inner`'s cheap skip-or-move path
(the alternative, `draw_twice` every frame, forces a real redraw every
frame -- `place`'s own doc has that history). A `Rect` fills whatever
region it is given, and `.background(rect(..))` is how a tool card is
styled, so on the frame a card changed height its text laid out at the
new height and its background painted at the old one. A row whose
measurement disagrees with its offer is now drawn again at its true box,
in that frame, on both placements -- the bottom-anchored half used a
`reposition`, which writes an offset and never a size, so it could not
fix it either. Test:
`a_row_that_changes_height_draws_its_background_at_the_new_height_immediately`,
which changes each of five rows in both directions so both placements are
covered without the test knowing which is which.
### The fling was `List`'s alone
`Scroll` dropped its released velocity on the floor, with a comment
explaining that the areas it wrapped were at most a screenful. That
stopped being true when a code fence became one. `iris::sense::Flinger`
is `List`'s fling as a type both use: the curve, the clock (started at
the first tick, not the release, so a caller on an explicit clock is not
handed a fling that already expired), the incremental delta, Compose's
two release thresholds and the trace line. It deliberately does not know
which way a positive delta moves the content or where the content ends,
because a `List` and a `Scroll` answer those oppositely -- the caller
applies `tick`'s delta in its own convention and calls `stop` at its own
wall. `Scroll` also gains the two things a coasting widget needs and had
no reason to have: the real display density (a hardcoded 1.0 is what made
a one-second coast run for 45 on a list) and `PressState::scrolling`, so
a finger on a coasting fence stops it from the first sample rather than
after `DRAG_SLOP`.
Tests: three in `scroll.rs`, plus `fence_fling.rs`, which flicks a real
fence in the real transcript screen and reads that fence's own `Scroll`
back out of what was drawn -- found by downcasting through
`UiRenderState::active`, since there is no handle to it from outside and
a bare coordinate would only prove that *something* moved.
### The workaround list
`transcript-ui/src/tool.rs` no longer flattens its two `Span`s into one,
so a tool group holds its cards 4dp off its own edge again. "A `Span` of
`Pad`ded children inside another `Span` places those children a slot out
of step" is **not reproducible on 2026-09-08** -- checked with the
headless render that found it (`IRIS_TOOLS_EXPANDED=1
iris/run-headless.sh transcript --shot -- -p transcript-ui`, cards
correct with the spans nested) and pinned at layer 1 by
`a_span_of_padded_children_inside_a_span_draws_each_where_its_box_is`.
Worth knowing for the next one of these: that layer-1 test does **not**
reproduce the fault outside a `List` — the plain nested-span shape passed
immediately, and it only became a faithful reproduction of the real tree
once the row was placed inside a masked `List`, which is where `place`'s
oversized measurement pass lives.
The other two entries there are missing capabilities rather than defects
being dodged, and each now carries its diagnosis in `IRIS_TODO.md`
instead of a workaround: **overflow ellipsis** (parley has none, and the
easy implementation breaks every byte-offset consumer of `TextBuffer` --
spans, `byte_at`, `Selection`, `apply_delta` -- so it needs a displayed
string distinct from the source, plus an `Overflow` enum in place of
`TextAttrs::wrap`), and **selectable tool-card text** (no key collision
to design around, since a tools row has no markdown blocks at all; the
cost is the register/unregister lifecycle across the three routes that
rebuild a card, which is exactly where a stale `Selection` handle
panics).
+15 -8
View File
@@ -714,17 +714,24 @@ 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: MotionAction::Up => {
// 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 => {
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);
} }
// A cancel ends the press -- a release that never arrives
// leaves whichever widget took pointer capture holding it
// forever -- but it is **not** a release, and saying so is
// `CursorState::cancelled`. It used to take the `Up` arm, so
// the system's own swipe up from the bottom edge to leave the
// app (moves, then `ACTION_CANCEL`) reached iris as a flick
// released at speed, and the transcript flung while the app
// was in the background: Iris's 2026-09-08 "leaving and
// reopening the app also randomly moved the vertical scroll".
MotionAction::Cancel => {
ui_state.cursor.pos = vec2(x, y);
ui_state.cursor.buttons.left.update(false);
ui_state.cursor.cancelled = true;
}
_ => return false, _ => return false,
} }
if trace_input { if trace_input {
+12 -4
View File
@@ -42,9 +42,11 @@ pub enum TouchAction {
Move, Move,
Up, Up,
/// The gesture taken away by the system (a parent view claiming it, a /// The gesture taken away by the system (a parent view claiming it, a
/// call arriving). It ends the press exactly as `Up` does -- a /// call arriving, the swipe up from the bottom edge to leave the
/// release that never arrives leaves pointer capture held forever -- /// app). It ends the press, because a release that never arrives
/// which is why a replay file can say it. /// leaves pointer capture held forever -- but it is not a release,
/// and nothing follows from it: no tap, no selection, no fling. See
/// `CursorState::cancelled`, which is what it sets.
Cancel, Cancel,
} }
@@ -393,7 +395,13 @@ impl Harness {
self.cursor.buttons.left.update(true); self.cursor.buttons.left.update(true);
} }
TouchAction::Move => {} TouchAction::Move => {}
TouchAction::Up | TouchAction::Cancel => self.cursor.buttons.left.update(false), TouchAction::Up => self.cursor.buttons.left.update(false),
// The platform taking the gesture away, not the finger
// lifting -- see `CursorState::cancelled`.
TouchAction::Cancel => {
self.cursor.buttons.left.update(false);
self.cursor.cancelled = true;
}
} }
// Layer 1's half of `iris::input` (`sense::log_input_event`'s own // Layer 1's half of `iris::input` (`sense::log_input_event`'s own
// doc): no batching happens here, so `historical` is always empty // doc): no batching happens here, so `historical` is always empty
+92
View File
@@ -963,3 +963,95 @@ fn a_scroll_area_opens_at_the_start_of_content_it_has_not_measured_yet() {
); );
} }
} }
/// docs/IRIS_TODO.md's "A `Span` of `Pad`ded children inside another
/// `Span` places those children a slot out of step", worked around in
/// `transcript-ui/src/tool.rs` by flattening the two spans into one --
/// which costs a tool group the inset its cards should sit inside.
///
/// The shape is the smallest one that reproduced it there: an outer
/// `Span(DOWN)` whose second child is another `Span(DOWN)` whose children
/// are each a `Pad` around a fixed-height rect. Each rect is asserted to
/// be *drawn* where its own box is -- `primitive_corners` rather than
/// `window_region`, since the report is about what is on screen and the
/// two resolve the move chain differently.
#[test]
fn a_span_of_padded_children_inside_a_span_draws_each_where_its_box_is() {
const PAD: f32 = 4.0;
const ROW: f32 = 20.0;
const HEADER: f32 = 30.0;
let mut rsc = TestRsc {
ui: UiData::default(),
};
let header_fill = rsc.ui.widgets.add_strong(Rect::new(UiColor::RED)).any();
let header_id = header_fill.id();
let header = rsc.ui.widgets.add_strong(Sized {
inner: header_fill,
x: None,
y: Some(Len::abs(HEADER)),
});
let mut inner = Span::empty(Dir::DOWN);
let mut rects = Vec::new();
for _ in 0..3 {
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
rects.push(rect.weak());
let sized = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(),
x: None,
y: Some(Len::abs(ROW)),
});
let padded = rsc.ui.widgets.add_strong(Pad {
padding: Padding::uniform(PAD),
inner: sized.any(),
});
let fill = rsc.ui.widgets.add_strong(Rect::new(UiColor::BLUE)).any();
let card = rsc.ui.widgets.add_strong(Stack {
children: vec![fill, padded.any()],
size: StackSize::Child(1),
});
let wide = rsc.ui.widgets.add_strong(Sized {
inner: card.any(),
x: Some(Len::rest(1.0)),
y: None,
});
inner.push(wide.any());
}
let inner = rsc.ui.widgets.add_strong(inner);
let outer = rsc.ui.widgets.add_strong(Span {
children: vec![header.any(), inner.any()],
dir: Dir::DOWN,
gap: Len::ZERO,
});
let mut list = List::new(Axis::Y);
list.push_back(ListRow::new(0, outer.any()));
let list = rsc.ui.widgets.add_strong(list);
let root = rsc
.ui
.widgets
.add_strong(Masked {
shape: None,
inner: list.any(),
})
.any();
let mut render = UiRenderState::new();
render.resize((200.0, 400.0));
render.update(&root, &mut rsc);
render.update(&root, &mut rsc);
let head_slot = render
.first_primitive(header_id)
.expect("the header drew a primitive");
let head_top = render.primitive_corners(head_slot, &rsc).top_left.y;
for (i, rect) in rects.iter().enumerate() {
let want = head_top + HEADER + (ROW + 2.0 * PAD) * i as f32 + PAD;
let slot = render
.first_primitive(rect.id())
.expect("each rect drew a primitive");
let drawn = render.primitive_corners(slot, &rsc);
assert!(
(drawn.top_left.y - want).abs() < 0.01,
"row {i} should be drawn at y={want}, got {drawn:?}"
);
}
}
+225
View File
@@ -151,6 +151,26 @@ pub struct CursorState {
/// which is exactly the inferred-as-measured number UI_RULES.md /// which is exactly the inferred-as-measured number UI_RULES.md
/// forbids. Carrying the sample's own time makes the span real. /// forbids. Carrying the sample's own time makes the span real.
pub time: Instant, pub time: Instant,
/// The platform took this gesture away rather than the finger
/// finishing it -- Android's `ACTION_CANCEL`, or a
/// `TouchAction::Cancel` line in a `harness` replay. (The winit
/// backend drives a mouse, which the platform never takes away
/// mid-gesture, so it has nothing to set this from yet; a touch
/// path there would set it from `TouchPhase::Cancelled`.)
///
/// It is **not** a release. Every widget tracking the press is sent
/// [`CursorSense::Cancel`], including whoever holds pointer capture,
/// and nothing follows from it: no tap, no selection, and above all
/// no fling. Reporting it as an ordinary `PressEnd` instead is what
/// made leaving the app move the transcript on Iris's phone
/// (2026-09-08): the swipe up from the bottom edge to go home is
/// delivered to the app as moves and then a cancel, so a cancel read
/// as a release handed the list the swipe's own velocity and it flung
/// while nobody was looking.
///
/// Set for the one sample that carries it and cleared by
/// [`CursorState::end_frame`], like `scroll_delta`.
pub cancelled: bool,
} }
impl Default for CursorState { impl Default for CursorState {
@@ -161,6 +181,7 @@ impl Default for CursorState {
buttons: CursorButtons::default(), buttons: CursorButtons::default(),
scroll_delta: Vec2::ZERO, scroll_delta: Vec2::ZERO,
time: Instant::now(), time: Instant::now(),
cancelled: false,
} }
} }
} }
@@ -202,6 +223,7 @@ impl CursorState {
pub fn end_frame(&mut self) { pub fn end_frame(&mut self) {
self.buttons.end_frame(); self.buttons.end_frame();
self.scroll_delta = Vec2::ZERO; self.scroll_delta = Vec2::ZERO;
self.cancelled = false;
} }
} }
@@ -372,6 +394,33 @@ impl SensorUi for UiRenderState {
}; };
let button_down = cursor.buttons.select(&CursorButton::Left).is_on(); let button_down = cursor.buttons.select(&CursorButton::Left).is_on();
// The platform took the gesture away (`CursorState::cancelled`).
// Everybody still tracking this press hears about it -- the
// capture holder included, which is what makes this different
// from the loser cancels at the end of an ordinary dispatch --
// and no other sense is derived from this sample, because there
// is no press left to derive one from. Nothing may follow: not a
// tap, not a selection, and not a fling.
if cursor.cancelled {
let captured = pointer.captured.take();
let mut told: Vec<WidgetId> = captured.into_iter().collect();
for id in pointer.pressed.drain(..) {
if Some(id) != captured {
told.push(id);
}
}
requests.release();
for id in told {
deliver_cancel(self, rsc, state, id, &cursor, window_size, &requests);
}
// Deliberately not `requests.holder()`: a cancel handler has
// no gesture left to claim, so one that asked for the pointer
// anyway is refused here rather than left holding a capture
// that no release will ever reach.
rsc.events_mut().get_type::<CursorSense>().global = pointer;
return;
}
// Exclusive pointer capture (`PointerRequests::capture`): once // Exclusive pointer capture (`PointerRequests::capture`): once
// some widget has committed to a drag, every other widget sees // some widget has committed to a drag, every other widget sees
// nothing from this pointer at all -- no hover, no click, no press // nothing from this pointer at all -- no hover, no click, no press
@@ -1216,6 +1265,19 @@ impl DragGesture {
// leaving the arbiter open is the fault this sense was added // leaving the arbiter open is the fault this sense was added
// for, since its origin then measures the *next* touch and // for, since its origin then measures the *next* touch and
// pans by the distance between two unrelated fingers. // pans by the distance between two unrelated fingers.
//
// **Unless this gesture is the winner.** A `Cancel` goes to
// every widget that was handed a frame of the press and did
// not capture it (`PointerInput`'s doc), and one gesture is
// routinely driven by several of those: a transcript row's
// text block feeds `transcript_ui::Selection`'s shared
// `DragGesture`, which captures under the *list's* id -- so
// the block is a "loser" on the very frame its own gesture
// committed. Acting on that would release the pan the frame
// it started. `id` is what this gesture captures as, so
// comparing it against the holder is exactly the question
// "was it us that won".
CursorSense::Cancel if pointer.holder() == Some(id) => GestureOutcome::Undecided,
CursorSense::Cancel => { CursorSense::Cancel => {
if crate::diagnostics::trace_enabled() { if crate::diagnostics::trace_enabled() {
log::debug!( log::debug!(
@@ -1891,6 +1953,169 @@ impl FlingCalculator {
} }
} }
/// One fling in flight: the physics ([`FlingCalculator`]) plus how much of
/// its total travel has already been applied, so a tick only ever hands
/// back this frame's *incremental* delta.
///
/// It owns the curve and the clock and nothing else. Which way a positive
/// delta moves the content, and whether the content has anywhere left to
/// go, are the caller's -- a `List` scrolls its anchor one way and a
/// `Scroll` moves its `amt` the other, and a `Flinger` that tried to know
/// which would have to be told, which is the same thing as not knowing.
/// So a caller applies [`Self::tick`]'s delta in its own convention and
/// calls [`Self::stop`] when it runs out of content.
///
/// Every scrolling widget in this crate flings through this one type,
/// which is what Iris's 2026-09-08 "flinging doesn't work in horizontal
/// scroll areas -- flinging should be enabled by default in all scroll
/// areas on android to match compose's behavior" asks for: Compose's
/// `scrollable` attaches `ScrollableDefaults.flingBehavior()` on every
/// axis, and it is not something a caller opts into.
pub struct Flinger {
fling: Option<InFlight>,
}
struct InFlight {
calc: FlingCalculator,
velocity: f32,
/// When the curve begins -- **the first [`Flinger::tick`], not the
/// release**. Set there so the only clock this reads is the one its
/// driver hands it: a caller running frames on an explicit clock
/// (`iris::harness`, `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. In a running app the difference is at most one
/// frame, since that is how soon a fling is first ticked.
started_at: Option<Instant>,
applied: f32,
}
impl Default for Flinger {
fn default() -> Self {
Self::new()
}
}
impl Flinger {
pub fn new() -> Self {
Self { fling: None }
}
/// Start a fling at `velocity_px_per_s`, in whatever pixel space the
/// caller applies [`Self::tick`]'s delta in. `density` is physical
/// pixels per dp, from the painter -- it does **not** cancel out of
/// the spline (see [`FlingCalculator`]), and a hardcoded 1.0 against a
/// 2.55-density screen made a one-second coast run for 45.
///
/// Answers whether a fling actually started, which is a caller's cue
/// to register for frames (`UiData::animate`): registering a widget
/// that is not animating only asks the next frame to find that out.
/// Cancels any fling already in progress.
///
/// Compose's two thresholds at a release, and **only** those two. The
/// maximum is `ViewConfiguration.getScaledMaximumFlingVelocity()`
/// (8000dp/s), which `DragGestureNode.sendDragStopped` passes into
/// `VelocityTracker.calculateVelocity(maximumVelocity)`; it is applied
/// here rather than in the tracker because the tracker works in pixels
/// and has no density. The minimum is 1px/s, from
/// `DefaultFlingBehavior.performFling`'s `abs(initialVelocity) > 1f`
/// and its own stated reason ("we need it since spline curve gives us
/// NaNs") -- **not**
/// `ViewConfiguration.getScaledMinimumFlingVelocity()`'s 50dp/s, whose
/// single use in either artifact is `NestedScrollInteropConnection`,
/// for View interop. A 50dp/s floor would swallow slow, deliberate
/// releases that Compose flings.
pub fn start(&mut self, velocity_px_per_s: f32, density: f32) -> bool {
// 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). A plain `assert!` rather
// than a `debug_assert!`: it is one comparison per *gesture*, and
// every build anybody runs -- the emulator's and Iris's phone's --
// is release, where a debug-only guard against silently wrong
// output is no guard at all (docs/REVIEW-2026-09-07.md's R1).
assert!(velocity_px_per_s.is_finite());
assert!(density.is_finite() && density > 0.0);
let max = MAX_FLING_VELOCITY_DP_S * density;
let velocity_px_per_s = velocity_px_per_s.clamp(-max, max);
if velocity_px_per_s.abs() <= 1.0 {
self.fling = None;
return false;
}
self.fling = Some(InFlight {
calc: FlingCalculator::new(density),
velocity: velocity_px_per_s,
started_at: None,
applied: 0.0,
});
true
}
/// Whether a fling is in flight. What a caller polls to decide whether
/// a fresh press is a *catch* ([`PressState::scrolling`]) and when to
/// stop driving [`Self::tick`].
pub fn is_flinging(&self) -> bool {
self.fling.is_some()
}
/// The velocity a fling in progress is coasting at, `None` at rest --
/// what a test reads to see what a release actually measured, at the
/// place it landed.
pub fn velocity(&self) -> Option<f32> {
self.fling.as_ref().map(|f| f.velocity)
}
/// End any fling with no further movement -- the next touch-down's
/// job (Android's `Scroller::abortAnimation`, which the view is
/// likewise expected to call: the curve has no idea a finger came back
/// down), and equally what a caller calls when the content has run out
/// underneath it.
pub fn stop(&mut self) {
self.fling = None;
}
/// Advance to `now` and answer how far to move the content *this*
/// frame, in the caller's own sign convention. `0.0` with nothing
/// flinging, so a caller does not need to check first; the fling ends
/// itself on the spline's own schedule, after which
/// [`Self::is_flinging`] is false and the caller stops asking for
/// frames.
pub fn tick(&mut self, now: Instant) -> f32 {
let Some(f) = &mut self.fling else {
return 0.0;
};
let elapsed = now.saturating_duration_since(*f.started_at.get_or_insert(now));
let target = f.calc.position_at(f.velocity, elapsed);
let delta = target - f.applied;
f.applied = target;
// 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). Gated on
// `iris::diagnostics::trace_enabled` since 2026-09-07 (docs/
// RUST.md's review, D1): one line per fling *tick*, unconditional,
// was enough on its own to help fill the log ring.
if crate::diagnostics::trace_enabled() {
log::debug!(
target: "iris::frame",
"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(f.velocity, elapsed),
f.velocity,
f.calc.distance(f.velocity) - target,
);
}
if elapsed >= f.calc.duration(f.velocity) {
self.fling = None;
}
delta
}
}
#[cfg(test)] #[cfg(test)]
mod velocity_tracker_tests { mod velocity_tracker_tests {
use super::*; use super::*;
+104 -118
View File
@@ -224,10 +224,10 @@ pub struct List {
/// row is evicted (`pop_front`/`pop_back`) so this cannot grow past /// row is evicted (`pop_front`/`pop_back`) so this cannot grow past
/// however many rows are currently loaded. /// however many rows are currently loaded.
heights: HashMap<RowKey, f32>, heights: HashMap<RowKey, f32>,
/// A fling in progress, or `None` if the list is at rest -- see /// The list's fling, shared with every other scrolling widget in the
/// `fling`/`tick_fling`/`is_scrolling`, IRIS_TODO.md's "swiping has no /// crate -- see `fling`/`tick_fling`/`is_scrolling`, IRIS_TODO.md's
/// momentum." /// "swiping has no momentum."
fling: Option<Fling>, fling: Flinger,
/// What `tick_fling` re-arms every frame a fling is still running, so /// What `tick_fling` re-arms every frame a fling is still running, so
/// the list keeps animating without needing a caller to poll it -- /// the list keeps animating without needing a caller to poll it --
/// set once via `set_redraw_handle` by whoever owns the surface this /// set once via `set_redraw_handle` by whoever owns the surface this
@@ -262,27 +262,6 @@ pub struct List {
at_end: bool, at_end: bool,
} }
/// One in-flight fling: the physics answer (`FlingCalculator`) plus how
/// much of its total distance has already been applied to the anchor, so
/// `tick_fling` only ever moves the list by this frame's *incremental*
/// delta -- matching every other place in this widget that scrolls by
/// writing `anchor.offset`.
struct Fling {
calc: FlingCalculator,
velocity: f32,
/// 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,
}
impl List { impl List {
pub fn new(axis: Axis) -> Self { pub fn new(axis: Axis) -> Self {
Self { Self {
@@ -294,7 +273,7 @@ impl List {
snap_end: true, snap_end: true,
viewport_len: 0.0, viewport_len: 0.0,
last_viewport_len: 0.0, last_viewport_len: 0.0,
fling: None, fling: Flinger::new(),
redraw: None, redraw: None,
density: 1.0, density: 1.0,
at_start: false, at_start: false,
@@ -482,46 +461,13 @@ impl List {
/// (`bench_client.rs`'s fling phase, the headless tests) calls /// (`bench_client.rs`'s fling phase, the headless tests) calls
/// `tick_fling` directly instead and does not register. /// `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- // No anchor means this list has never drawn, so there is nothing
// near-zero span, or a caller passing a raw device value straight // to move: `Flinger` cannot know that and this does.
// through) would propagate silently into `deceleration_for`'s if self.anchor.is_none() {
// `.ln()` -- the fling either never settles or jumps to NaN self.fling.stop();
// positions with nothing on screen saying why (docs/
// REVIEW-2026-09-06.md finding 3). A plain `assert!` rather than a
// `debug_assert!`: it is one comparison per *gesture*, and every
// build anybody runs -- the emulator's and Iris's phone's -- is
// release, where a debug-only guard against silently wrong output
// is no guard at all (docs/REVIEW-2026-09-07.md's R1).
assert!(velocity_px_per_s.is_finite());
// Compose's two thresholds at a release, and **only** those two.
//
// The maximum is `ViewConfiguration.getScaledMaximumFlingVelocity()`
// (8000dp/s), which `DragGestureNode.sendDragStopped` passes into
// `VelocityTracker.calculateVelocity(maximumVelocity)`. It is
// applied here rather than in the tracker because the tracker
// works in pixels and has no density; this widget takes one from
// the painter in `draw`.
//
// The minimum is 1px/s, from `DefaultFlingBehavior.performFling`'s
// `abs(initialVelocity) > 1f` and its own stated reason ("we need
// it since spline curve gives us NaNs") -- not
// `ViewConfiguration.getScaledMinimumFlingVelocity()`'s 50dp/s,
// which Compose's scrolling never consults: its single use in
// either artifact is `NestedScrollInteropConnection`, for View
// interop. A 50dp/s floor would swallow slow, deliberate releases
// that Compose flings, so it is deliberately not here.
let max = MAX_FLING_VELOCITY_DP_S * self.density;
let velocity_px_per_s = velocity_px_per_s.clamp(-max, max);
if velocity_px_per_s.abs() <= 1.0 || self.anchor.is_none() {
self.fling = None;
return; return;
} }
self.fling = Some(Fling { self.fling.start(velocity_px_per_s, self.density);
calc: FlingCalculator::new(self.density),
velocity: velocity_px_per_s,
started_at: None,
applied: 0.0,
});
} }
/// Whether a fling is currently animating. What a caller's own /// Whether a fling is currently animating. What a caller's own
@@ -529,7 +475,7 @@ impl List {
/// (`bench_client.rs`'s fling phase) or to decide whether the list is /// (`bench_client.rs`'s fling phase) or to decide whether the list is
/// "moving on its own" for any other purpose. /// "moving on its own" for any other purpose.
pub fn is_scrolling(&self) -> bool { pub fn is_scrolling(&self) -> bool {
self.fling.is_some() self.fling.is_flinging()
} }
/// The velocity a fling in progress is coasting at, in this list's /// The velocity a fling in progress is coasting at, in this list's
@@ -540,13 +486,13 @@ impl List {
/// measured at the place it landed, rather than re-timing the /// measured at the place it landed, rather than re-timing the
/// gesture itself. /// gesture itself.
pub fn fling_velocity(&self) -> Option<f32> { pub fn fling_velocity(&self) -> Option<f32> {
self.fling.as_ref().map(|f| f.velocity) self.fling.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) {
self.fling = None; self.fling.stop();
} }
/// Advance an in-flight fling to `now`, applying this call's share of /// Advance an in-flight fling to `now`, applying this call's share of
@@ -561,47 +507,22 @@ impl List {
/// Safe to call even with no fling active (a no-op returning `false`), /// Safe to call even with no fling active (a no-op returning `false`),
/// so a caller does not need to check `is_scrolling` first. /// so a caller does not need to check `is_scrolling` first.
pub fn tick_fling(&mut self, now: Instant) -> bool { pub fn tick_fling(&mut self, now: Instant) -> bool {
let Some(f) = &mut self.fling else { let Some(velocity) = self.fling.velocity() else {
return false; return false;
}; };
let elapsed = now.saturating_duration_since(*f.started_at.get_or_insert(now)); let delta = self.fling.tick(now);
let target = f.calc.position_at(f.velocity, elapsed);
let delta = target - f.applied;
f.applied = target;
let settled_on_schedule = elapsed >= f.calc.duration(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.
// Gated on `iris::diagnostics::trace_enabled` since 2026-09-07
// (docs/RUST.md's review, D1): one line per fling *tick*,
// unconditional, was enough on its own to help fill the log
// ring -- see `android::view::IrisViewPeer::render`'s own doc for
// the same finding on its two per-frame lines.
if crate::diagnostics::trace_enabled() {
log::debug!(
target: "iris::frame",
"iris 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
// it (or one moving toward the end that has already reached that) // it (or one moving toward the end that has already reached that)
// stops rather than continuing to spend its remaining distance on // stops rather than continuing to spend its remaining distance on
// a part of the list that will never scroll further. // a part of the list that will never scroll further. `at_start`/
let hit_bound = (velocity < 0.0 && self.at_start) || (velocity > 0.0 && self.at_end); // `at_end` are what the last draw found, which is the only thing
// here that knows where the content ends.
if settled_on_schedule || hit_bound { if (velocity < 0.0 && self.at_start) || (velocity > 0.0 && self.at_end) {
self.fling = None; self.fling.stop();
}
if !self.fling.is_flinging() {
return false; return false;
} }
if let Some(redraw) = &self.redraw { if let Some(redraw) = &self.redraw {
@@ -1002,12 +923,23 @@ impl List {
/// skip-or-move `draw_inner` already provides for an unchanged or /// skip-or-move `draw_inner` already provides for an unchanged or
/// merely-translated widget. Caching the height once measured is what /// merely-translated widget. Caching the height once measured is what
/// lets an already-seen row go back to that cheap path while a /// lets an already-seen row go back to that cheap path while a
/// first-seen one still gets a correctly-sized initial paint. A stale /// first-seen one still gets a correctly-sized initial paint.
/// cached height (the row's content changed height since) briefly ///
/// offers the wrong box; the height recorded from what it *actually* /// **A row whose measurement disagrees with the box it was offered is
/// reports this frame corrects it starting next frame -- the same /// drawn again, this frame, at the box its own height implies** --
/// one-frame lag `Scroll`'s own content-length cache accepts, per /// both placements, since both offer a cached height and both can be
/// LAYOUT.md. /// wrong the frame a row's content changes size. This is not an
/// optimisation to skip: a row is routinely `.background(rect(..))`
/// (a tool card *is* one), and `Rect::draw` fills whatever region it
/// is handed, so a row offered last frame's height paints its
/// background at last frame's height while its text lays out at the
/// new one -- Iris's 2026-09-08 report that "collapsing and opening an
/// edit card draws the card background a frame late, so it looks
/// closed even when there's text". A `reposition` does not fix it
/// (it writes an offset, never a size), which is what the bottom-
/// anchored half used to do. The extra draw happens only on the frame
/// a row actually changes height, which is a frame that was already
/// redrawing that row.
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`/ // Every current caller derives `slot` from `repair_anchor`/
// `prev_slot`/`next_slot`, which already check existence -- but // `prev_slot`/`next_slot`, which already check existence -- but
@@ -1069,6 +1001,10 @@ impl List {
let region = Self::abs_region(axis, top, top + h); let region = Self::abs_region(axis, top, top + h);
let used = painter.widget_within(self.slot_widget(slot), region); let used = painter.widget_within(self.slot_widget(slot), region);
let height = resolve(used); let height = resolve(used);
if height != h {
let corrected = Self::abs_region(axis, top, top + height);
painter.widget_within(self.slot_widget(slot), corrected);
}
(top, top + height, height) (top, top + height, height)
} }
(Placement::Top(top), None) => { (Placement::Top(top), None) => {
@@ -1085,17 +1021,8 @@ impl List {
let used = painter.widget_within(self.slot_widget(slot), region); let used = painter.widget_within(self.slot_widget(slot), region);
let height = resolve(used); let height = resolve(used);
if height != h { if height != h {
// The row's real height changed since it was cached
// (and was therefore redrawn -- an unchanged widget
// never disagrees with its own cache). It painted
// anchored at the *offered* box's leading edge
// (`bottom - h`, per every widget in this crate's
// top-left-anchoring convention), not where its true
// height means its bottom edge should be; correct with
// an O(1) reposition, `Aligned`'s own trick for this
// exact "learned a size after already drawing" case.
let corrected = Self::abs_region(axis, bottom - height, bottom); let corrected = Self::abs_region(axis, bottom - height, bottom);
painter.reposition(self.slot_widget(slot), corrected); painter.widget_within(self.slot_widget(slot), corrected);
} }
(bottom - height, bottom, height) (bottom - height, bottom, height)
} }
@@ -1392,6 +1319,17 @@ mod tests {
/// background rect's own id (to check what it actually painted at) /// background rect's own id (to check what it actually painted at)
/// alongside the row widget. /// alongside the row widget.
fn background_styled_row(rsc: &mut TestRsc, height: f32) -> (WidgetId, StrongWidget) { fn background_styled_row(rsc: &mut TestRsc, height: f32) -> (WidgetId, StrongWidget) {
let (bg_id, _, row) = resizable_background_row(rsc, height);
(bg_id, row)
}
/// [`background_styled_row`] with the foreground's own `Sized` handed
/// back too, so a test can change the row's height the way a tool card
/// being collapsed or opened does.
fn resizable_background_row(
rsc: &mut TestRsc,
height: f32,
) -> (WidgetId, WeakWidget<Sized>, StrongWidget) {
let bg = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); let bg = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let bg_id = bg.id(); let bg_id = bg.id();
let fg_rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); let fg_rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
@@ -1400,11 +1338,59 @@ mod tests {
x: None, x: None,
y: Some(Len::abs(height)), y: Some(Len::abs(height)),
}); });
let fg_weak = fg.weak();
let stack = Stack { let stack = Stack {
children: vec![bg.any(), fg.any()], children: vec![bg.any(), fg.any()],
size: StackSize::Child(1), size: StackSize::Child(1),
}; };
(bg_id, rsc.ui.widgets.add_strong(stack).any()) (bg_id, fg_weak, rsc.ui.widgets.add_strong(stack).any())
}
/// Iris's 2026-09-08 report: "collapsing and opening an edit card
/// draws the card background a frame late, so it looks closed even
/// when there's text, and then looks open even when the text is
/// collapsed."
///
/// A row is offered a box sized to its *cached* height, and a
/// `.background(rect(..))` fills whatever box it is given -- so on the
/// frame a row changes height its text is laid out at the new height
/// and its background painted at the old one. Every row is exercised,
/// in both directions, because which of `place`'s two placements a row
/// takes depends on where it sits relative to the anchor and the fault
/// was in both.
#[test]
fn a_row_that_changes_height_draws_its_background_at_the_new_height_immediately() {
for key_to_change in 0..5u64 {
for new_height in [50.0f32, 8.0] {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = List::new(Axis::Y);
let mut rows = Vec::new();
for key in 0..5u64 {
let (bg_id, fg, row) = resizable_background_row(&mut rsc, 20.0);
rows.push((bg_id, fg));
list.push_back(ListRow::new(key, row));
}
let (_, root) = add_list(&mut rsc, list);
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
render.update(&root, &mut rsc);
let (bg_id, fg) = rows[key_to_change as usize];
rsc.ui.widgets.get_mut(&fg).unwrap().y = Some(Len::abs(new_height));
render.update(&root, &mut rsc);
let px = render.active[&bg_id].region.to_px((100.0, 100.0).into());
let drawn = px.size().y;
assert!(
(drawn - new_height).abs() < 0.5,
"row {key_to_change} resized to {new_height}px drew its background at {drawn}px on the same frame"
);
}
}
} }
#[test] #[test]
+241 -18
View File
@@ -1,5 +1,5 @@
use crate::prelude::*; use crate::prelude::*;
use crate::sense::{DragGesture, GestureOutcome, PointerRequests, PressState}; use crate::sense::{DragGesture, Flinger, GestureOutcome, PointerRequests, PressState};
use std::time::Instant; use std::time::Instant;
pub struct Scroll { pub struct Scroll {
@@ -26,9 +26,40 @@ pub struct Scroll {
/// `sense.rs` and only what a committed pan *means* is decided here. /// `sense.rs` and only what a committed pan *means* is decided here.
/// See [`Self::drag`]. /// See [`Self::drag`].
gesture: DragGesture, gesture: DragGesture,
/// The momentum a release leaves behind, the same [`Flinger`] a
/// `List` coasts on. Every scroll area flings, on either axis and
/// with nothing to opt into -- Compose's `scrollable` attaches
/// `ScrollableDefaults.flingBehavior()` on every axis it is given,
/// and Iris asked for the same (2026-09-08: "flinging should be
/// enabled by default in all scroll areas on android to match
/// composes behavior").
fling: Flinger,
/// Physical pixels per dp, copied from the painter on every draw --
/// what a fling's deceleration is computed against. 1.0 until this
/// widget has drawn once, which is also the only state in which
/// nothing can be flung, since there is no content length yet.
density: f32,
} }
impl Widget for Scroll { impl Widget for Scroll {
/// A `Scroll` animates exactly one thing, its fling. The registration
/// that makes this run is `UiData::animate`, which
/// `WidgetLike::scroll_area`'s own drag handler calls the frame a
/// release starts one.
fn tick(&mut self, now: Instant) -> bool {
let delta = self.fling.tick(now);
self.scroll(delta);
// A fling must not keep spending its distance on content that is
// not there. Unlike `List`, this widget knows exactly where its
// content ends -- `update_amt` has just clamped `amt` into it --
// so the wall is read after the move rather than from what the
// last draw found.
if self.amt <= 0.0 || self.amt >= self.scroll_range() {
self.fling.stop();
}
self.fling.is_flinging()
}
fn draw(&mut self, painter: &mut Painter) -> Size { fn draw(&mut self, painter: &mut Painter) -> Size {
// The region offered to the child is sized using *last* frame's // The region offered to the child is sized using *last* frame's
// content length, not a fresh measurement -- deliberately, so that // content length, not a fresh measurement -- deliberately, so that
@@ -58,6 +89,11 @@ impl Widget for Scroll {
let axis = self.axis; let axis = self.axis;
let container_len = painter.px_size().axis(axis); let container_len = painter.px_size().axis(axis);
self.container_len = container_len; self.container_len = container_len;
// Learned from the frame rather than passed in: a fling's
// deceleration is a physical quantity and needs the real display
// density, and `draw` is where this widget meets the only thing
// that knows it.
self.density = painter.density();
if self.snap_end if self.snap_end
&& let Some(content_len) = self.content_len && let Some(content_len) = self.content_len
@@ -111,6 +147,8 @@ impl Scroll {
container_len: 0.0, container_len: 0.0,
content_len: None, content_len: None,
gesture: DragGesture::on(axis), gesture: DragGesture::on(axis),
fling: Flinger::new(),
density: 1.0,
} }
} }
@@ -128,11 +166,12 @@ impl Scroll {
/// takes the gesture over. Android's own `EditText` behaves the same /// takes the gesture over. Android's own `EditText` behaves the same
/// way -- a vertical drag scrolls, and only a long press selects. /// way -- a vertical drag scrolls, and only a long press selects.
/// ///
/// No fling: unlike `List`, `Scroll` has no per-frame tick to animate /// Answers whether this frame *started a fling*, which is the
/// one with (`List::set_redraw_handle`/`tick_fling`), and the areas /// caller's cue to register the widget for frames
/// this wraps today -- a six-line composer, a diagnostics pane -- are /// (`UiData::animate`) -- see [`Widget::tick`]. Split that way
/// at most a screenful, where Android does not fling either. The /// because the two halves have different owners: the velocity is this
/// released velocity is deliberately dropped rather than approximated. /// widget's business and whether anything animates at all is the
/// frame loop's, and `drag` has no `Rsc` to reach the loop through.
pub fn drag( pub fn drag(
&mut self, &mut self,
pointer: &PointerRequests, pointer: &PointerRequests,
@@ -140,17 +179,27 @@ impl Scroll {
sense: CursorSense, sense: CursorSense,
pos_window: Vec2, pos_window: Vec2,
now: Instant, now: Instant,
) { ) -> bool {
// A default `PressState`: a scroll area has no selection of its // A scroll area has no selection of its own to extend, so a drag
// own to extend, so a horizontal drag stays `Undecided` and a // across the axis stays `Undecided` and one along it past the
// vertical one past the slop pans, which is the whole contract // slop pans, which is the whole contract here. A caller that
// here; and it never flings (see this method's doc), so there is // *does* own a selection (the transcript's `Selection`) drives
// never a moving target to catch either. A caller that *does* own
// a selection, or a fling (the transcript's `Selection`), drives
// `DragGesture` itself instead. // `DragGesture` itself instead.
//
// `scrolling` is the other half: a finger put down on content
// that is still coasting means "stop it here", and commits to a
// pan on that very sample with no slop to wait out
// (`DragArbiter::press_start`). The fling is cancelled in the
// same breath, since the curve has no idea a finger came back
// down.
let mut press = PressState::default();
if self.gesture.starts_press(sense) {
press.scrolling = self.fling.is_flinging();
self.fling.stop();
}
match self match self
.gesture .gesture
.handle(pointer, id, sense, pos_window, now, PressState::default()) .handle(pointer, id, sense, pos_window, now, press)
{ {
// `scroll(dy)`, not `scroll(-dy)` -- `Selection::drag` passes // `scroll(dy)`, not `scroll(-dy)` -- `Selection::drag` passes
// `-dy` to `List::scroll` because a `List`'s anchor offset and // `-dy` to `List::scroll` because a `List`'s anchor offset and
@@ -161,12 +210,28 @@ impl Scroll {
// holds for both, and the one to check a sign against, is that // holds for both, and the one to check a sign against, is that
// the content follows the finger. // the content follows the finger.
GestureOutcome::Pan(dy) => self.scroll(dy), GestureOutcome::Pan(dy) => self.scroll(dy),
// Same sign as `Pan`, since `tick` applies it through the
// same `scroll`.
GestureOutcome::Released(Some(v)) => {
return self.fling.start(v, self.density);
}
GestureOutcome::Undecided GestureOutcome::Undecided
| GestureOutcome::Tapped | GestureOutcome::Tapped
| GestureOutcome::SelectStart | GestureOutcome::SelectStart
| GestureOutcome::SelectExtend | GestureOutcome::SelectExtend
| GestureOutcome::Cancelled | GestureOutcome::Cancelled
| GestureOutcome::Released(_) => {} | GestureOutcome::Released(None) => {}
}
false
}
/// How far this area can be panned: the content's length past the
/// container's, or zero when it all fits. The one arithmetic
/// `update_amt`'s clamp and `tick`'s wall both ask for, stated once.
fn scroll_range(&self) -> f32 {
match self.content_len {
Some(len) => (len - self.container_len).max(0.0),
None => 0.0,
} }
} }
@@ -178,10 +243,10 @@ impl Scroll {
/// question that cannot be answered yet -- answering it anyway is /// question that cannot be answered yet -- answering it anyway is
/// what `content_len`'s doc describes. /// what `content_len`'s doc describes.
pub fn update_amt(&mut self) { pub fn update_amt(&mut self) {
let Some(content_len) = self.content_len else { if self.content_len.is_none() {
return; return;
}; }
let len = (content_len - self.container_len).max(0.0); let len = self.scroll_range();
self.amt = self.amt.clamp(0.0, len); self.amt = self.amt.clamp(0.0, len);
self.snap_end = self.amt == len; self.snap_end = self.amt == len;
} }
@@ -193,6 +258,13 @@ impl Scroll {
self.amt self.amt
} }
/// Which way this area pans. For a caller that found the widget
/// rather than built it -- a test walking what is drawn, a scroll
/// indicator asking which edge to sit on.
pub fn axis(&self) -> Axis {
self.axis
}
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();
@@ -361,4 +433,155 @@ mod tests {
); );
assert!((s.amt - 0.0).abs() < 0.01, "amt={}", s.amt); assert!((s.amt - 0.0).abs() < 0.01, "amt={}", s.amt);
} }
/// Iris, 2026-09-08: "Flinging doesn't work in horizontal scroll
/// areas. Flinging should be enabled by default in all scroll areas
/// on android to match composes behavior." A release with real
/// velocity coasts, decelerating, and settles on its own.
#[test]
fn a_released_pan_flings_and_settles() {
for axis in [Axis::X, Axis::Y] {
let (_ui, mut s, id) = area();
s.axis = axis;
s.gesture = DragGesture::on(axis);
let render = PointerRequests::default();
let t = Instant::now();
let at = |d: f32| Vec2::from_axis(axis, d, 0.0);
s.drag(
&render,
id,
CursorSense::PressStart(CursorButton::Left),
at(0.0),
t,
);
// Four samples 8ms apart, accelerating away from the start --
// three is the fewest `VelocityTracker`'s quadratic fit can
// use, so this is a gesture that genuinely has a velocity.
for (i, d) in [-40.0, -100.0, -180.0, -280.0].into_iter().enumerate() {
s.drag(
&render,
id,
CursorSense::Pressing(CursorButton::Left),
at(d),
t + Duration::from_millis(8 * (i as u64 + 1)),
);
}
let at_release = s.amt;
s.drag(
&render,
id,
CursorSense::PressEnd(CursorButton::Left),
at(-280.0),
t + Duration::from_millis(32),
);
assert!(
s.fling.is_flinging(),
"{axis:?}: a released pan with velocity must fling"
);
// Frames at 8ms until it stops, with each step no longer than
// the one before it -- a coast that does not decelerate is
// the linear-spline bug this crate has had once already.
let mut last_step = f32::INFINITY;
let mut ticks = 0;
let mut now = t + Duration::from_millis(32);
while s.tick(now) {
let before = s.amt;
now += Duration::from_millis(8);
s.tick(now);
let step = (s.amt - before).abs();
assert!(
step <= last_step + 0.01,
"{axis:?}: the fling sped up: {last_step} then {step}"
);
last_step = step;
ticks += 1;
assert!(ticks < 10_000, "{axis:?}: the fling never settled");
}
assert!(
s.amt > at_release,
"{axis:?}: the fling moved the content the wrong way: {at_release} -> {}",
s.amt
);
}
}
/// The wall: a fling must not spend its remaining distance on content
/// that is not there. Released hard toward the start, it settles
/// exactly on it.
#[test]
fn a_fling_stops_at_the_end_of_the_content() {
// Both walls. A positive delta is applied as `amt -= delta`, so a
// positive velocity runs toward the start of the content and a
// negative one toward its end; 1000px of content in a 100px box
// leaves `amt` in 0..=900.
for (velocity, wall) in [(50_000.0f32, 0.0f32), (-50_000.0, 900.0)] {
let (_ui, mut s, _id) = area();
s.fling.start(velocity, 1.0);
let t = Instant::now();
let mut now = t;
for _ in 0..1_000 {
if !s.tick(now) {
break;
}
now += Duration::from_millis(8);
}
assert!(
!s.fling.is_flinging(),
"the fling toward {wall} ran past the content"
);
assert!(
(s.amt - wall).abs() < 0.01,
"it should have settled on {wall}, got amt={}",
s.amt
);
}
}
/// A finger on coasting content stops it there, from the first
/// sample, with no `DRAG_SLOP` to wait out -- the catch
/// `DragArbiter::press_start` describes, which a scroll area needs
/// for the same reason a list does now that it can coast at all.
#[test]
fn a_press_on_a_coasting_area_catches_it() {
let (_ui, mut s, id) = area();
s.fling.start(-4_000.0, 1.0);
let t = Instant::now();
s.tick(t);
s.tick(t + Duration::from_millis(8));
let caught_at = s.amt;
assert!(s.fling.is_flinging(), "the fixture must still be moving");
let render = PointerRequests::default();
let down = t + Duration::from_millis(16);
s.drag(
&render,
id,
CursorSense::PressStart(CursorButton::Left),
Vec2::new(0.0, 0.0),
down,
);
assert!(!s.fling.is_flinging(), "a touch-down must end the fling");
assert!(
(s.amt - caught_at).abs() < 0.01,
"the down itself must not move the content, only stop it"
);
// A move well under `DRAG_SLOP` still tracks the finger, because
// this press caught something that was moving.
s.drag(
&render,
id,
CursorSense::Pressing(CursorButton::Left),
Vec2::new(0.0, 2.0),
down + Duration::from_millis(8),
);
assert!(
(s.amt - (caught_at - 2.0)).abs() < 0.01,
"a caught press must pan from its first sample: {} -> {}",
caught_at,
s.amt
);
}
} }
+10 -1
View File
@@ -124,11 +124,20 @@ widget_trait! {
// has the arbitration and why there is no fling. The wheel // has the arbitration and why there is no fling. The wheel
// above and this are the two inputs of one scroll, so they // above and this are the two inputs of one scroll, so they
// are registered together rather than left to each caller. // are registered together rather than left to each caller.
.on(CursorSense::drag_senses(), |ctx, rsc| { .on(CursorSense::drag_senses(), |ctx, rsc: &mut Rsc| {
let id = ctx.widget.id(); let id = ctx.widget.id();
let (sense, pos) = (ctx.data.sense, ctx.data.cursor.pos); let (sense, pos) = (ctx.data.sense, ctx.data.cursor.pos);
let flung =
ctx.widget(rsc) ctx.widget(rsc)
.drag(ctx.data.pointer, id, sense, pos, ctx.data.cursor.time); .drag(ctx.data.pointer, id, sense, pos, ctx.data.cursor.time);
// The half that actually makes it move -- a fling is
// set by the widget and driven by the frame loop, and
// only this side can reach the loop. Only when one
// actually started: registering a widget that is not
// animating asks the next frame to find that out.
if flung {
rsc.ui_mut().animate(id);
}
}) })
.add(state) .add(state)
} }
@@ -0,0 +1,123 @@
//! Layer 1 for Iris's 2026-09-08 "flinging doesn't work in horizontal
//! scroll areas": a real markdown fence in the real transcript screen,
//! flicked sideways, has to keep moving after the finger leaves.
//!
//! The fence is pushed here rather than hunted for in the bench fixture,
//! so the test knows which row it is pressing and where. The `Scroll` it
//! asserts on is found by walking what is actually drawn -- there is no
//! handle to it from the outside, and a coordinate would only prove that
//! *something* moved.
use iris::harness::{Harness, TouchAction};
use iris::prelude::*;
use transcript_fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
/// The horizontal scroll area drawn inside `top..bottom`, with the box
/// it was drawn at -- a fence is the only thing in a transcript that pans
/// sideways. Found by walking what is actually drawn, because there is no
/// handle to a fence's own `Scroll` from the outside and a bare
/// coordinate would only prove that *something* moved.
fn fence_scroll_in(h: &Harness, top: f32, bottom: f32) -> Option<(WidgetId, PixelRegion)> {
h.render
.active
.keys()
.copied()
.filter(|&id| {
h.rsc
.ui
.widgets
.get_dyn(id)
.and_then(|w| w.as_any().downcast_ref::<Scroll>())
.is_some_and(|s| s.axis() == Axis::X)
})
.find_map(|id| {
let r = h.render.window_region(&id, &h.rsc)?;
(r.top_left.y >= top && r.bot_right.y <= bottom).then_some((id, r))
})
}
fn amt(h: &Harness, id: WidgetId) -> f32 {
h.rsc
.ui
.widgets
.get_dyn(id)
.and_then(|w| w.as_any().downcast_ref::<Scroll>())
.expect("the fence's scroll area is still drawn")
.amt()
}
#[test]
fn a_flick_across_a_code_fence_keeps_moving_after_the_finger_leaves() {
use client_core::transcript_fold::{TranscriptItem, TranscriptRow};
let mut h = Harness::new(phone_size(), PHONE_SCALE);
let opened = transcript_fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
let screen = opened.screen;
h.frame(0);
h.frame(PHONE_FRAME_MS);
let fence = TranscriptRow::Single(TranscriptItem::AssistantMsg {
seq: 9_000_000,
text: "```\none two three four five six seven eight nine ten eleven twelve \
thirteen fourteen fifteen sixteen seventeen eighteen twenty twentyone\n```"
.to_string(),
settled: true,
});
screen.push_row(&mut h.rsc, &fence);
(screen.list)(&mut h.rsc).jump_to_end();
h.frame(100);
h.frame(108);
let key = transcript_ui::row::row_key(&fence.key());
let (top, bottom) = (screen.list)(&mut h.rsc)
.extent(key)
.expect("the fence row is on screen");
let (fence_scroll, box_) = fence_scroll_in(&h, top, bottom)
.expect("the pushed fence draws a horizontal scroll area of its own");
assert_eq!(amt(&h, fence_scroll), 0.0, "a fence opens at its start");
// Down the middle of the fence's own box, so the press is on the
// text inside the scroll area rather than on the row's sender label.
let y = (box_.top_left.y + box_.bot_right.y) / 2.0;
// A flick sideways: four samples 8ms apart, accelerating, then the
// finger leaves.
h.touch(TouchAction::Down, Vec2::new(900.0, y), 200);
for (i, x) in [860.0, 800.0, 720.0, 620.0].into_iter().enumerate() {
h.touch(TouchAction::Move, Vec2::new(x, y), 208 + 8 * i as u64);
}
h.touch(TouchAction::Up, Vec2::new(620.0, y), 240);
let at_release = amt(&h, fence_scroll);
assert!(
at_release > 0.0,
"the flick itself must have panned the fence, got {at_release}"
);
// Frames for the next half second, with nothing touching the screen.
let mut t = 240;
while t <= 740 {
h.frame(t);
t += PHONE_FRAME_MS;
}
let coasted = amt(&h, fence_scroll);
assert!(
coasted > at_release + 1.0,
"the fence stopped dead at the release: {at_release} -> {coasted}"
);
// ...and it settles rather than running forever.
let settled = coasted;
while t <= 4_000 {
h.frame(t);
t += PHONE_FRAME_MS;
}
let after = amt(&h, fence_scroll);
assert!(
after >= settled,
"a fling must not run backwards: {settled} -> {after}"
);
let last = after;
h.frame(t);
assert_eq!(last, amt(&h, fence_scroll), "the fling never settled");
}
@@ -0,0 +1,206 @@
//! Layer 1 of docs/RUST.md's "Three test layers" for a gesture the
//! *platform* takes away, over the real transcript screen and the real
//! bench fixture.
//!
//! Both halves of Iris's 2026-09-08 report about the transcript moving on
//! its own live here. A cancel is not a release, so nothing may follow it
//! -- and every widget that was tracking the press has to hear about it,
//! or the next press anywhere on screen is measured from the origin the
//! abandoned one left behind.
use iris::harness::{Harness, TouchAction, TouchScript};
use iris::prelude::*;
use transcript_fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
fn opened() -> (Harness, transcript_ui::TranscriptScreen) {
let mut h = Harness::new(phone_size(), PHONE_SCALE);
let opened = transcript_fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
h.frame(0);
h.frame(PHONE_FRAME_MS);
(h, opened.screen)
}
fn script(name: &str, text: &str) -> TouchScript {
TouchScript::parse(text).unwrap_or_else(|e| panic!("{name}: {e}"))
}
/// Where the content actually is, in window pixels: the top of whichever
/// row is under the middle of the viewport, tracked by key. The anchor's
/// own `idx/off` display is not that -- the list rehomes its anchor to a
/// different row without the content moving at all -- so a test asserting
/// "nothing moved" reads a row's own extent, the way `catch_a_fling.rs`
/// does.
fn tracked_row(h: &mut Harness, screen: &transcript_ui::TranscriptScreen) -> (RowKey, f32) {
let middle = phone_size().y / 2.0;
let list = (screen.list)(&mut h.rsc);
let key = list.key_at(middle).expect("a row under the viewport");
let (top, _) = list.extent(key).expect("that row has an extent");
(key, top)
}
fn row_top(h: &mut Harness, screen: &transcript_ui::TranscriptScreen, key: RowKey) -> f32 {
(screen.list)(&mut h.rsc)
.extent(key)
.expect("the tracked row is still loaded")
.0
}
/// The system's own swipe up from the bottom edge to leave the app is
/// delivered to the app as moves and then `ACTION_CANCEL`. Read as a
/// release it hands the list that swipe's velocity, and the transcript
/// flings while nobody is looking -- "leaving and reopening the app also
/// randomly moved the vertical scroll".
#[test]
fn a_cancelled_flick_does_not_fling() {
let (mut h, screen) = opened();
let flick = script(
"flick-cancelled",
include_str!("../touch/flick-cancelled.touch"),
);
h.replay(&flick);
assert_eq!(
(screen.list)(&mut h.rsc).fling_velocity(),
None,
"a gesture the platform took away must not fling"
);
// ...and it must not be moving on its own over the following second
// either, which is what a fling started some other way would look
// like.
let (key, settled) = tracked_row(&mut h, &screen);
let end = flick.end_ms() + 1_000;
let mut t = flick.end_ms();
while t <= end {
h.frame(t);
t += PHONE_FRAME_MS;
}
let now = row_top(&mut h, &screen, key);
assert!(
(now - settled).abs() < 0.5,
"the list kept moving after a cancelled gesture: {settled} -> {now}"
);
}
/// The other half, and the one that made a *later* touch snap: a cancel
/// has to reach every widget that was handed a frame of the press, so the
/// gesture it was driving forgets its origin. Without it the arbiter is
/// still open with the abandoned press's touch-down as its origin, and
/// the next press is measured from there -- a jump the size of the
/// distance between two unrelated touches.
#[test]
fn a_press_ended_by_a_cancel_leaves_no_origin_for_the_next_one() {
let (mut h, screen) = opened();
// Press near the top of the transcript and let the platform take it.
h.touch(TouchAction::Down, Vec2::new(540.0, 700.0), 0);
h.touch(TouchAction::Cancel, Vec2::new(540.0, 700.0), 8);
let (key, before) = tracked_row(&mut h, &screen);
// A plain tap, a long way down the screen from where that press
// started. It must move nothing at all.
h.touch(TouchAction::Down, Vec2::new(540.0, 1900.0), 200);
h.touch(TouchAction::Up, Vec2::new(540.0, 1900.0), 250);
let after = row_top(&mut h, &screen, key);
assert!(
(after - before).abs() < 0.5,
"a tap after a cancelled press panned the list by {}px, the distance between them",
after - before
);
assert_eq!(
(screen.list)(&mut h.rsc).fling_velocity(),
None,
"and it must not have flung either"
);
}
/// The report itself: "if I scroll in a horizontal area and then tap in a
/// vertical area, it seems to snap."
///
/// A markdown fence pans sideways through its own `Scroll`, which takes
/// pointer capture the moment it commits. Everything else that was handed
/// a frame of that press is told so with `CursorSense::Cancel` -- and the
/// widget the press actually landed on is the fence's own text block,
/// which drives `transcript_ui::Selection`'s shared `DragGesture`. A
/// block that does not register `Cancel` never hears it, so the gesture
/// stays open with the fence's touch-down as its origin and the next
/// press anywhere is measured from there.
///
/// The fence is pushed here rather than hunted for in the fixture, so the
/// test knows exactly which row it is pressing and where.
#[test]
fn panning_a_code_fence_then_tapping_elsewhere_moves_nothing() {
use client_core::transcript_fold::{TranscriptItem, TranscriptRow};
let (mut h, screen) = opened();
let fence = TranscriptRow::Single(TranscriptItem::AssistantMsg {
seq: 9_000_000,
text: "```\none two three four five six seven eight nine ten eleven twelve\n\
thirteen fourteen fifteen sixteen seventeen eighteen nineteen\n```"
.to_string(),
settled: true,
});
// A plain paragraph under it, because the tap has to land on
// ordinary text: a tap that happens to hit a tool group's header
// toggles it, and a row changing height moves the list for a reason
// that has nothing to do with this.
let para = TranscriptRow::Single(TranscriptItem::AssistantMsg {
seq: 9_000_001,
text: "A plain paragraph with nothing to tap in it, only words, so that a \
press here is a press on ordinary text and nothing else."
.to_string(),
settled: true,
});
screen.push_row(&mut h.rsc, &fence);
screen.push_row(&mut h.rsc, &para);
(screen.list)(&mut h.rsc).jump_to_end();
h.frame(100);
h.frame(108);
// Press in the middle of the fence's own row, so the gesture starts on
// the text block inside the scroll area rather than in a gap.
let key = transcript_ui::row::row_key(&fence.key());
let (top, bottom) = (screen.list)(&mut h.rsc)
.extent(key)
.expect("the fence row is on screen");
let y = (top + bottom) / 2.0;
assert!(
y > 0.0 && y < phone_size().y,
"the fence row has to be on screen to be pressed: {top}..{bottom}"
);
// Sideways, well past `DRAG_SLOP`, so the fence commits and captures.
h.touch(TouchAction::Down, Vec2::new(800.0, y), 200);
for (i, x) in [760.0, 700.0, 620.0, 540.0].into_iter().enumerate() {
h.touch(TouchAction::Move, Vec2::new(x, y), 208 + 8 * i as u64);
}
h.touch(TouchAction::Up, Vec2::new(540.0, y), 248);
let (tracked, before) = tracked_row(&mut h, &screen);
// A tap on the paragraph, a long way down the screen from where that
// pan started.
let para_key = transcript_ui::row::row_key(&para.key());
let (ptop, pbottom) = (screen.list)(&mut h.rsc)
.extent(para_key)
.expect("the paragraph row is on screen");
h.touch(
TouchAction::Down,
Vec2::new(540.0, (ptop + pbottom) / 2.0),
400,
);
h.touch(
TouchAction::Up,
Vec2::new(540.0, (ptop + pbottom) / 2.0),
450,
);
let after = row_top(&mut h, &screen, tracked);
assert!(
(after - before).abs() < 0.5,
"a tap after panning a code fence moved the transcript by {}px",
after - before
);
}
@@ -0,0 +1,11 @@
# `flick-120hz.touch` to the sample, with the platform taking the gesture
# away instead of the finger lifting -- Android's `ACTION_CANCEL`, which
# is what the swipe up from the bottom edge to leave the app delivers
# after its moves. Nothing may follow from it: no tap, no selection and,
# the one that showed on Iris's phone, no fling.
0 down 540 1000
4 move 540 1040
8 move 540 1086
12 move 540 1138
16 move 540 1196
20 cancel 540 1196
+14 -8
View File
@@ -217,9 +217,9 @@ where
let tap_links = links.clone(); let tap_links = links.clone();
field field
// `| CursorSense::unclick()` on top of the usual click-or-drag set // The whole `drag_senses()` set, which is what every widget
// -- this block's own registration only ever needs to see a // driving a `DragGesture` registers. This block normally only sees
// gesture's *first* frame (`PressStart`, or a `Pressing` that // a gesture's *first* frames (`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
// id and every further frame, including the terminal `Drop`, // id and every further frame, including the terminal `Drop`,
@@ -227,9 +227,16 @@ where
// `iris::sense`'s pointer-capture doc for why that has to be a // `iris::sense`'s pointer-capture doc for why that has to be a
// stable id rather than this row's, which `List` can retire mid- // stable id rather than this row's, which `List` can retire mid-
// drag as content scrolls. // drag as content scrolls.
.on( //
CursorSense::click_or_drag() | CursorSense::unclick(), // `Cancel` is the one that is *not* optional, and leaving it out
move |ctx, rsc| { // is what made Iris's 2026-09-08 "scroll a horizontal area, then
// tap in a vertical one, and it snaps": a cancel is delivered to
// the widget that was **pressed**, not to whoever holds the
// capture, so when a code fence inside this block panned sideways
// and took the pointer, nothing ever told the shared gesture its
// press was over. It stayed open with the fence's touch-down as
// its origin, and the next press anywhere was measured from there.
.on(CursorSense::drag_senses(), move |ctx, rsc| {
let (pos, size, cursor) = (ctx.data.pos, ctx.data.size, ctx.data.cursor.pos); let (pos, size, cursor) = (ctx.data.pos, ctx.data.size, ctx.data.cursor.pos);
let outcome = selection.borrow_mut().drag( let outcome = selection.borrow_mut().drag(
rsc, rsc,
@@ -256,8 +263,7 @@ where
<Rsc::State as OpenUrl>::open_url(ctx.state, &url); <Rsc::State as OpenUrl>::open_url(ctx.state, &url);
} }
} }
}, })
)
.add(rsc); .add(rsc);
// The column holds the *framed* widget; the field is what // The column holds the *framed* widget; the field is what
+26 -20
View File
@@ -89,6 +89,8 @@ const GAP_DP: f32 = 8.0;
const RAW_RADIUS_DP: f32 = 4.0; const RAW_RADIUS_DP: f32 = 4.0;
/// The room inside a verbatim block. /// The room inside a verbatim block.
const RAW_PAD_DP: f32 = 8.0; const RAW_PAD_DP: f32 = 8.0;
/// How far a group holds its cards off its own edge (`ToolRows.kt`).
const GROUP_INSET_DP: f32 = 4.0;
/// How much of a tool's output an open card draws before it offers the /// How much of a tool's output an open card draws before it offers the
/// rest behind a tap. /// rest behind a tap.
@@ -190,9 +192,10 @@ fn on_tap<Rsc: HasEvents>(
Rsc::State: FocusHost + OpenUrl, Rsc::State: FocusHost + OpenUrl,
{ {
let (list, selection) = (shared.list, shared.selection.clone()); let (list, selection) = (shared.list, shared.selection.clone());
ptr.on( // The whole `drag_senses()` set -- what any widget driving a
CursorSense::click_or_drag() | CursorSense::unclick(), // `DragGesture` registers, `Cancel` included. See `row.rs`'s twin
move |ctx, rsc| { // registration for what leaving `Cancel` out did.
ptr.on(CursorSense::drag_senses(), move |ctx, rsc| {
let outcome = selection.borrow_mut().drag( let outcome = selection.borrow_mut().drag(
rsc, rsc,
list, list,
@@ -205,8 +208,7 @@ fn on_tap<Rsc: HasEvents>(
if outcome == GestureOutcome::Tapped { if outcome == GestureOutcome::Tapped {
f(rsc); f(rsc);
} }
}, })
)
.add(rsc); .add(rsc);
} }
@@ -665,22 +667,26 @@ where
.add_strong(rsc) .add_strong(rsc)
.any(), .any(),
); );
// The cards go straight into the group's own `Span`, not into a // The cards sit in their own `Span` inside the group's, inset from
// second one inside it. **A `Span` of `Pad`ded children inside another // its edge the way `ToolRows.kt` insets them.
// `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 // This shape was flattened into one `Span` between 2026-09-06 and
// as empty bars with somebody else's summary in them. Bisected on // 2026-09-08 to work around "a `Span` of `Pad`ded children inside
// 2026-09-06 against `iris/run-headless.sh transcript` with // another `Span` places those children a slot out of step", which
// `IRIS_TOOLS_EXPANDED=1`: removing the inner `Span` fixes it and // cost the group that inset. **Not reproducible on 2026-09-08**:
// removing the cards' own `Pad` fixes it, while the card background, // `IRIS_TOOLS_EXPANDED=1 iris/run-headless.sh transcript --shot` puts
// the `Sized` wrappers and the per-card `WidgetPtr` all make no // every card's content in its own box with the two spans nested, and
// difference. It is a framework defect rather than this file's -- // `iris`'s `a_span_of_padded_children_inside_a_span_draws_each_where_
// docs/RUST.md's P1b box and docs/IRIS_TODO.md carry the repro -- and // its_box_is` pins that at layer 1. Something between those dates
// one `Span` is the shape that works today. What it costs is the 4dp // fixed it -- most likely f5b8893's `mov`-vs-`reposition` work or the
// inset the Compose group holds its cards off its edge by; the cards' // nested-mask pass -- so the workaround is gone rather than kept
// own padding stands in for it. // against a defect that no longer exists.
{
let mut cards = Span::empty(Dir::DOWN);
for index in 0..count { for index in 0..count {
group.push(build_card_ptr(rsc, shared, index).0); cards.push(build_card_ptr(rsc, shared, index).0);
}
group.push(cards.pad(dp(GROUP_INSET_DP)).add_strong(rsc).any());
} }
// Shutting it from here anchors the other end: the reader is at the // 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 // bottom of a long group, and what they are looking at is what follows