iris: a mask is a shape, not a rectangle -- .masked_by, and touch obeys it

Iris, on the code fence: "the code block scrolling currently masks in an
inner rectangle. Ideally masks should have a shape associated with them,
rounded rectangle being one of them ... so that the mask becomes the
parent container with rounded edges. Make sure alpha works properly with
it, eg. on the corners where alpha should be decreased / multiplied."

`Mask` is now `{ primitive, parent }` -- the slot of a primitive already
written, plus the mask this one nests inside. The fragment stage
evaluates that primitive's own coverage at the masked pixel, through the
same `rounded_rect_coverage` a drawn rect goes through, and multiplies it
into the alpha along the whole `parent` chain. Nothing about the shape is
copied, so a rounded container's corner and its children's clipped corner
are one piece of arithmetic and cannot drift; two nested feathers dim a
pixel twice, which is the multiply she asked for.

`.masked()` is unchanged for callers: it writes an undrawn rect
(`Drawn::No`/`NOT_DRAWN` -- owned, moved, resized and freed like any
other primitive, simply never rasterized) and points at that, so square
clipping is the same mechanism rather than a special case. New
`.masked_by(shape)` draws `shape` behind the content in its own layer and
clips to the first primitive it drew, with no radius written twice; it
replaces `.masked().background(w)`, which drew both and clipped to the
box. `transcript-ui`'s `BlockFrame::Verbatim` is the first caller.

Hit-testing applies the shape (`SensorUi::run_sensors` ->
`UiRenderState::mask_admits`, coverage above one half, which is where the
drawn edge is), as well as the widget's own box -- the two ask different
questions and both have to hold. `primitive_corners` is a floor-for-floor
transliteration of the shader's `corners_of`, not `region.to_px()`: the
phone's 2.55 density puts nothing on a whole pixel, and skipping the
rounding disagrees with the pixels by up to one along each edge.

A mask's shape must be a rect, asserted by name in `set_mask_to`. A glyph
would need a CPU-side alpha plane before the hit test could agree with
the shader, and a standalone image a bind-group switch the fragment stage
cannot make. So no texture mask exists; the branch where one would go is
in both copies of `mask_coverage`. docs/LAYOUT.md's section end lists this
and the three other places the code is narrower than the design.

Tests. Layer 1, `layout_tests.rs`: the child's coverage swept across the
container's corner arc equals the container's own exactly; nested masks
multiply rather than intersect, asserted where both feathers are partial,
which is the only place the two differ; a press in a rounded-away corner
misses while one inside the curve and one on a straight edge hit; and
`a_plain_mask_still_clips_to_a_square_box`, the half this had no reason to
touch. The first version of the corner test swept the straight chord
between the arc's ends, which lies inside the circle everywhere -- it
proved nothing and said so, which is why it counts both sides now.

`iris/tests/mask_sdf.rs` is the only test here that needs a GPU: it lifts
`distance_from_rect` and `rounded_rect_coverage` out of
`iris_core::SHAPE_SHADER` by name -- lifted, not copied, since a copy
would be edited alongside the shader -- and runs them in a compute pass
over ~200k points at five radii against `iris_core::rounded_rect_coverage`.
Worst disagreement under 1e-5; the negative control (`+ 0.01` inside the
shader's smoothstep) fails it at 0.03.

Layer 2 for looking: `./run-headless.sh phone --phone --shot /tmp/mask.png
--seconds 6 -- -p transcript-fixture` draws the fixture's horizontally
scrolled code fence clipped on the curve at both top corners.

Two things found on the way and fixed here:

- The winit backend had the defect the Android one was fixed for in
  85869d0 -- `Backends::PRIMARY` and an `.expect` on the adapter. This
  VM's Venus device disappears when the host runs out of virgl contexts,
  which happened mid-task, and layer 2 aborted with `Could not get
  adapter!` while GL sat there working. It probes and rebuilds the
  instance on `Backends::GL` exactly as Android does now, and the request
  names the backends it tried. The rule had been written on one member of
  a set of two.
- `active_primitive_count` counted mask shapes, so `iris::frame`'s
  `primitives=` -- a number Iris reads off a phone report as "how much is
  on screen" -- would have gained one per masked widget.

`widget_trait!` now accepts a `///` doc comment on its functions, since
`masked_by` is public API and rustdoc is where a contract is read.

docs/LAYOUT.md, docs/RUST.md (both queue boxes, the commands, and where
the GPU test sits among the three layers), docs/IRIS.md and
docs/IRIS_TODO.md ("Masks defined relative to each other", now closed).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-08 02:18:57 -04:00
1 parent 3eb0e033d5
commit 38bf6309cb
23 files changed
+1284 -122

No files matched your search

+42
View File
@@ -8,6 +8,48 @@ capability that moved. Small and trivial changes do not go here.
An entry gives the date, what changed, why, and a short before/after where
it helps judge the change without the session that made it. Newest first.
## 2026-09-08: masks have a shape -- `.masked_by(shape)`, and clipping applies to touch
A mask no longer carries a rectangle. It carries **the slot of a
primitive already drawn**, and the fragment stage evaluates that
primitive's own coverage at each masked pixel and multiplies it into the
alpha -- the same rounded-rect SDF the primitive itself is drawn with.
Nothing about the shape is copied, so a rounded container's corner and
the corner its content is cut to cannot fall out of step, and nested
masks multiply rather than intersect: a pixel inside two feathered
corners is dimmed by both.
// before -- the mask clipped to the padded box, the rounding was
// only painted behind it, and the two knew nothing of each other
field.scrollable_on(Axis::X)
.masked()
.pad(dp(FRAME_PAD_DP))
.background(rect(fill).radius(dp(FRAME_RADIUS_DP)))
// after -- one rect, drawn and clipped to
field.scrollable_on(Axis::X)
.pad(dp(FRAME_PAD_DP))
.masked_by(rect(fill).radius(dp(FRAME_RADIUS_DP)))
`.masked()` is unchanged for callers and still clips to the widget's own
box; under it, it now writes an undrawn rect primitive and points the
mask at that, so square-cornered clipping is the same mechanism rather
than a special case. `.masked_by(shape)` draws `shape` behind the
content, in its own layer, and clips to the first primitive it drew.
There is no radius or shape argument anywhere -- that is the point.
**A press now has to be inside the shape, not just the box.** A corner
the container rounded away is not there to be tapped, which needed the
coverage function on the CPU as well as in the shader;
`iris/tests/mask_sdf.rs` runs the shader's own text against the Rust one
over a grid of points so the two cannot drift apart.
One limit worth knowing before reaching for it: **a mask's shape must be
a rect**, asserted by name. Clipping to a glyph or an image would need,
respectively, a CPU-side alpha plane for the hit test and a bind-group
switch the fragment stage cannot make. The shader has the branch where
either would go.
## 2026-09-07: iris runs on a GLES-only Android device, and reports the renderer it cannot build
`AndroidRenderer::new` asked wgpu for `Backends::PRIMARY`, which does not
+23 -8
View File
@@ -701,12 +701,15 @@ Iris's report, verbatim, with a screenshot. Phone: Mali-G715 (Vulkan),
has no rules for stays plain rather than being coloured by the
nearest one's.
- [ ] **Masks defined relative to each other.** Wanted: mask A multiplies
by something *and also* applies mask B — a mask can reference a parent
mask, the way the move chain references a parent offset. Today masks
are independent regions. Design it beside the move chain (same shape:
a parent index and a bounded walk in the shader); do it when a real
widget needs it, not before.
- [x] **Masks defined relative to each other. (Done: chaining
2026-09-07 in d507ae4, the multiply 2026-09-08.)** Built exactly
beside the move chain, as this asked: `Mask::parent` is a slot index
and the fragment stage walks it under the same bound the move chain
uses. Each step multiplies the referenced primitive's coverage into
the pixel's alpha, so a pixel inside two feathered corners is dimmed
by both — the "multiplies by something *and also* applies mask B" half.
The real widget that needed it was the transcript's code fence inside
the list. See docs/LAYOUT.md's "Masks with a shape".
- [ ] **Positions as a single float per scroll.** Iris raised, and half
rejected, letting a scroll update one float rather than positions:
input handling cares about most elements in a list, so absolute
@@ -1076,9 +1079,21 @@ do not duplicate it there.
Iris pasted a full Copy report (Mali-G715 Vulkan, 2.55, 120Hz). What it
showed, beyond her words:
- [ ] **"Sometimes when I try to catch it while it's still moving
- [x] **"Sometimes when I try to catch it while it's still moving
(particularly if I drag) then it fails to stop & snap to where finger
is."** The report's release lines show catches ending as
is." (done 2026-09-07, b87f5a5.)** Built as described below.
`DragArbiter::press_start` takes a `PressState` -- what the target
looked like at the moment the press landed -- rather than asking the
list later, because by then the fling has already been cancelled and
the answer is no. The defect layer 1 found doing it: one touch-down
reaches every sensor under the finger, so a block and the tool row
containing it deliver the same `PressStart` twice, and re-reading the
state on the second delivery turned every catch back into an ordinary
slop-waiting press. Tests in
`iris/transcript-fixture/tests/catch_a_fling.rs`, with
`the_same_small_drag_on_a_settled_list_moves_nothing` as the half the
change had no reason to touch. **Not yet confirmed from the phone.**
The original reading follows. The report's release lines show catches ending as
`v=-41`/`v=-274` pans, so the gesture *does* reach `Panning`, but the
content under the finger does not follow it while the fling is still
running and the slop has not been crossed. Compose: a down while
+36 -1
View File
@@ -948,7 +948,7 @@ When this lands, copy this entry into `IRIS.md` (newest first):
> design, the move-offset mechanism this shipped alongside, and the file
> list.
## Masks with a shape (decided 2026-09-07, not yet built)
## Masks with a shape (decided 2026-09-07, built 2026-09-08)
Iris, on the code block's scrolling: "the code block scrolling currently
masks in an inner rectangle. Ideally masks should have a shape
@@ -1039,3 +1039,38 @@ and that the CPU SDF and the shader agree at a grid of points; a
`run-headless.sh --phone` screenshot of a scrolled code block shows
rounded corners with no square pixels poking out at the top and bottom
of the scrolled content. Record the commands in RUST.md when it lands.
### What was built (2026-09-08), and where it differs
The commands and the screenshot are in docs/RUST.md's queue entry. Four
places the code is narrower than the design above, each deliberate:
- **No `kind` and no `flags` on `Mask`.** It is `{ primitive, parent }`.
The referenced instance already carries its own `binding`, so a copy
of it in the mask is a second thing to keep in step; *alpha only* is
the only mode there is, so there is nothing to select. Both are a
field away if a second mode appears.
- **A mask's shape must be a rect.** `Painter::set_mask_to` asserts it,
by name, rather than leaving the shader to read a `rects` entry that
is not there. A glyph would need a CPU-side alpha plane before the
hit test could agree with the shader, and a standalone image needs a
bind-group switch the fragment stage cannot make (`masks_layout`'s own
comment on why an image's bind group must not name the masks buffer).
So **the texture-mask pass condition is not met and no texture mask
exists** — the point of the reference design is that adding one is a
binding check and a sampled alpha, with no new shader path, and the
shader's `mask_coverage` already has the branch where it would go.
- **The shape is a primitive of its own, not always a drawn one.** A
plain `.masked()` writes an undrawn `RectPrimitive` at its region
(`Drawn::No`, `NOT_DRAWN`) and points the mask at that, so "clip to my
box" and "clip to that widget's rounded background" are one mechanism
and square-cornered clipping did not become a special case.
`.masked_by(shape)` draws `shape` behind the content — in its own
layer, the way `Stack` puts a background under its content — and
clips to the first primitive it drew.
- **The CPU/shader agreement is a GPU test**, `iris/tests/mask_sdf.rs`,
the only test in the workspace that needs an adapter. It lifts
`distance_from_rect` and `rounded_rect_coverage` out of
`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
that would be edited alongside.
+137 -20
View File
@@ -609,11 +609,12 @@ In order; two builders at a time. Each is ticked here by the agent that
closes it.
- [x] Test rig, layers 1 and 2 ("Three test layers" below), landed 2026-09-07.
- [ ] Fling parity with Compose, and the phone's keyboard push-up, with
insets shown in the diagnostics overlay. The worktree note here was
stale by 2026-09-07 night: no worktree exists and the keyboard half
is ticked in IRIS_TODO's night entry. What remains is the impulse
estimator item below.
- [x] Fling parity with Compose, and the phone's keyboard push-up, with
insets shown in the diagnostics overlay. Every part is ticked in
IRIS_TODO's 2026-09-07 entries: the keyboard half in the night entry,
the estimator in the "later" one (Lsq2, not Impulse -- see the box
below), and the catch in b87f5a5. **Not yet confirmed from the
phone**, which is what would close it for Iris rather than for us.
- **Orchestrator note, 2026-09-07 late**: the tree was found holding a
non-compiling diff from two killed agents (catch-a-fling in
`sense.rs`/`selection.rs`; shaped masks in the render files). One
@@ -621,6 +622,11 @@ closes it.
one sonnet agent owns report hygiene and the bench header in
`bench_client.rs` and client-core's log ring. If both boxes below
are still open and nothing is running, that work was cut off again.
**2026-09-08: it was cut off again** -- the sonnet agent's two boxes
had landed (7485d78, b8ea723), catch-a-fling had landed unticked
(b87f5a5), and the shaped-mask diff was left uncommitted in the tree
with one failing test. Both are closed below, by one session working
inline rather than by agents. Nothing is running now.
- [x] Rows at the transcript's top edge: culled too early in one state,
drawn through the header in the other (docs/IRIS_TODO.md, 2026-09-07).
Done 2026-09-07, e922b73 + d507ae4; the root causes and the test names
@@ -648,9 +654,17 @@ closes it.
`fonts.xml` monospace declaration against fontique's actually-scanned
families, Android-only, verified `mono=Some("Droid Sans Mono")` on this
checkout's emulator.
- [ ] Scroll clamped at both ends, and Compose's impulse velocity
estimator with min/max fling velocity (docs/IRIS_TODO.md, 2026-09-07
later). After the culling fix lands (same file).
- [x] Scroll clamped at both ends (e922b73, `List::clamp_to_content`)
and Compose's velocity estimator (docs/IRIS_TODO.md, 2026-09-07
later). Ticked 2026-09-08 against those entries, which were already
`[x]` while this box was not. Two things this box's own wording had
wrong, both corrected there by reading Compose's sources: the touch
path is **Lsq2 over absolute positions**, not `Strategy.Impulse`
(Impulse is the mouse-wheel/trackpad path), and there is **no minimum
fling velocity** on it -- `minimumFlingVelocity` belongs to
`NestedScrollInteropConnection`. So iris ports Lsq2, caps at 8000dp/s
and floors at 1px/s, and has no 50dp/s threshold Compose does not
have.
- [x] **APK runtime logs in Dev Updater (Iris, 2026-09-07: "please add
android / apk runtime log support to dev updater").** **Done
2026-09-07** -- dev-updater `013d711`, and this repo's provider half;
@@ -716,9 +730,27 @@ closes it.
APK installable (648 MB) -- RUST.md's logging section names both.
- [ ] Input-event and timing instrumentation into the log ring, copied
by the report button. After the logging route lands (same ring).
- [ ] Catch-a-fling: down during a fling stops it at the down and drags
with no slop (docs/IRIS_TODO.md, night). Opus, next slot; uses the
layer-1 harness.
- [x] **Catch-a-fling (done 2026-09-07, b87f5a5; verified and ticked
2026-09-08).** A down on a moving list ends the fling on that sample
and enters `Panning` with no `DRAG_SLOP` wait, which is Compose's
`scrollable(startDragImmediately = isScrollInProgress)`; a catch
released without moving is `Released(None)` rather than a `Tapped`,
which is also what Compose delivers. `DragArbiter::press_start` takes
a `PressState` -- what the target looked like when the press landed
(`already_selected`, `scrolling`) -- rather than reading the list
again later, because by then the fling it is asking about has already
been cancelled. The defect layer 1 found on the way: one touch-down
reaches every sensor under the finger, so a transcript row's block and
the tool row containing it deliver the same `PressStart` twice, and
re-reading `PressState` on the second delivery turned every catch back
into an ordinary slop-waiting press. Tests:
`transcript-fixture/tests/catch_a_fling.rs` (the recorded 120Hz flick,
150ms of fling, a down and three 2px moves -- the content tracks the
finger sample for sample, failing at the parent commit with "the
content 0.0px"), plus `the_same_small_drag_on_a_settled_list_moves_
nothing`, which is the half the change had no reason to touch: 6px is
inside `DRAG_SLOP`, so pinning the content on *every* press would pass
the first test and quietly take the slop away from every ordinary one.
- [x] **Report hygiene (done 2026-09-07).** Ring takes Debug only from
`iris`/`client_core` targets, Copy report always copies and trims the
log. `client_core::log_ring::ring_accepts` is the one filter (Info+
@@ -806,15 +838,87 @@ closes it.
renderer with the hook: `iris panic at .../render.rs:140:14: Could not
get adapter!: NotFound {...}` in the ring on the run that died, and
`iris app log: the previous run died -- ...` on the next one.
- [ ] Masks with a shape -- docs/LAYOUT.md "Masks with a shape (decided
2026-09-07)". A mask references a primitive already drawn
(rect SDF, texture or glyph alpha), chained and multiplied; `.masked()`
points at the widget's own primitives; hit-testing applies the shape.
**Chaining landed early**, 2026-09-07 (d507ae4): a mask carries the
mask it was set inside and the fragment stage walks that chain, so
nesting works and `Painter::set_mask` no longer aborts on it. Still
rectangles only -- the shape half, and the hit-testing half, are what
is left of this item.
- [x] **Masks with a shape (done 2026-09-08).** docs/LAYOUT.md's "Masks
with a shape" carries the design and, at its end, the four places the
code is deliberately narrower than it. **Chaining landed early**,
2026-09-07 (d507ae4). What landed now is the shape half and the
hit-testing half:
`Mask` is `{ primitive, parent }` -- the slot of a primitive already
written, and the mask this one nests inside. The fragment stage
evaluates that primitive's own coverage *at the masked pixel*
(`mask_coverage` in `shader.wgsl`, the same `rounded_rect_coverage` a
drawn rect goes through) and multiplies it into the pixel's alpha,
walking `parent` and multiplying every coverage on the chain. So the
container's corner and its children's clipped corner are one piece of
arithmetic, and two nested feathers dim a pixel twice -- the "alpha
should be decreased / multiplied" Iris asked for.
`.masked()` is unchanged for callers and writes an undrawn
`RectPrimitive` at its own region (`Drawn::No`/`NOT_DRAWN` -- owned,
moved, resized and freed like any other primitive, simply never
rasterized), so "clip to my box" and "clip to that rounded background"
are one mechanism rather than a square-cornered special case. New:
**`.masked_by(shape)`** draws `shape` behind the content in its own
layer and clips to the first primitive it drew, with no radius passed
twice -- it replaces `.masked().background(w)`, which drew the two and
clipped to the box. `transcript-ui`'s `BlockFrame::Verbatim` is its
first caller, which is the code fence Iris raised this about.
**Hit-testing applies the shape**: `SensorUi::run_sensors` asks
`UiRenderState::mask_admits` (coverage above one half, which is where
the drawn edge is) as well as the widget's own box -- the two ask
different questions and both have to hold. `primitive_corners` is a
`floor`-for-`floor` transliteration of the shader's `corners_of`,
which is the whole reason it is not `region.to_px()`: the phone's 2.55
density puts nothing on a whole pixel, and skipping the rounding
disagrees with the pixels by up to one along each edge.
A mask's shape **must be a rect**, asserted by name in
`Painter::set_mask_to`. A glyph would need a CPU-side alpha plane
before the hit test could agree with the shader, and a standalone
image a bind-group switch the fragment stage cannot make. So the
design's texture-mask pass condition is **not met and no texture mask
exists** -- the branch where one would go is in both `mask_coverage`s.
How it was checked, all four commands:
cd iris && cargo test --workspace # layer 1 + the GPU test
cargo test -p iris --lib layout_tests:: # the four mask tests
cargo test -p iris --test mask_sdf # CPU/shader agreement
./run-headless.sh phone --phone --shot /tmp/mask.png --seconds 6 \
-- -p transcript-fixture # layer 2, for looking
`iris/tests/mask_sdf.rs` is the only test in the workspace that needs
a GPU: it lifts `distance_from_rect` and `rounded_rect_coverage` out
of `iris_core::SHAPE_SHADER` **by name** and runs them in a compute
pass over a grid of ~200k points at five radii, against the CPU
`iris_core::rounded_rect_coverage` -- worst disagreement under 1e-5,
and the negative control (a `+ 0.01` inside the shader's smoothstep)
fails it at 0.03. It lifts rather than copies because a copy would be
edited alongside the shader, which is exactly the drift it exists to
catch. The layer-1 tests are in `layout_tests.rs`: the child's
coverage swept across the container's corner arc equals the
container's own *exactly* (the sweep goes from the arc's centre --
the straight chord between the arc's ends lies inside the circle
everywhere, so the first version of this test proved nothing and said
so); nested masks multiply rather than intersect, asserted where both
feathers are partial, which is the only place the two differ; a press
in a rounded-away corner misses while one inside the curve and one on
a straight edge hit; and `a_plain_mask_still_clips_to_a_square_box`,
the half the shape work had no reason to touch. The screenshot shows
the fixture's horizontally scrolled code fence clipped on the curve at
both top corners with no square pixels outside it.
**Also landed here, and not on this item's list**: the winit backend
had the same defect the Android one was fixed for in `85869d0` -- it
asked for a `Backends::PRIMARY` adapter and `.expect`ed one. This VM's
Venus device disappears whenever the host runs out of virgl contexts,
which happened mid-task, and layer 2 aborted with `Could not get
adapter!` while GL sat there working. `default::render::UiRenderer::
new` now probes and rebuilds the instance on `Backends::GL` exactly as
Android does, and the adapter request names the backends it tried. The
rule was written on one member of a set of two; this is the other.
- [ ] Compose app: the `Reversed range` crash in `ToolInput.highlighted`
(docs/TODO.md). Main branch, not rustify.
@@ -891,6 +995,19 @@ runs inside `cargo test`.
layer 1 records that the platform was asked and layer 2 has no
Android platform to ask.
**The one exception, added 2026-09-08**: `iris/tests/mask_sdf.rs`
needs a GPU but no compositor and no window -- it asks wgpu for an
adapter, runs two functions lifted out of `shader.wgsl` itself in a
compute pass, and compares the answers with the CPU transliteration
in `iris_core::render::sdf`. It sits inside `cargo test` because what
it checks is arithmetic rather than pixels: the fragment stage and
the hit test have to agree about where a rounded edge is, and neither
layer 1 (which cannot run the shader) nor layer 2 (where a
half-pixel disagreement is invisible) can say whether they do. Reach
for this shape only when the question is "do these two
implementations of one function agree" -- anything about what is
*drawn* is still layer 2.
2. **A phone-shaped desktop window under headless sway -- for looking.**
cd iris && ./run-headless.sh phone --phone --shot /tmp/p.png -- -p transcript-fixture