docs/LAYOUT.md: masks reference a drawn primitive instead of copying a shape, and hit-testing applies the shape (Iris, 2026-09-07)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-07 12:38:55 -04:00
1 parent 232de0ec53
commit 1121d7cc83
3 files changed
+101 -40

No files matched your search

+37
View File
@@ -8,6 +8,43 @@ 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 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-07: a headless harness, replayed touch, and physical-pixel desktop layout
Layer 1 and 2 of docs/RUST.md's "Three test layers".
**New: `iris::harness`** -- a screen driven in-process with no window, no
compositor and no GPU, on a clock the caller advances. `Harness::new(size,
density)` gives you an `Rsc`, a `UiRenderState` and a state that
implements `FocusHost`/`OpenUrl` by *recording* what the platform was
asked for (`keyboard_shown`, `opened_urls`) rather than doing it;
`frame(t_ms)`/`frames_until(..)` run frames, `touch(action, pos, t_ms)`
feeds one pointer sample the way Android's `on_touch_event` does, and
`replay(&TouchScript)` runs a whole recorded gesture. `TouchScript` parses
a plain `t_ms action x y` file (`down`/`move`/`up`/`cancel`), so the
batched 120Hz flick shape your phone actually delivers is a file that
`cargo test` can replay -- something the emulator cannot produce at all.
**New: `List::fling_velocity() -> Option<f32>`**, what the release
measured, readable where it landed rather than by re-timing the gesture.
**Changed: `List` starts a fling's curve at its first `tick_fling`, not
at the release.** The only clock it reads is now the one its driver hands
it; in a running app the difference is at most a frame.
**Changed: the desktop backend lays out in physical pixels with a
density, exactly as Android does.** `iris::default::content_scale(window)`
is the desktop's `content_scale` -- winit's scale factor, overridable with
the `IRIS_SCALE` environment variable -- and it now feeds
`UiRenderState::set_density`/`TextData::density` instead of dividing
coordinates into a separate "logical" space. That division had
`UiRenderState::resize` (physical) and the window uniform (logical)
disagreeing on any display whose scale factor is not 1.0, and rasterised
glyphs at one resolution to display them at another. `Input::event` lost
its `scale_factor` parameter as a result, and `DefaultUiState::
window_size()` now answers physical pixels. On a 1.0 display nothing
changes. The override is what lets `run-headless.sh --phone` open a window
at your phone's own 1080x2424 and 2.55.
## 2026-09-07: widgets can animate, and a fling finally moves ## 2026-09-07: widgets can animate, and a fling finally moves
Iris's phone said "fling still doesn't work" twice. The velocity was only Iris's phone said "fling still doesn't work" twice. The velocity was only
+61 -38
View File
@@ -965,43 +965,62 @@ already produces an anti-aliased rounded edge from
`distance_from_rect(pos, center, corner, radius)` with a half-pixel `distance_from_rect(pos, center, corner, radius)` with a half-pixel
`smoothstep`, and the border variant multiplies a second coverage in. `smoothstep`, and the border variant multiplies a second coverage in.
**Design.** **Design** (revised the same day on Iris's two corrections: hit-testing
applies the shape too, and a mask should reference a primitive rather
than carry a copy of its shape).
1. **A mask is a shape, and the shape is the same SDF the `Rect` 1. **A mask is a reference to a primitive already drawn, plus how to
primitive draws with.** `Mask` gains `radius: f32` (one uniform use it.** `Mask { kind, idx, flags, parent }`: the primitive's
corner radius, matching `Rect.radius`; per-corner radii only when a binding (`RECT`, `TEXTURE`, `GLYPH`) and slot, flags (today one:
concrete need appears). The fragment stage computes coverage as *alpha only* -- take the primitive's coverage and ignore its colour,
`1.0 - smoothstep(-min(edge, radius), edge, distance_from_rect(...))` which is the default and the only mode until a need for another
-- the *same expression* `draw_rounded_rect` uses, factored into one appears), and the enclosing mask's slot for nesting. The fragment
function both call -- and does `color.a *= coverage`. So a mask whose stage evaluates the referenced primitive *at the masked pixel* --
region and radius equal a rounded container's are clipped to exactly for a `Rect`, the same `draw_rounded_rect` coverage from the same
the pixels that container fills, corner alpha included, because they SDF; for a texture or glyph, the sampled alpha -- and does
are the same arithmetic. `radius = 0` becomes a half-pixel `color.a *= coverage`. Nothing about the shape is copied: a rounded
anti-aliased edge instead of today's hard cut, which is what `Rect` container's corner and its children's clipped corner are the same
does already, so a masked rect and an unmasked one look the same. primitive's arithmetic, and a texture mask (an alpha image as the
2. **Nested masks chain and multiply, like moves.** Today one clip) works with no new shader path.
`mask_idx` per primitive; nesting two rectangles could be handled by What this needs from the data layout: evaluating a primitive at an
intersecting spans on the CPU, but the intersection of two rounded arbitrary pixel means its placement (its spans and `move_idx`, today
rectangles is not a rounded rectangle. So `Mask` gains `parent: u32` vertex attributes) has to be readable from a storage buffer in the
(the enclosing mask's slot, or the sentinel), the shader walks the fragment stage. If it is not already there, put it there once, for
chain multiplying coverage, and the walk is bounded the way every primitive, rather than keeping a second copy for masks -- the
`resolve_move` is (`MOVE_CHAIN_LIMIT`'s sibling; assert on overflow vertex stage can read the same buffer. Textures: the shader binds one
in debug, print the chain). The painter's `set_mask` records the image at a time (see `masks_layout`'s comment on why an image's own
current mask as the parent. Alpha multiplies rather than takes a bind group must not name the masks buffer), so a texture mask is
minimum, so a pixel in two feathered corners is dimmed by both -- limited to what the fragment can sample without a bind-group switch:
that is what "alpha should be multiplied" asks for and what a real the atlas, and the primitive's own bound image when the masked
compositor does. primitive is drawn in the same image's batch. Say so at the flag.
3. **The widget API: the container is the mask.** `Masked` takes a 2. **Nested masks chain and multiply, like moves.** `parent` walks up
`MaskShape` (`Rect`, `Rounded(radius)`); and the rounded `Rect` the chain, bounded like `resolve_move` (`MOVE_CHAIN_LIMIT`'s sibling;
widget, the thing a code block or card is already inside, gets a debug-assert on overflow and print the chain); coverages multiply,
`.masked()` builder that wraps its children in a `Masked` carrying so a pixel inside two feathered corners is dimmed by both, which is
*its own* radius. One value, by construction, never a radius on the what a compositor does and what "alpha should be multiplied" asks.
container and a second one on the mask to keep in sync. The code 3. **`.masked()` points the mask at the current widget's own
block in `transcript-ui/src/row.rs` (`.masked()` at the inner primitives.** `Masked` stops describing a region: it records which
rectangle) moves to masking at the rounded container instead. primitive(s) the wrapping widget drew this frame (the painter knows
4. **Hit-testing keeps the rectangle.** Input outside the rounded -- it just allocated the slots) and sets the mask to reference them.
corner but inside the box is a few pixels; not worth a second SDF So a rounded `Rect` widget's `.masked()` clips its children to
walk on the CPU. State this in the `Masked` doc so nobody "fixes" it. itself by pointing at the rect it already draws; an image widget's
`.masked()` clips to its alpha. No radius or shape argument exists to
fall out of sync. When a widget draws more than one primitive (a
bordered rect is one primitive; a card with a stripe is two), the
mask references the *first* and the doc says so; a widget that wants
another names it.
4. **Hit-testing applies the shape.** A press is inside a masked
subtree only if the mask's coverage at that point is above one half.
For a `Rect` that is the same rounded-rect SDF evaluated on the CPU
-- one function in the shared crate, with the WGSL a transliteration
of it and a test that compares the two at a grid of points
(`headless` renders to a buffer and reads back, or the Rust version
is checked against the values the shader produced once and recorded).
For a texture, the CPU needs the alpha: keep the alpha channel of an
image used as a mask readable on the CPU (it was uploaded from CPU
memory; keeping the alpha plane is a quarter of the image), and read
it at the point. A masked corner that cannot be tapped and a masked
corner that is not drawn are then the same corner.
**Rejected.** A stencil buffer (a second pass per mask level and no **Rejected.** A stencil buffer (a second pass per mask level and no
anti-aliasing); the scissor rectangle (rectangles only, no alpha); anti-aliasing); the scissor rectangle (rectangles only, no alpha);
@@ -1011,8 +1030,12 @@ rendering a masked subtree to an offscreen texture and compositing
**Pass conditions.** A headless test draws a rounded container with a **Pass conditions.** A headless test draws a rounded container with a
masked child that overhangs all four sides and asserts the child's masked child that overhangs all four sides and asserts the child's
coverage at a corner pixel equals the container's own coverage there coverage at a corner pixel equals the container's own coverage there
(same SDF, so exactly equal, not approximately); a nested-mask test (same primitive evaluated, so exactly equal, not approximately); a
asserts the product at a pixel inside both feathers; a nested-mask test asserts the product at a pixel inside both feathers; a
texture-mask test clips a rect to an alpha image and asserts a
transparent texel masks fully; a hit-test asserts a press in a
container's clipped corner misses and one just inside the curve hits,
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 `run-headless.sh --phone` screenshot of a scrolled code block shows
rounded corners with no square pixels poking out at the top and bottom 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. of the scrolled content. Record the commands in RUST.md when it lands.
+3 -2
View File
@@ -60,8 +60,9 @@ closes it.
recent log; a debug button copies it; Dev Updater reads it), write recent log; a debug button copies it; Dev Updater reads it), write
the decision in docs/DECISIONS.md, build it. the decision in docs/DECISIONS.md, build it.
- [ ] Masks with a shape -- docs/LAYOUT.md "Masks with a shape (decided - [ ] Masks with a shape -- docs/LAYOUT.md "Masks with a shape (decided
2026-09-07)". Rounded masks through the `Rect` SDF, chained and 2026-09-07)". A mask references a primitive already drawn
multiplied, the container as the mask. (rect SDF, texture or glyph alpha), chained and multiplied; `.masked()`
points at the widget's own primitives; hit-testing applies the shape.
- [ ] Compose app: the `Reversed range` crash in `ToolInput.highlighted` - [ ] Compose app: the `Reversed range` crash in `ToolInput.highlighted`
(docs/TODO.md). Main branch, not rustify. (docs/TODO.md). Main branch, not rustify.