diff --git a/docs/IRIS.md b/docs/IRIS.md index 8dd1e74..7ed31af 100644 --- a/docs/IRIS.md +++ b/docs/IRIS.md @@ -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 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`**, 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 Iris's phone said "fling still doesn't work" twice. The velocity was only diff --git a/docs/LAYOUT.md b/docs/LAYOUT.md index 5c71a77..83db8d2 100644 --- a/docs/LAYOUT.md +++ b/docs/LAYOUT.md @@ -965,43 +965,62 @@ already produces an anti-aliased rounded edge from `distance_from_rect(pos, center, corner, radius)` with a half-pixel `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` - primitive draws with.** `Mask` gains `radius: f32` (one uniform - corner radius, matching `Rect.radius`; per-corner radii only when a - concrete need appears). The fragment stage computes coverage as - `1.0 - smoothstep(-min(edge, radius), edge, distance_from_rect(...))` - -- the *same expression* `draw_rounded_rect` uses, factored into one - function both call -- and does `color.a *= coverage`. So a mask whose - region and radius equal a rounded container's are clipped to exactly - the pixels that container fills, corner alpha included, because they - are the same arithmetic. `radius = 0` becomes a half-pixel - anti-aliased edge instead of today's hard cut, which is what `Rect` - does already, so a masked rect and an unmasked one look the same. -2. **Nested masks chain and multiply, like moves.** Today one - `mask_idx` per primitive; nesting two rectangles could be handled by - intersecting spans on the CPU, but the intersection of two rounded - rectangles is not a rounded rectangle. So `Mask` gains `parent: u32` - (the enclosing mask's slot, or the sentinel), the shader walks the - chain multiplying coverage, and the walk is bounded the way - `resolve_move` is (`MOVE_CHAIN_LIMIT`'s sibling; assert on overflow - in debug, print the chain). The painter's `set_mask` records the - current mask as the parent. Alpha multiplies rather than takes a - minimum, so a pixel in two feathered corners is dimmed by both -- - that is what "alpha should be multiplied" asks for and what a real - compositor does. -3. **The widget API: the container is the mask.** `Masked` takes a - `MaskShape` (`Rect`, `Rounded(radius)`); and the rounded `Rect` - widget, the thing a code block or card is already inside, gets a - `.masked()` builder that wraps its children in a `Masked` carrying - *its own* radius. One value, by construction, never a radius on the - container and a second one on the mask to keep in sync. The code - block in `transcript-ui/src/row.rs` (`.masked()` at the inner - rectangle) moves to masking at the rounded container instead. -4. **Hit-testing keeps the rectangle.** Input outside the rounded - corner but inside the box is a few pixels; not worth a second SDF - walk on the CPU. State this in the `Masked` doc so nobody "fixes" it. +1. **A mask is a reference to a primitive already drawn, plus how to + use it.** `Mask { kind, idx, flags, parent }`: the primitive's + binding (`RECT`, `TEXTURE`, `GLYPH`) and slot, flags (today one: + *alpha only* -- take the primitive's coverage and ignore its colour, + which is the default and the only mode until a need for another + appears), and the enclosing mask's slot for nesting. The fragment + stage evaluates the referenced primitive *at the masked pixel* -- + for a `Rect`, the same `draw_rounded_rect` coverage from the same + SDF; for a texture or glyph, the sampled alpha -- and does + `color.a *= coverage`. Nothing about the shape is copied: a rounded + container's corner and its children's clipped corner are the same + primitive's arithmetic, and a texture mask (an alpha image as the + clip) works with no new shader path. + What this needs from the data layout: evaluating a primitive at an + arbitrary pixel means its placement (its spans and `move_idx`, today + vertex attributes) has to be readable from a storage buffer in the + fragment stage. If it is not already there, put it there once, for + every primitive, rather than keeping a second copy for masks -- the + vertex stage can read the same buffer. Textures: the shader binds one + image at a time (see `masks_layout`'s comment on why an image's own + bind group must not name the masks buffer), so a texture mask is + limited to what the fragment can sample without a bind-group switch: + the atlas, and the primitive's own bound image when the masked + primitive is drawn in the same image's batch. Say so at the flag. +2. **Nested masks chain and multiply, like moves.** `parent` walks up + the chain, bounded like `resolve_move` (`MOVE_CHAIN_LIMIT`'s sibling; + debug-assert on overflow and print the chain); coverages multiply, + so a pixel inside two feathered corners is dimmed by both, which is + what a compositor does and what "alpha should be multiplied" asks. +3. **`.masked()` points the mask at the current widget's own + primitives.** `Masked` stops describing a region: it records which + primitive(s) the wrapping widget drew this frame (the painter knows + -- it just allocated the slots) and sets the mask to reference them. + So a rounded `Rect` widget's `.masked()` clips its children to + 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 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 masked child that overhangs all four sides and asserts the child's coverage at a corner pixel equals the container's own coverage there -(same SDF, so exactly equal, not approximately); a nested-mask test -asserts the product at a pixel inside both feathers; a +(same primitive evaluated, so exactly equal, not approximately); 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 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. diff --git a/docs/RUST.md b/docs/RUST.md index c193372..f6445d1 100644 --- a/docs/RUST.md +++ b/docs/RUST.md @@ -60,8 +60,9 @@ closes it. recent log; a debug button copies it; Dev Updater reads it), write the decision in docs/DECISIONS.md, build it. - [ ] Masks with a shape -- docs/LAYOUT.md "Masks with a shape (decided - 2026-09-07)". Rounded masks through the `Rect` SDF, chained and - multiplied, the container as the mask. + 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. - [ ] Compose app: the `Reversed range` crash in `ToolInput.highlighted` (docs/TODO.md). Main branch, not rustify.