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:
1 parent
232de0ec53
commit
1121d7cc83
3 files changed
+101
-40
No files matched your search
@@ -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
@@ -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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user