Compare commits
3
Commits
9e301f30c6
...
fe7dc9c728
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe7dc9c728 | ||
|
|
02b277e7ad | ||
|
|
fc82d9d7e8 |
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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).
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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, ¶);
|
||||||
|
(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(¶.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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user