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

+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