iris: a press only reaches the widget the pointer is on

Iris's 2026-09-08 report, both halves, and her own diagnosis of the
second: "tapping outside of something that a fling is currently active
for should have no code in common with the fling that could influence
it."

`run_sensors` runs a widget one frame after the pointer leaves it
(`ActivationState::End`, which is not `Off`) so `HoverEnd` can fire, and
`should_run` derived the press and wheel senses from raw button state
without consulting `hover`. That farewell frame carried a `PressStart` to
a widget the finger was nowhere near -- and a press on already-coasting
content is a catch, which commits to a pan with no `DRAG_SLOP`, so the
widget captured the pointer and swallowed the whole gesture. Its hover
was stale because a gesture that ends while captured returns from the
capture branch, which never reaches the loop that updates it.

Measured before the fix on the real screen: a fence flicked sideways,
then a finger down on a row 500px above it dragged 160px down the screen
-- the list moved by zero, the fence moved by zero, and the fence held
the pointer throughout. After: the list follows the finger and the
fence's fling carries on coasting, which is what she asked for and falls
out of the fix rather than being arranged.

`should_run` now requires `hover.is_on()` for every non-hover sense.
`Drop`/`Cancel` are unaffected -- they are delivered deliberately to a
widget that is not under the pointer, with an explicit `On`.

Also: the composer is clipped to its own bar rather than inside its
padding (`.masked_by(rect(BAR_FILL))` in place of a `.masked()` +
`.background()` pair) -- "the box should be clipped rather than the inset
text". A long message was being sliced mid-glyph 12dp in from the bar's
edge, leaving a band of bare surface above the cut.

New: `Scroll::is_scrolling`, the name `List` already uses; the phone
rig's `--message TEXT` and `--ime PX`, since the composer's overflowing
and keyboard-open states cannot otherwise be looked at headlessly.

Tests fail on the old code, one per layer:
`a_press_does_not_reach_a_widget_the_pointer_has_just_left` (sensors, no
screen) and `a_drag_away_from_a_coasting_fence_scrolls_the_list_and_
leaves_it_coasting` (the report itself, layer 1).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-08 16:23:32 -04:00
1 parent cc8148cbec
commit 5e34dba2fd
7 files changed
+438 -22

No files matched your search

+79
View File
@@ -1529,3 +1529,82 @@ This does not reopen the 2026-09-07 platform-fonts decision. Body and
monospace text still come from the platform's own collection; an icon is
the opposite case, a small closed set of codepoints no system font is
guaranteed to have, and it is the same division the Compose app makes.
## 2026-09-08: a press only reaches what the pointer is actually on
Iris's report -- "if I try to scroll vertically while a horizontal scroll
animation is still active, it stays locked to the horizontal scroll. It
should let it keep going and instead only affect vertical scrolling" --
and her own diagnosis of it, which was the right one: "it seems like iris
is set up so the animation stuff is global which it definitely should not
be. Tapping outside of something that a fling is currently active for
should have no code in common with the fling that could influence it."
It was global, and it was in `sense::should_run`. `run_sensors` runs a
widget one frame *after* the pointer leaves it (`ActivationState::End`,
which is not `Off`) so a `HoverEnd` can fire, and `should_run` derived
`PressStart`/`Pressing`/`PressEnd`/`Scroll` from the raw button and wheel
state without consulting `hover` at all. So that farewell frame carried a
press to a widget the finger was nowhere near.
That alone would have been a stray event; what made it eat the gesture is
the catch added on 2026-09-07 (`PressState::scrolling`), which commits a
press on already-moving content to a pan immediately, with no `DRAG_SLOP`
-- so the widget captured the pointer on that frame and every later sample
went to it. And the widget's hover was stale in the first place because a
gesture that ends while captured returns from `run_sensors`' capture
branch, which never reaches the loop that would have updated it.
Measured on the real screen before the fix: a fence flicked sideways, then
a finger put down on a row **500px above it** and dragged 160px down the
screen. The list moved by zero, the fence moved by zero, and the fence
held the pointer for the whole gesture -- the report, exactly.
- **`should_run` now requires `hover.is_on()` for every non-hover sense.**
Press and wheel both, since a wheel event reaching a widget the cursor
has just left is the same fault with a different sense. `Drop` and
`Cancel` are unaffected: they are delivered deliberately to a widget
that is *not* under the pointer, and `run_sensors` hands both an
explicit `On`.
- **`Scroll::is_scrolling`** (new): whether a fling is coasting in this
area, the same question and the same name `List::is_scrolling` already
answers for the other scrolling widget.
Nothing about the fling, the arbiter or the catch changed. A press outside
a coasting area now has no code in common with it, so the horizontal fling
keeps coasting through a vertical drag on its own -- which is the second
half of what Iris asked for, and it falls out of the fix rather than being
arranged. A press *inside* a coasting area is still a catch on either
axis, which is what Compose does ("Compose does catch no matter what axis
if you tap in the horizontal area").
Two tests, one per layer:
`sense_tests::a_press_does_not_reach_a_widget_the_pointer_has_just_left`
is the mechanism with two stacked scroll areas and no screen, and
`fence_fling.rs`'s
`a_drag_away_from_a_coasting_fence_scrolls_the_list_and_leaves_it_coasting`
is the report itself over the real transcript. Both fail on the old code.
## 2026-09-08: the composer is clipped to its bar, not inside its padding
Iris: "the message input box doesn't clip correctly ... the box should be
clipped rather than the inset text."
The composer was `.masked().background(rect(...))` -- two boxes, one
inside the other. The mask sat *inside* the `dp(FIELD_PAD_DP)` padding, so
a message longer than the six lines shown was cut through the middle of a
glyph 12dp in from the bar's edge, with a band of bare surface above the
cut. Measured at the phone's own size and density (1080x2424 at 2.55): the
bar's top edge at y=1995.6 and the text sliced at y=2026.2.
It is `.masked_by(rect(BAR_FILL))` now: the same rect is the surface drawn
behind the field *and* the shape the field is clipped to, so the two
cannot fall out of step -- the idiom `row.rs` already uses to cut a code
fence to its own rounded panel. Text now disappears under the bar's edge
at 1995.6. The padding still holds text off the edge at the end the
content is anchored to, which is the end anybody is reading.
The composer's overflowing and keyboard-open states had no way to be
looked at headlessly, since that window has no keyboard: the phone rig
takes `--message TEXT` and `--ime PX` for them
(`transcript-fixture/examples/phone.rs`, through `RUN_HEADLESS_ARGS`).